diff --git a/.gitignore b/.gitignore index 2918ae32a69..b1de9292f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ docs/** !docs/reference/headless-linux-server.md !docs/reference/linux-glibc-compatibility.md !docs/reference/relay-grace-time-reconfiguration.md +!docs/reference/windows-setup-shell.md # Stably CLI (only docs/ are tracked) .stably/* diff --git a/AGENTS.md b/AGENTS.md index bfb1617f8ea..6bc54a6f5c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh - **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`. - **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms. - **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`. +- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md). - **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc. ## SSH Use Case diff --git a/README.md b/README.md index b161a52b71a..670b54e2835 100644 --- a/README.md +++ b/README.md @@ -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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) +- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) --- diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index a7ad171a402..13499118b65 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [ 'codex/codex-app-server-grant-entry' ] as const +// Entries executed as worker threads of the main process. Electron's module is +// not registered on worker threads, so require("electron") throws +// "Cannot find module 'electron'" there too (verified on Electron 43) and kills +// the worker at startup. These carry hand-written "must stay electron-free" +// comments, which is convention, not enforcement — and the port-scan worker in +// particular sits one import away from a client module that deliberately does +// require electron. +const WORKER_THREAD_ENTRY_NAMES = [ + 'stt-worker', + 'warp-theme-parser-worker', + 'session-scanner-opencode-sqlite-worker-entry', + 'main-thread-hang-watchdog-entry', + 'port-scan-command-worker-entry' +] as const + +type EntryRuntime = 'plain-Node process' | 'worker thread' + const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/ function collectReachableChunks( @@ -56,13 +73,14 @@ function collectReachableChunks( function assertNoElectronRequire( entryName: string, entry: OutputChunk, - byFileName: Map + byFileName: Map, + runtime: EntryRuntime = 'plain-Node process' ): void { for (const chunk of collectReachableChunks(entry, byFileName)) { if (ELECTRON_REQUIRE_RE.test(chunk.code)) { throw new Error( `[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` + - `requires electron. "${entryName}" runs as a plain-Node process, where ` + + `requires electron. "${entryName}" runs as a ${runtime}, where ` + `require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` + `v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.` ) @@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin { for (const entryName of PLAIN_NODE_ENTRY_NAMES) { const entry = entryByName.get(entryName) if (entry) { - assertNoElectronRequire(entryName, entry, byFileName) + assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process') + } + } + + for (const entryName of WORKER_THREAD_ENTRY_NAMES) { + const entry = entryByName.get(entryName) + if (entry) { + assertNoElectronRequire(entryName, entry, byFileName, 'worker thread') } } diff --git a/config/knip.json b/config/knip.json index a48793b7902..cb929266ff3 100644 --- a/config/knip.json +++ b/config/knip.json @@ -10,6 +10,7 @@ "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", "src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts", + "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", "src/main/codex/codex-app-server-grant-entry.ts", diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index c84075e6c04..0a010442e1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -10,6 +10,101 @@ } }, "gates": [ + { + "id": "editor.restored-sibling-owner-reparent", + "title": "Restored sibling tabs migrate filesystem authority before becoming editable", + "maturity": "experimental", + "protection": "partial", + "owner": "editor-runtime", + "layer": "renderer-store-controller-contract", + "surfaces": [ + "restored editor tabs", + "editor save and autosave", + "filesystem watches and hot-exit persistence" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "ssh", "paired-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh", "paired-runtime"], + "coverageNotes": "Deterministic store/controller tests cover local, direct-SSH, paired-runtime identity, folder workspaces, queue quiescence, stale route and connection-generation rejection, authoritative active-workspace projection, owner-derived IDs, drafts and editor maps, tab/group placement, exact watch replacement, change/delete/rename correlation, explicit save, autosave, collision refusal without activation reconciliation, provenance, and restart hot-exit persistence. Live headed/headless paired-runtime and post-establish Electron IPC remain uncollected.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11304", + "https://github.com/stablyai/orca/pull/11369" + ], + "invariant": "A restored absolute path owned by a same-host sibling workspace cannot read, save, watch, or persist until old saves drain, the exact route and host generations are revalidated, and one authoritative activation transaction installs the sibling owner, target projection, editor state, reconciliation, and first-activation terminal preparation; collisions and stale routes fail before activation preparation or reconciliation.", + "oracle": "Hold an old-owner save in flight, start migration, then remove the sibling, change a folder root, or reconnect SSH and require owner migration to fail closed after quiescence. On a stable route require one coherent target-workspace projection, first-activation terminal generation preparation, destination authority for explicit save and autosave, exact watch replacement, destination-only change/delete/rename handling, owner-derived state rekeys, and hot-exit restart ownership. Seed an invalid destination tab/group model before a collision and require the target projection to remain byte-identical.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "testFiles": [ + "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "src/renderer/src/lib/runtime-workspace-file-route.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts", + "assertions": [ + "one atomic commit rekeys owner-derived editor, preview, draft, cursor, view, tab, group, active, reveal, focus, provenance, and persistence state", + "active reparenting uses the centralized workspace activation transaction for terminal, browser, pending-creation, explorer, first-activation, and post-commit state", + "source watch unsubscribes once, destination watch subscribes once, and only destination change/delete/rename events reach the file", + "dirty and clean destination collisions fail closed without changing either open session or reconciling destination tabs and groups", + "folder and direct-SSH owners retain exact root, host, target, and connection-generation authority" + ] + }, + { + "file": "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts", + "assertions": [ + "migration waits for the old save queue and routes later explicit and automatic saves through the destination worktree", + "a removed sibling, changed folder root, or changed direct-SSH generation after quiescence rejects migration without changing ownership" + ] + }, + { + "file": "src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "assertions": [ + "same-host runtime, folder, local, and direct-SSH roots resolve without cross-host fallback" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-03", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts", + "result": "passed", + "durationSeconds": 3, + "summary": "Three deterministic files passed 20 lifecycle, stale-route, activation, ownership, host, collision, persistence, watch, and save tests." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused renderer store/controller contract on a local development runner" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One local deterministic run is recorded; CI and soak history have not started." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The final2 verifier's byte-identical post-quiescence lifecycle scope passed 21/21 on 7890271160 and failed 7 tests on f92db196a1; base e08eba674c, published 1bcfe3bcb0, and revert b3627461ab were structurally red. The permanent collision oracle is in test blob ac62a89c671a0c8f081ebf06736715bd2150ce9f (SHA-256 a61970ace80be1cb56f781aee6f878a8446c6c4527f9eb0cd0c151d8df96bf50). The same byte-identical blob is structurally red on base, published, and revert, behaviorally red on rejected final2 candidate 7890271160 because pre-commit reconciliation removes the stale destination tab, and green on implementation b72a838cdc; the full permanent gate passes 20/20." + }, + "performanceBudget": { + "required": true, + "evidence": "Route lookup scans only the already-indexed renderer workspace catalog, while migration is O(open editor files + source/destination tabs/groups) and performs no provider call, Git scan, polling, retry, subprocess, or global fanout beyond the required old/new watch delta." + }, + "promotionCriteria": [ + "Collect two fresh child-verifier decisions against the correction candidate.", + "Collect headed and headless paired-runtime coverage after the post-establish Electron IPC gap is fixed.", + "Collect physical Windows or WSL path/host evidence and CI soak history." + ], + "knownGaps": [ + "Headed Electron post-establish IPC, paired headless, reconnect, and physical Windows/WSL journeys are uncollected.", + "Cold many-repo main-process sibling discovery remains a separately measured follow-up.", + "Registered-workspace symlink policy remains inherited and unchanged." + ], + "demotionRule": "Keep experimental or demote if migration can race a save, retain source authority, fan out watches, lose dirty/editor state, accept an owner-generation change, merge a collision, or the focused contract flakes without a product or harness bug." + }, { "id": "terminal-provider.login-session-retirement", "title": "macOS login-session retirement preserves live daemons through transient rejection bursts", @@ -2243,6 +2338,120 @@ ], "demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one." }, + { + "id": "agent-status.manual-compact-identity", + "title": "Manual Claude compact completion retires only its exact status generation", + "maturity": "experimental", + "protection": "partial", + "owner": "agent-session", + "layer": "shared-main-relay-contract", + "surfaces": [ + "Claude status hooks", + "manual compact lifecycle", + "last-status persistence", + "SSH relay ingest" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["local", "ssh"], + "coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/issues/11352", + "https://github.com/stablyai/orca/pull/11353" + ], + "invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.", + "oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id" + ], + "testFiles": [ + "src/shared/manual-compact-prompt-identity.test.ts", + "src/main/agent-hooks/manual-compact-hook-stream.test.ts", + "src/main/agent-hooks/manual-compact-status-cleanup.test.ts", + "src/shared/agent-hook-listener.test.ts", + "src/shared/agent-hook-relay.test.ts", + "src/relay/agent-hook-server.test.ts", + "src/main/agent-hooks/server.test.ts", + "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts" + ], + "assertionRefs": [ + { + "file": "src/shared/manual-compact-prompt-identity.test.ts", + "assertions": [ + "matching manual identity settles once", + "stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed", + "4,097 rejected pane keys allocate no ownership state" + ] + }, + { + "file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts", + "assertions": [ + "local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities", + "later provider work and wrong transport identity remain authoritative", + "persisted PreCompact restores its prompt" + ] + }, + { + "file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts", + "assertions": ["pane, tab, and server cleanup revoke compact completion authority"] + }, + { + "file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts", + "assertions": [ + "SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-08-02", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 2.66, + "summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage." + }, + { + "date": "2026-08-02", + "runner": "manual", + "platform": "macos", + "command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id", + "result": "passed", + "durationSeconds": 120, + "summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled." + } + ], + "runtimeBudget": { + "p95Seconds": 20, + "scope": "shared, main-process, relay, persistence, and fake-SSH contract tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "New deterministic gate with one local run; CI soak history is not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The byte-identical oracle (SHA-256 24e80c15b467ffa9c47a81099f11bf26dea71293e47b5d950b2fad1497094764) failed 5 of 6 on origin/main@a20165a43d, passed 6 of 6 on the candidate, failed 3 of 6 when only the exact-transition guard was disabled, and passed 6 of 6 after restoration." + }, + "performanceBudget": { + "required": true, + "evidence": "Each compact event performs one O(1) current-status lookup plus bounded UUID, source, connection, session, and event comparisons. Identity lives on the existing status row and is removed by its existing lifecycle; the change adds no owner map, global scan, polling, timer, subprocess, network request, or listener. The 4,097-key adversarial arm retains zero status, prompt, or ownership entries." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run live manual compact journeys on Linux and Windows and through a real SSH target.", + "Keep automatic compact completion disabled until a generation-unique provider identity is proven." + ], + "knownGaps": [ + "No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.", + "Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.", + "Automatic Claude and Kimi completion remain intentionally fail-closed because their hooks do not prove an exact compact generation." + ], + "demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug." + }, { "id": "agent-session.provider-ownership", "title": "Provider sessions are resumed once per workspace ownership claim", @@ -6646,15 +6855,16 @@ "providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "daemon", "ssh"], - "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", + "coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"], - "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", - "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", + "invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.", + "oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot", "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts", "pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1", "pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1" @@ -6663,6 +6873,7 @@ "src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts", "src/main/runtime/orchestration/formatter.test.ts", "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", + "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", "src/main/runtime/orca-runtime.test.ts", "src/cli/handlers/orchestration.test.ts", "src/cli/handlers/orchestration-check-identity.test.ts", @@ -6684,6 +6895,13 @@ "ordinary mail and remote attachments use the same attested pane and process authority" ] }, + { + "file": "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts", + "assertions": [ + "creator lookup uses the assignee-handle and pane-leaf indexes without a retained-Run scan", + "300 Task reads remain bounded with 50,000 unrelated retained Runs" + ] + }, { "file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts", "assertions": [ diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index 6e4803ed7ba..4bb219a5687 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -14,6 +14,21 @@ import { describe, expect, it } from 'vitest' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') +const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM'] + +// Why: Windows releases the image handle on a just-executed exe (and finishes the +// AV scan of the freshly compiled one) after the process exits, so tearing down the +// fixture races those locks. Retry, then leave the temp tree rather than reporting a +// teardown lock as a launcher failure. +function removeFixtureTree(path) { + try { + rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } catch (error) { + if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) { + throw error + } + } +} // Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows; // keep the larger allowance scoped to the real compiler integration test. function itWindows(name, test) { @@ -35,7 +50,7 @@ describe('Windows CLI launcher', () => { expect(result.stderr).toContain('Windows CLI launcher') expect(result.stderr).toContain('Windows host') } finally { - rmSync(outputRoot, { recursive: true, force: true }) + removeFixtureTree(outputRoot) } }) @@ -108,7 +123,7 @@ describe('Windows CLI launcher', () => { orcaNodeOptions: '--no-warnings' }) } finally { - rmSync(appRoot, { recursive: true, force: true }) + removeFixtureTree(appRoot) } }) @@ -161,7 +176,7 @@ describe('Windows CLI launcher', () => { pathKeys: ['PATH', 'Path'] }) } finally { - rmSync(appRoot, { recursive: true, force: true }) + removeFixtureTree(appRoot) } }) }) diff --git a/config/scripts/happy-dom-mutation-observer-retention.test.ts b/config/scripts/happy-dom-mutation-observer-retention.test.ts new file mode 100644 index 00000000000..475a84c9b31 --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.test.ts @@ -0,0 +1,72 @@ +/** @vitest-environment happy-dom */ +import { describe, expect, it } from 'vitest' + +import { + installHappyDomMutationObserverRetention, + retainedMutationCallbackCount +} from './happy-dom-mutation-observer-retention' + +function readListenerCallbacks(target: Node): unknown[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === 'mutationListeners' + ) + const listeners = listenersSymbol + ? (target as unknown as Record)[listenersSymbol] + : [] + if (!Array.isArray(listeners)) { + return [] + } + return listeners.map((listener: { callback?: { deref: () => unknown } }) => + listener.callback?.deref() + ) +} + +describe('happy-dom MutationObserver retention', () => { + it('pins the internal callback that happy-dom only holds weakly', () => { + expect(installHappyDomMutationObserverRetention()).toBe(true) + const target = document.createElement('div') + document.body.append(target) + const observer = new MutationObserver(() => {}) + observer.observe(target, { childList: true, subtree: true }) + + const callbacks = readListenerCallbacks(target) + expect(callbacks.length).toBe(1) + expect(callbacks[0]).toBeTypeOf('function') + expect(retainedMutationCallbackCount(observer)).toBe(1) + + observer.disconnect() + expect(retainedMutationCallbackCount(observer)).toBe(0) + target.remove() + }) + + it('keeps delivering records after the weak callback would have been collected', async () => { + installHappyDomMutationObserverRetention() + const target = document.createElement('div') + document.body.append(target) + let deliveries = 0 + const observer = new MutationObserver(() => { + deliveries += 1 + }) + observer.observe(target, { childList: true, subtree: true }) + + target.replaceChildren(document.createElement('div')) + await Promise.resolve() + expect(deliveries).toBe(1) + + const collectGarbage = (globalThis as { gc?: () => void }).gc + if (collectGarbage) { + for (let round = 0; round < 5; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 1)) + collectGarbage() + } + await new Promise((resolve) => setTimeout(resolve, 1)) + } + + target.replaceChildren(document.createElement('span')) + await Promise.resolve() + expect(deliveries).toBe(2) + + observer.disconnect() + target.remove() + }) +}) diff --git a/config/scripts/happy-dom-mutation-observer-retention.ts b/config/scripts/happy-dom-mutation-observer-retention.ts new file mode 100644 index 00000000000..a315b3d520c --- /dev/null +++ b/config/scripts/happy-dom-mutation-observer-retention.ts @@ -0,0 +1,81 @@ +// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause +// under parallel test load silently and permanently stops a still-connected observer. Browsers +// keep that callback reachable for as long as the observer observes; mirror that lifetime here so +// DOM-driven tests never lose mutation records mid-run. + +type HappyDomMutationListener = { + callback?: { deref: () => unknown } +} + +type PatchableMutationObserver = { + observe: (target: Node, options?: MutationObserverInit) => void + disconnect: () => void +} + +const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners' +const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention') + +const retainedCallbacks = new WeakMap>() + +function readMutationListeners(target: Node): HappyDomMutationListener[] { + const listenersSymbol = Object.getOwnPropertySymbols(target).find( + (candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION + ) + if (!listenersSymbol) { + return [] + } + const listeners = (target as unknown as Record)[listenersSymbol] + return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : [] +} + +/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */ +export function retainedMutationCallbackCount(observer: MutationObserver): number { + return retainedCallbacks.get(observer)?.size ?? 0 +} + +export function installHappyDomMutationObserverRetention(): boolean { + const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver }) + .MutationObserver + if (!observerClass) { + return false + } + const prototype = observerClass.prototype as unknown as PatchableMutationObserver & + Record + if (prototype[RETENTION_INSTALLED] === true) { + return true + } + const observe = prototype.observe + const disconnect = prototype.disconnect + + prototype.observe = function patchedObserve( + this: object, + target: Node, + options?: MutationObserverInit + ): void { + const existing = new Set(readMutationListeners(target)) + observe.call(this as unknown as PatchableMutationObserver, target, options) + const pinned = retainedCallbacks.get(this) ?? new Set() + for (const listener of readMutationListeners(target)) { + if (existing.has(listener)) { + continue + } + const callback = listener.callback?.deref() + if (callback) { + pinned.add(callback) + } + } + if (pinned.size > 0) { + retainedCallbacks.set(this, pinned) + } + } + + prototype.disconnect = function patchedDisconnect(this: object): void { + disconnect.call(this as unknown as PatchableMutationObserver) + retainedCallbacks.delete(this) + } + + prototype[RETENTION_INSTALLED] = true + return true +} + +installHappyDomMutationObserverRetention() diff --git a/config/scripts/locale-cross-locale-key-overrides.mjs b/config/scripts/locale-cross-locale-key-overrides.mjs index 124fb8f32e5..85b937b3244 100644 --- a/config/scripts/locale-cross-locale-key-overrides.mjs +++ b/config/scripts/locale-cross-locale-key-overrides.mjs @@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = { 'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': { zh: '测试版', ja: 'ベータ' + }, + // Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state. + 'auto.components.PullRequestPage.7b8f6bf6d8': { + zh: '开放' + }, + // This "Open" is the button that opens the config file, not an issue state. + 'auto.components.settings.McpConfigFileRow.e720c139cd': { + zh: '打开' + }, + // Terminal cursor-color group: the on-screen cursor, not the Cursor editor. + 'auto.components.settings.TerminalWindowSection.c9e1fdf42f': { + ko: '커서', + zh: '光标' + }, + 'auto.components.onboarding.ThemeStep.ab2a583a97': { + ko: '커서', + zh: '光标' + }, + // Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors. + 'auto.components.sidebar.workspace.status.6437a8c253': { + zh: '天蓝', + ja: '空色' + }, + 'auto.components.sidebar.workspace.status.caabd5ca85': { + zh: '锌灰' } } diff --git a/config/scripts/locale-generic-ui-terms.mjs b/config/scripts/locale-generic-ui-terms.mjs new file mode 100644 index 00000000000..2b41a324ce6 --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.mjs @@ -0,0 +1,102 @@ +// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate +// these, so the policy must neither pin the whole value to English nor revert the translation +// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin +// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word. + +// Renderings a locale is expected to use. They are translations, never machine-translation +// errors, so a brand revert must leave them alone. Nonsense forms that share the same English +// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse") +// stay in BRAND_MISTRANSLATIONS and keep getting reverted. +const GENERIC_TERM_FAMILIES = [ + { + terms: ['Agent', 'Agents', 'agent', 'agents'], + renderings: { + ko: ['에이전트'], + ja: ['エージェント'], + zh: ['代理', '智能体'], + es: ['Agente', 'agente', 'Agentes', 'agentes'] + } + }, + { + terms: ['Commit', 'Commits', 'commit', 'commits'], + renderings: { + ko: ['커밋'], + ja: ['コミット'], + zh: ['提交'], + es: [ + 'Confirmación', + 'confirmación', + 'Confirmaciones', + 'confirmaciones', + 'Confirmar', + 'confirmar' + ] + } + }, + { + terms: ['Continue'], + renderings: { + ko: ['계속하다', '계속'], + ja: ['続ける', '続行'], + zh: ['继续'], + es: ['Continuar', 'continuar'] + } + }, + { + terms: ['Repo', 'Repos', 'repo', 'repos'], + renderings: { + ko: ['저장소', '레포'], + ja: ['リポジトリ', 'リポ'], + zh: ['存储库', '仓库'], + es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios'] + } + }, + { + terms: ['Terminal', 'Terminals', 'terminal', 'terminals'], + renderings: { + ko: ['터미널'], + ja: ['ターミナル'], + zh: ['终端'], + es: ['Terminales', 'terminales'] + } + } +] + +export const LOCALIZABLE_GENERIC_TERMS = new Set( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms) +) + +const RENDERINGS_BY_TERM = new Map( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings])) +) + +export function isLocalizableGenericTerm(term) { + return LOCALIZABLE_GENERIC_TERMS.has(term) +} + +export function isCanonicalGenericRendering(term, locale, form) { + return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false +} + +export function canonicalGenericRenderings(locale) { + return GENERIC_TERM_FAMILIES.flatMap((family) => + (family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms })) + ) +} + +function spansOf(value, needle) { + const spans = [] + for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) { + spans.push([at, at + needle.length]) + } + return spans +} + +// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the +// electrical-connector mistranslation would cut a valid word in half. +export function overlapsCanonicalRendering(term, locale, value, start, end) { + const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? [] + return renderings.some((form) => + spansOf(value, form).some(([from, to]) => start < to && from < end) + ) +} diff --git a/config/scripts/locale-generic-ui-terms.test.mjs b/config/scripts/locale-generic-ui-terms.test.mjs new file mode 100644 index 00000000000..42a7d63d0b3 --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.test.mjs @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs' +import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs' + +// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because +// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin +// the boundary: generic words stay translated, real brand names stay Latin. +describe('locale generic UI terms', () => { + it('keeps a translated generic term when the whole value is that term', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AppearancePane.terminalTitle', + enValue: 'Terminal', + localeValue: '터미널', + locale: 'ko' + }) + ).toBe('터미널') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.QuickCommandsPane.4ccc63da87', + enValue: 'Agent', + localeValue: '에이전트', + locale: 'ko' + }) + ).toBe('에이전트') + expect( + repairTranslatedValue({ + key: 'auto.components.mobile.MobileHero.a8fb43cf1c', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('继续') + }) + + it('keeps a translated generic term inside a sentence', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.EditorFontSection.matchTerminal', + enValue: 'Match terminal font', + localeValue: '터미널 글꼴과 동일', + locale: 'ko' + }) + ).toBe('터미널 글꼴과 동일') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel', + enValue: 'Toggle Agent Dashboard', + localeValue: '에이전트 대시보드 전환', + locale: 'ko' + }) + ).toBe('에이전트 대시보드 전환') + expect( + repairTranslatedValue({ + key: 'components.agentSessionContinuation.dialogTitle', + enValue: 'Continue in New Session', + localeValue: '新しいセッションで続ける', + locale: 'ja' + }) + // 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive. + ).toBe('新規セッションで続ける') + }) + + it('still reverts real brand names that machine translation localized', () => { + expect( + repairTranslatedValue({ + key: 'auto.stats.StatsPane.7d26110cea', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.appearance.search.9ae151b26b', + enValue: 'linear', + localeValue: '线性', + locale: 'zh' + }) + ).toBe('Linear') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.NetworkPane.tailscale', + enValue: 'Connect over Tailscale', + localeValue: '通过尾鳞连接', + locale: 'zh' + }) + ).toBe('通过 Tailscale 连接') + }) + + it('still reverts nonsense renderings of a generic term', () => { + // 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge". + expect( + repairTranslatedValue({ + key: 'auto.components.agent.AgentCombobox.986f946354', + enValue: 'Blank Terminal', + localeValue: '空白端子', + locale: 'zh' + }) + ).toBe('空白 Terminal') + expect( + repairTranslatedValue({ + key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', + enValue: 'Commit', + localeValue: 'Comprometerse', + locale: 'es' + }) + ).toBe('Commit') + expect( + repairTranslatedValue({ + key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', + enValue: 'Markdown', + localeValue: '가격 인하', + locale: 'ko' + }) + ).toBe('Markdown') + }) + + it('does not cut a valid word that contains a nonsense rendering', () => { + // 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子. + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1', + enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.', + localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。', + locale: 'zh' + }) + ).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。') + }) + + it('still fills in a translation when the catalog holds English', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', + enValue: 'Terminal 1', + localeValue: 'Terminal 1', + locale: 'ko' + }) + ).toBe('터미널 1') + expect( + repairTranslatedValue({ + key: 'auto.store.slices.worktrees.889487d8bb', + enValue: 'Dismiss', + localeValue: '해고하다', + locale: 'ko' + }) + ).toBe('닫기') + }) + + it('keeps agent catalog product names in English by key, not by word', () => { + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.9e2a9bb87b', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('Continue') + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.760bc6883d', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + }) + + it('does not list a generic term as never-translatable', () => { + const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term)) + expect(pinned).toEqual([]) + }) +}) diff --git a/config/scripts/locale-ja-value-overrides.mjs b/config/scripts/locale-ja-value-overrides.mjs index 7f9eeb60af7..30954473186 100644 --- a/config/scripts/locale-ja-value-overrides.mjs +++ b/config/scripts/locale-ja-value-overrides.mjs @@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = { 'Launch plan': '起動プラン', 'Launch agent': 'エージェントを起動', 'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動', - Play: 'Play', + Play: '再生', Action: '操作', Actions: '操作', action: '操作', @@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = { 'Pick a base branch below': '以下のベースブランチを選択', 'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択', 'Local project, Git repo, or folder with many repos': - 'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー', + 'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', 'Enter passphrase': 'パスフレーズを入力', 'Enter password': 'パスワードを入力', 'Enter the passphrase for': 'のパスフレーズを入力', diff --git a/config/scripts/locale-key-overrides.mjs b/config/scripts/locale-key-overrides.mjs index d0577e80bab..d1b4b853bd7 100644 --- a/config/scripts/locale-key-overrides.mjs +++ b/config/scripts/locale-key-overrides.mjs @@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = { ja: 'クローズ' }, 'auto.components.GitHubItemDialog.dc1ca081a8': { - ko: '진행 중', - zh: '进行中', + ko: '열림', + zh: '开放', ja: 'オープン' }, 'auto.components.tab.bar.TabBarCreateEntry.b27864279e': { @@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = { zh: '打开 Linear 任务', ja: 'Linear タスクを開く' }, + // Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action. 'auto.components.sidebar.SidebarNav.c86d83b5c3': { - ko: '새로 만들기', - zh: '新建', - ja: '新規' + ko: '신규', + zh: '新功能', + ja: '新機能' }, 'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': { ko: 'Discord', diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 9b40ad61f50..e8d13941ffa 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -752,6 +752,21 @@ "auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": { "ko": "{{value1}}단계 중 {{value0}}단계" }, + "auto.components.contextual.tours.ContextualTourOverlaySurface.complete": { + "ko": "완료" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": { + "ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": { + "ko": "자동화란 무엇인가요?" + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": { + "ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + }, + "auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": { + "ko": "결과 확인" + }, "auto.components.crash.report.CrashReportDialog.88fea8e84e": { "ko": "보내지 않음" }, @@ -1868,6 +1883,9 @@ "auto.components.right.sidebar.SourceControl.3a231c845b": { "ko": "알 수 없음" }, + "auto.components.right.sidebar.SourceControl.6d7f2a47e5": { + "ko": "폴더의 변경 사항 취소" + }, "auto.components.right.sidebar.SourceControl.812cb992ee": { "ko": "{{value0}}에서 열기" }, @@ -1880,6 +1898,9 @@ "auto.components.right.sidebar.SourceControl.9bb062a886": { "ko": "commit 되지 않음" }, + "auto.components.right.sidebar.SourceControl.a5e5a11090": { + "ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다." + }, "auto.components.right.sidebar.SourceControl.aaf1451654": { "ko": "초안 {{value0}} 만들기" }, @@ -1994,6 +2015,9 @@ "auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": { "ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?" }, + "auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": { + "ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다." + }, "auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": { "ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?" }, @@ -2015,6 +2039,9 @@ "auto.components.right.sidebar.source.control.primary.action.1d47e850cf": { "ko": "연결된 리뷰 브랜치에 업데이트 푸시" }, + "auto.components.right.sidebar.source.control.primary.action.5a477d80cb": { + "ko": "모든 변경 사항 스테이징" + }, "auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": { "ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요." }, @@ -2162,6 +2189,9 @@ "auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": { "ko": "{assistantMessage}" }, + "auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": { + "ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다." + }, "auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": { "ko": "fix-login-flow" }, @@ -2228,6 +2258,9 @@ "auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": { "ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다." }, + "auto.components.settings.DevToolsPane.orcaCloudDescription": { + "ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다." + }, "auto.components.settings.DeveloperPermissionsPane.16381e040a": { "ko": "마이크" }, @@ -2255,6 +2288,12 @@ "auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": { "ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다." }, + "auto.components.settings.EphemeralVmsPane.recipes": { + "ko": "레시피" + }, + "auto.components.settings.EphemeralVmsPane.whatTitle": { + "ko": "이 스킬로 함께 하는 작업" + }, "auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": { "ko": "편집기" }, @@ -2727,7 +2766,7 @@ "ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다." }, "auto.components.settings.SshTargetForm.137e88ce8d": { - "ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:" + "ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다." }, "auto.components.settings.SshTargetForm.2ee9bcd2e8": { "ko": "server, deploy@server:2222, ssh://server" @@ -3710,6 +3749,9 @@ "auto.components.settings.ssh.search.f9493b80c0": { "ko": "서버" }, + "auto.components.settings.source.control.action.recipe.options.commitMessage": { + "ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다." + }, "auto.components.settings.task.tracker.integration.cards.1a12e33fe5": { "ko": "Linear 액세스 추가" }, @@ -4683,7 +4725,7 @@ "ko": "중지 중" }, "auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": { - "ko": "Space" + "ko": "저장 공간" }, "auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": { "ko": "확보 가능" @@ -4953,7 +4995,7 @@ "ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다." }, "auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": { - "ko": "Space" + "ko": "저장 공간" }, "auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": { "ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간." diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 1d128a67897..19db7c753ec 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = { 'Install the Orca skill so agents know to use the Orca CLI.': '에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.', 'Local project, Git repo, or folder with many repos': - '로컬 프로젝트, Git repo 또는 repos가 많은 폴더', + '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.': 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.', 'changed since you last approved. Re-review before it runs': diff --git a/config/scripts/locale-phrase-fixes.mjs b/config/scripts/locale-phrase-fixes.mjs index fec68ef8b5d..b3c8026cea5 100644 --- a/config/scripts/locale-phrase-fixes.mjs +++ b/config/scripts/locale-phrase-fixes.mjs @@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = { { pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' }, { pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' }, { pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' }, - { pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' }, - { pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' }, - { pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' }, + // Swatch labels want the bare color noun, not the adjectival 的 form — and not English. + { pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' }, + { pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' }, + { pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' }, { pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' }, { pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' }, ...ZH_PHRASE_FIXES_ROUND5 diff --git a/config/scripts/locale-prose-term-exemptions.mjs b/config/scripts/locale-prose-term-exemptions.mjs deleted file mode 100644 index fbe2d483fa1..00000000000 --- a/config/scripts/locale-prose-term-exemptions.mjs +++ /dev/null @@ -1,19 +0,0 @@ -const LOCALIZED_PROSE_TERM_KEYS = new Set([ - 'auto.hooks.useMacosTccPromptNotice.description', - 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8' -]) - -const LOCALIZABLE_PROSE_TERMS = new Set([ - 'Agent', - 'Agents', - 'agent', - 'agents', - 'Terminal', - 'Terminals', - 'terminal', - 'terminals' -]) - -export function isLocalizedProseTermContext(term, key) { - return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term) -} diff --git a/config/scripts/locale-repair-catalog-missing-leaves.test.mjs b/config/scripts/locale-repair-catalog-missing-leaves.test.mjs new file mode 100644 index 00000000000..65e142e5149 --- /dev/null +++ b/config/scripts/locale-repair-catalog-missing-leaves.test.mjs @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' + +import { repairCatalog } from './locale-translation-policy.mjs' + +// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet +// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work. +describe('repairCatalog with un-bootstrapped keys', () => { + const enCatalog = { + auto: { + lib: { agent: { catalog: { '760bc6883d': 'Codex' } } }, + components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } } + } + } + + const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } }) + + it('skips leaves the locale catalog is missing instead of throwing', () => { + for (const locale of ['ko', 'ja', 'zh', 'es']) { + const localeCatalog = translatedOnly() + expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow() + expect(localeCatalog.auto.components, locale).toBeUndefined() + } + }) + + it('still repairs the leaves that are present', () => { + const localeCatalog = translatedOnly() + expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1) + expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex') + }) +}) diff --git a/config/scripts/locale-translation-policy-ko-round5.test.mjs b/config/scripts/locale-translation-policy-ko-round5.test.mjs index b3f88e4e141..e03157e0bc6 100644 --- a/config/scripts/locale-translation-policy-ko-round5.test.mjs +++ b/config/scripts/locale-translation-policy-ko-round5.test.mjs @@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.', locale: 'ko' }) - ).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') + ).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') expect( repairTranslatedValue({ key: 'auto.components.editor.MarkdownPreview.322afab6ff', @@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => { ).toBe('파이프라인') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '터미널 1', locale: 'ko' }) - ).toBe('Terminal 1') + ).toBe('터미널 1') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트 스킬', locale: 'ko' }) - ).toBe('Agent 스킬') + ).toBe('에이전트 스킬') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '푸시되지 않은 커밋', locale: 'ko' }) - ).toBe('푸시되지 않은 commits') + ).toBe('푸시되지 않은 커밋') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '레포', locale: 'ko' }) - ).toBe('Repo') + ).toBe('레포') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', locale: 'ko' }) - ).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더') + ).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더') }) it('re-glues Korean particles after Latin terms without gluing content words', () => { diff --git a/config/scripts/locale-translation-policy.es-round5.test.mjs b/config/scripts/locale-translation-policy.es-round5.test.mjs index b52afceb02d..3bf5a77fb3a 100644 --- a/config/scripts/locale-translation-policy.es-round5.test.mjs +++ b/config/scripts/locale-translation-policy.es-round5.test.mjs @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { repairTranslatedValue } from './locale-translation-policy.mjs' describe('locale-translation-policy es round 5', () => { - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'mensaje de confirmación', locale: 'es' }) - ).toBe('mensaje de Commit') + ).toBe('mensaje de confirmación') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'repositorio', locale: 'es' }) - ).toBe('Repo') + ).toBe('repositorio') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios', locale: 'es' }) - ).toBe('Proyecto local, repo de Git o carpeta con muchos repos') + ).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios') }) }) diff --git a/config/scripts/locale-translation-policy.ja-round5.test.mjs b/config/scripts/locale-translation-policy.ja-round5.test.mjs index 3100efc3abe..219bc33113b 100644 --- a/config/scripts/locale-translation-policy.ja-round5.test.mjs +++ b/config/scripts/locale-translation-policy.ja-round5.test.mjs @@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: '遊ぶ', locale: 'ja' }) - ).toBe('Play') + ).toBe('再生') expect( repairTranslatedValue({ key: 'auto.components.TaskPage.8396825a14', @@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => { ).toBe('まずプロジェクトを追加') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3', @@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'エージェントのスキル', locale: 'ja' }) - ).toBe('Agent のスキル') + ).toBe('エージェントのスキル') expect( repairTranslatedValue({ key: 'auto.components.tab.bar.TabBar.3d5d6c960d', @@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'コミット', locale: 'ja' }) - ).toBe('commits') + ).toBe('コミット') expect( repairTranslatedValue({ key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df', @@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'リポ', locale: 'ja' }) - ).toBe('Repo') + ).toBe('リポ') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => { 'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', locale: 'ja' }) - ).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー') + ).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー') }) }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index a3feb5f59df..50a490dc184 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -1,5 +1,8 @@ import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs' -import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs' +import { + isCanonicalGenericRendering, + overlapsCanonicalRendering +} from './locale-generic-ui-terms.mjs' import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs' import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs' import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs' @@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.' // Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac). export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX] +// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to +// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs +// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead. export const NEVER_TRANSLATE_VALUES = new Set([ - 'Agent', - 'Agents', 'Aider', 'Amp', 'Android', @@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Codebuff', 'Codex', 'Command Code', - 'Continue', 'Cursor', 'Droid', 'Devin', @@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Pi', 'PostHog', 'Qwen Code', - 'Repo', - 'Repos', 'Rovo Dev', - 'Commit', - 'Commits', 'Markdown', - 'Terminal', - 'Terminals', 'VS Code', 'Warp', 'Zed', - 'agent', - 'agents', 'android', 'codex', - 'commit', - 'commits', 'gemini', 'claude', 'markdown', - 'repo', - 'repos', - 'terminal', - 'terminals', 'gh', 'idle', 'anthropic', @@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) { return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value) } +function replaceMistranslatedForm(value, wrong, brand, locale) { + let result = '' + let cursor = 0 + for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) { + const end = at + wrong.length + result += value.slice(cursor, at) + result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand + cursor = end + } + return result + value.slice(cursor) +} + function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { let result = localeValue const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {} @@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (isScreenCursorContext(brand, enValue, key)) { continue } - if (isLocalizedProseTermContext(brand, key)) { - continue - } if (includesPreservedLatinTerm(result, brand)) { continue } @@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (!result.includes(wrong)) { continue } + // Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it + // rewrote ~2000 translated values back to English on every repair run. + if (isCanonicalGenericRendering(brand, locale, wrong)) { + continue + } // Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand. if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) { continue } - result = result.replaceAll(wrong, brand) + result = replaceMistranslatedForm(result, wrong, brand, locale) } } @@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) { for (const leaf of leaves) { const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog) + // Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only + // rewrites values that already exist, so skip instead of crashing on undefined. + if (typeof current !== 'string') { + continue + } const next = repairTranslatedValue({ key: leaf.key, enValue: leaf.value, diff --git a/config/scripts/locale-translation-policy.test.mjs b/config/scripts/locale-translation-policy.test.mjs index 1722f8f946e..d919de96cff 100644 --- a/config/scripts/locale-translation-policy.test.mjs +++ b/config/scripts/locale-translation-policy.test.mjs @@ -80,7 +80,7 @@ describe('locale-translation-policy', () => { localeValue: '壊れた小切手に対して AI エージェントを開始しました。', locale: 'ja' }) - ).toBe('失敗したチェックに対して AI agent を開始しました。') + ).toBe('失敗したチェックに対して AI エージェントを開始しました。') expect( repairTranslatedValue({ key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94', @@ -88,7 +88,7 @@ describe('locale-translation-policy', () => { localeValue: '電話機からターミナルとエージェントを制御します。', locale: 'ja' }) - ).toBe('スマートフォンから terminals と agents を操作') + ).toBe('スマートフォンからターミナルとエージェントを操作') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.934add88b6', @@ -267,7 +267,7 @@ describe('locale-translation-policy', () => { localeValue: '未已检测代理', locale: 'zh' }) - ).toBe('未检测到 agents') + ).toBe('未检测到代理') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -275,7 +275,7 @@ describe('locale-translation-policy', () => { localeValue: '代理技巧', locale: 'zh' }) - ).toBe('Agent 技能') + ).toBe('代理技能') expect( repairTranslatedValue({ key: 'auto.components.settings.appearance.search.9ae151b26b', @@ -358,7 +358,7 @@ describe('locale-translation-policy', () => { localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。', locale: 'ja' }) - ).toBe('commit するものはありません。PR はすでにマージされています。') + ).toBe('コミットするものはありません。PR はすでにマージされています。') expect( repairTranslatedValue({ key: 'auto.components.settings.integrations.search.581844769a', @@ -546,7 +546,7 @@ describe('locale-translation-policy', () => { localeValue: '玩', locale: 'zh' }) - ).toBe('Play') + ).toBe('播放') expect( repairTranslatedValue({ key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 6e451c6d89b..28901fba8d4 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('集成') }) - it('keeps Terminal as a product surface term', () => { + // Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical + // connector") mistranslation still reverts to Latin. + it('keeps terminal translated but reverts the 端子 mistranslation', () => { expect( repairTranslatedValue({ key: 'auto.components.settings.Settings.3de4bbb841', @@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端', locale: 'zh' }) - ).toBe('Terminal') + ).toBe('终端') expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端 1', locale: 'zh' }) - ).toBe('Terminal 1') + ).toBe('终端 1') expect( repairTranslatedValue({ key: 'auto.components.agent.AgentCombobox.986f946354', @@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '分体式端子右', locale: 'zh' }) - ).toBe('向右拆分 Terminal') + ).toBe('向右拆分终端') expect( repairTranslatedValue({ key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019', @@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端Cursor', locale: 'zh' }) - ).toBe('Terminal Cursor') + ).toBe('终端 Cursor') expect( repairTranslatedValue({ key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9', @@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('新 Terminal') }) - it('keeps workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.sidebar.SidebarNav.9c95e1ce91', @@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '代理', locale: 'zh' }) - ).toBe('Agents') + ).toBe('代理') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.28986b3747', @@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '已启动 AI 代理处理失败的检查。', locale: 'zh' }) - ).toBe('已启动 AI agent 处理失败的检查。') + ).toBe('已启动 AI 代理处理失败的检查。') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '次提交', locale: 'zh' }) - ).toBe('commits') + ).toBe('次提交') expect( repairTranslatedValue({ key: 'auto.store.slices.worktrees.d1d78a7baa', @@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => { 'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。', locale: 'zh' }) - ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。') + ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。') }) it('does not confuse proxy copy with Agent terminology', () => { @@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹', locale: 'zh' }) - ).toBe('本地项目、Git repo 或包含多个 repos 的文件夹') + ).toBe('本地项目、Git 仓库或包含多个仓库的文件夹') }) it('keeps product, provider, code, and shell tokens untranslated', () => { diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index e237bca76b0..ea49d748250 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'OpenCode Go': 'OpenCode Go', 'Open in Cursor': 'Abrir en Cursor', 'Local project, Git repo, or folder with many repos': - 'Proyecto local, repo de Git o carpeta con muchos repos' + 'Proyecto local, repositorio de Git o carpeta con muchos repositorios' }, ko: { Save: '저장', @@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = { teams: '团队', passing: '通过', 'opened this issue': '创建了此议题', - Open: '进行中', + // No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放), + // so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide. 'Join Discord': '加入 Discord', 'Pull request merged': '拉取请求已合并', 'Agent Skills': '代理技能', diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index d11c747c1df..087091a3f3e 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = { Inline: '内联', thread: '帖子串', destructive: 'destructive', - sheet: 'sheet', - page: 'page', + sheet: '面板', + page: '页面', Annotation: '批注', 'check #': '检查 #', by: '由', @@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = { '仓库操作方案。在此仓库自定义之前,将使用全局设置。', 'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.': '为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。', - Play: 'Play', - Flag: 'Flag', - Zinc: 'Zinc', - Rose: 'Rose', - Emerald: 'Emerald', - Amber: 'Amber', - Violet: 'Violet', - Sky: 'Sky', - Blue: 'Blue', - Neutral: 'Neutral', + // Workspace status icon and color swatch labels; siblings below are translated, so English + // pins here would leave the picker half-Chinese on the next catalog regeneration. + Play: '播放', + Flag: '标记', + Zinc: '锌灰', + Rose: '玫瑰', + Emerald: '翡翠', + Amber: '琥珀', + Violet: '紫罗兰', + Sky: '天蓝', + Blue: '蓝色', + Neutral: '中性', Dashed: '虚线', Dot: '圆点', Circle: '圆形', @@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = { '每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。', 'Show Linear in the Tasks source picker and sidebar shortcuts.': '在任务源选择器和侧边栏快捷方式中显示 Linear。', - 'Local project, Git repo, or folder with many repos': - '本地项目、Git repo 或包含多个 repos 的文件夹', + 'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹', 'Staged Changes': '已暂存的更改', Changes: '更改', 'Untracked Files': '未跟踪文件', @@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = { 'Split Down': '向下拆分', 'Split Left': '向左拆分', 'Split Right': '向右拆分', - 'Split Terminal Down': '向下拆分 Terminal', - 'Split Terminal Right': '向右拆分 Terminal', + 'Split Terminal Down': '向下拆分终端', + 'Split Terminal Right': '向右拆分终端', 'Optional account switching for Claude while preserving shared chat context.': 'Claude 的可选账户切换,同时保留共享聊天上下文。', 'Countdown timer showing time until prompt cache expires (Claude agents).': diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts index a7583e1d470..b9601a67d41 100644 --- a/config/scripts/plain-node-entry-guard.test.ts +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -85,3 +85,91 @@ describe('plain Node entry guard', () => { ) }) }) + +// Why (#11161): Electron's module is not registered on worker threads either — +// require("electron") throws "Cannot find module 'electron'" inside a +// main-process worker and kills it at startup. The worker entries carried only +// hand-written "must stay electron-free" comments, and the port-scan worker sits +// one import away from a client that deliberately does require electron. +describe('worker thread entry guard', () => { + function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void { + const hook = plugin.writeBundle + if (typeof hook !== 'function') { + throw new Error('Expected writeBundle hook') + } + hook.call( + { meta: { watchMode: false } } as never, + { dir: createOutputDir() } as Rollup.NormalizedOutputOptions, + bundle + ) + } + + function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk { + return { + type: 'chunk', + code, + dynamicImports: [], + fileName: `${name}.js`, + imports, + isEntry: true, + name + } as Rollup.OutputChunk + } + + it('rejects an Electron require reachable from a worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'port-scan-command-worker-entry.js': workerChunk( + 'port-scan-command-worker-entry', + 'require("electron")' + ) + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron') + }) + + it('names the worker-thread runtime so the failure is actionable', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'stt-worker.js': workerChunk('stt-worker', 'require("electron")') + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread') + }) + + // The real risk is transitive: a worker entry importing a shared chunk that + // reaches the electron-requiring client, not a direct import anyone would spot. + it('follows shared chunks out of a worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'session-scanner-opencode-sqlite-worker-entry.js': workerChunk( + 'session-scanner-opencode-sqlite-worker-entry', + 'require("./chunks/shared.js")', + ['chunks/shared.js'] + ), + 'chunks/shared.js': { + type: 'chunk', + code: 'require("electron")', + dynamicImports: [], + fileName: 'chunks/shared.js', + imports: [], + isEntry: false, + name: 'shared' + } as Rollup.OutputChunk + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js') + }) + + it('passes a clean worker entry', () => { + const plugin = createPlainNodeEntryGuardPlugin() + const bundle = { + 'warp-theme-parser-worker.js': workerChunk( + 'warp-theme-parser-worker', + 'require("node:worker_threads")' + ) + } as Rollup.OutputBundle + + expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow() + }) +}) diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 901c651798d..d3e25d3fd2c 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -6,6 +6,9 @@ import process from 'node:process' // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' +import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain']) @@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) { return changed } +// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms +// back to English, so drift only surfaces when someone regenerates the catalog. +export function collectGenericTermRegressions(enEntries, localeEntries, localeName) { + const renderings = canonicalGenericRenderings(localeName) + if (renderings.length === 0) { + return [] + } + + const regressions = [] + for (const [key, enValue] of enEntries) { + const localeValue = localeEntries.get(key) + if (typeof enValue !== 'string' || typeof localeValue !== 'string') { + continue + } + const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName }) + if (repaired === localeValue) { + continue + } + // Why: {{agent}} is an interpolation name, not English copy the reader sees. + const repairedCopy = repaired.replace(PLACEHOLDER_RE, '') + for (const { form, terms } of renderings) { + if (!localeValue.includes(form) || repaired.includes(form)) { + continue + } + if ( + terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy)) + ) { + regressions.push({ key, form, localeValue, repaired }) + break + } + } + } + return regressions +} + +function formatGenericTermRegressions(regressions) { + return regressions + .map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`) + .join('\n') +} + function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } = collectLocaleParityIssues(enCatalog, localeCatalog) + const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName) // Why: feature PRs own English declarations; absent target leaves deliberately // use i18next's existing English fallback until a localization PR supplies them. @@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { `${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.` ) - if (extraInLocale.length > 0 || interpolationMismatches.length > 0) { + if ( + extraInLocale.length > 0 || + interpolationMismatches.length > 0 || + genericTermRegressions.length > 0 + ) { console.error(`Locale catalog validation failed for ${localeName}.json.`) if (extraInLocale.length > 0) { console.error('') @@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`) } } + if (genericTermRegressions.length > 0) { + console.error('') + console.error( + 'repair-locale-catalog would rewrite these translated terms back to English.', + 'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs', + 'instead of listing its translation as a mistranslation.' + ) + console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20))) + if (genericTermRegressions.length > 20) { + console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`) + } + } return 1 } diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index 89e24bc0c0d..4bf3d7d7eba 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -4,7 +4,10 @@ import path from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs' +import { + collectGenericTermRegressions, + main as verifyLocalizationCatalog +} from './verify-localization-catalog.mjs' function writeJson(filePath, value) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') @@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => { report.mockRestore() } }) + + // Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English. + it('flags catalog values the repair policy would rewrite back to English', () => { + const enEntries = new Map([['auto.example.commitLabel', 'Commit message']]) + + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de confirmación']]), + 'es' + ) + ).toEqual([]) + + // A locale whose committed value is the English term is stable, not a regression. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Commit']]), + 'es' + ) + ).toEqual([]) + + // 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]), + 'es' + ) + ).toEqual([]) + }) + + it('ignores interpolation names when looking for English rewrites', () => { + expect( + collectGenericTermRegressions( + new Map([ + ['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}'] + ]), + new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]), + 'zh' + ) + ).toEqual([]) + }) }) diff --git a/config/vitest.config.ts b/config/vitest.config.ts index 1146cd4a5ca..735e1b2a0af 100644 --- a/config/vitest.config.ts +++ b/config/vitest.config.ts @@ -17,6 +17,8 @@ export default defineConfig({ environment: 'node', // Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's. execArgv: ['--no-experimental-webstorage'], + // Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does. + setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')], include: [ 'src/**/*.test.ts', 'src/**/*.test.tsx', diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index df51a8732ac..98410899a3d 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 13m + + downloads: 14m @@ -15,7 +15,7 @@ downloads downloads - 13m - 13m + 14m + 14m diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index cdee2a4ca41..2284bcdaa23 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -155,6 +155,8 @@ display `:99` when no display exists: Description=Orca runtime server After=network-online.target Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=5 [Service] Type=simple @@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100. StandardOutput=journal StandardError=journal Restart=on-failure +RestartPreventExitStatus=3 RestartSec=5 [Install] @@ -174,6 +177,19 @@ WantedBy=multi-user.target Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that clients should use. +Exit status `3` means another process already owns this userData profile, so +`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that +cannot succeed. Any other permanent startup fault is capped at 5 starts per +5 minutes; systemd's defaults (10s window, 5 starts) can never trip at +`RestartSec=5`, which is how one bad launch could restart thousands of times. +The start limit counts operator-initiated starts too, so once it trips systemd +refuses a plain `systemctl start` until the 5-minute window rolls over. Run +`sudo systemctl reset-failed orca-serve.service` first to clear it — the +[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do. +On systemd older than 230 those two directives are spelled +`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu +20.04, Orca's oldest supported base, ships systemd 245. + Enable the service: ```bash @@ -228,6 +244,8 @@ Then add the display dependency to the Orca service: Description=Orca runtime server After=network-online.target orca-xvfb.service Wants=network-online.target orca-xvfb.service +StartLimitIntervalSec=300 +StartLimitBurst=5 [Service] Type=simple @@ -237,6 +255,7 @@ Environment=DISPLAY=:99 Environment=LIBGL_ALWAYS_SOFTWARE=1 ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20 Restart=on-failure +RestartPreventExitStatus=3 RestartSec=5 [Install] @@ -415,6 +434,8 @@ recover_failed_upgrade() { sudo rm -f /opt/orca/orca-linux.AppImage.recovering \ /opt/orca/VERSION.recovering if ((recovery_ok)); then + # A tripped StartLimitBurst refuses a plain start + sudo systemctl reset-failed orca-serve.service || true sudo systemctl start orca-serve.service || true else echo 'Upgrade recovery failed; service remains stopped' >&2 @@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK" ORCA_BINARY_PROMOTED=1 sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION +# Clears a start-limit hit left by the version being replaced +sudo systemctl reset-failed orca-serve.service sudo systemctl start orca-serve.service ORCA_SERVICE_STOPPED=0 trap - EXIT @@ -614,6 +637,8 @@ restart_after_rollback_error() { fi fi if ((recovery_ok)); then + # A tripped StartLimitBurst refuses a plain start + sudo systemctl reset-failed orca-serve.service || true sudo systemctl start orca-serve.service || true else echo 'Rollback recovery failed; service remains stopped' >&2 @@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then else sudo rm -f /opt/orca/VERSION fi +# The crash-looping build you are rolling back from tripped StartLimitBurst +sudo systemctl reset-failed orca-serve.service sudo systemctl start orca-serve.service ORCA_SERVICE_STOPPED=0 sudo rm -rf -- "$ORCA_RESTORE" @@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want. `orca` user and that `/opt/orca` is readable by that user. - Clients cannot connect: make sure `--pairing-address` is an address reachable from the client, and make sure firewalls allow the selected `--port`. +- Journal shows `Another Orca instance is already running for this userData + profile` and the unit exits `3`: another process already owns the profile, so + `RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the + owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or + keep it and leave the unit down), then run + `sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` — + `reset-failed` clears the failed state and any start-limit counter. If no owner + exists, the lock is stale (Chromium recorded a pid that + has since been reused): remove `SingletonLock` and `SingletonSocket` from the + userData directory and start again. If an earlier crash-loop already leaked + AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and + release only the ones with no live owner using `fusermount -uz ` (or + `umount -l `), leaving the running instance's mount alone. - Service crash-loops right after an upgrade: use [Roll back](#roll-back) with the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would - make the crashing version the next rollback binary. + make the crashing version the next rollback binary. The loop trips + `StartLimitBurst`, so any manual `systemctl start` outside that script needs + `sudo systemctl reset-failed orca-serve.service` first. - Diagnosing other missing libraries: extract the AppImage without launching it with `./orca-linux.AppImage --appimage-extract`, then run `ldd squashfs-root/orca` to list any shared libraries the host is missing. diff --git a/docs/reference/windows-setup-shell.md b/docs/reference/windows-setup-shell.md new file mode 100644 index 00000000000..6c93185b602 --- /dev/null +++ b/docs/reference/windows-setup-shell.md @@ -0,0 +1,86 @@ +# Windows setup-runner shell + +On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated +runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by +default**, exactly as it has been since setup hooks shipped. + +A script opts into bash by starting with a `#!` interpreter line: + +```yaml +scripts: + setup: | + #!/usr/bin/env bash + [ -f .env ] || cp .env.example .env + pnpm install +``` + +Without that line the script keeps running under `cmd.exe`: + +```yaml +scripts: + setup: | + copy .env.example .env + xcopy /E assets dist +``` + +## Why the script declares it, not the terminal preference + +`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the +language a project's setup script is written in. Deriving the runner from it had two consequences: + +- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`, + `xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working. +- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project + could write a setup script that worked for all of its Windows contributors. + +A `#!` line is per-project, explicit, and identical for everyone who checks the repo out. + +The same rule applies to the per-user setup command in **Settings → repository hooks** +(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a +POSIX one-liner stored there needs its own `#!` line to run under bash on Windows. + +## What the `#!` line does and does not select + +The generated runner is always executed by bash (`bash `; Git Bash on native Windows), on +every platform. The `#!` line therefore does two things: + +- It declares the script is written for a POSIX shell, which is what selects the bash runner. +- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does + get `pipefail`. Without that replay the flags would be silently dropped, because `bash ` + never parses the interpreter line. Only the flags `set` itself accepts + (`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are + dropped, because `set -l` exits 2 and would abort the runner before its first line. + +The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and +`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux. + +## Requirements for the bash runner + +A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the +terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner +uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is +typed into whatever shell the terminal opened with. + +When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses +the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd` +runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script +(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a +half-set-up worktree that looks finished. + +## Launching a `.cmd` runner from a Git Bash terminal + +The runner format and the shell that types the launch command are independent: a Git Bash terminal +with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c +"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens +interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell +`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch +and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the +launching pane; the runner file's `.cmd`/`.sh` extension describes the format. + +The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the +agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the +bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the +same `ProcessStartInfo` launcher, so the batch runner is never handed to bash. + +WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts +choose their runner from the remote path format, never from local Windows preferences. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 39f923cb215..a6e656ac9c6 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -216,6 +216,11 @@ export const electronViteConfig: UserConfig = { 'session-scanner-opencode-sqlite-worker-entry': resolve( 'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts' ), + // Why: libuv spawns processes inline on the calling loop, so the port + // scan's probe commands run on a worker thread instead of the UI one. + 'port-scan-command-worker-entry': resolve( + 'src/main/ports/port-scan-command-worker-entry.ts' + ), // Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults // can't take down the main process (issue #7547). 'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'), diff --git a/mobile/app.json b/mobile/app.json index 413cebc8d28..53654335486 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.36", + "version": "0.0.37", "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": 9 + "versionCode": 10 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 24da552eafe..b71f171ceb8 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -164,7 +164,8 @@ export function HostScreen({ const [filters, setFilters] = useState({ filterRepoIds: new Set(), hideSleeping: false, - hideDefaultBranch: false + hideDefaultBranch: false, + alwaysShowDefaultBranch: true }) const [groupMode, setGroupMode] = useState('repo') const [workspaceStatuses, setWorkspaceStatuses] = useState( @@ -195,6 +196,7 @@ export function HostScreen({ sortMode: 'recent', hideSleeping: false, hideDefaultBranch: false, + alwaysShowDefaultBranch: true, filterRepoIds: [], collapsedGroups: [], workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES @@ -206,6 +208,7 @@ export function HostScreen({ sortMode, hideSleeping: filters.hideSleeping, hideDefaultBranch: filters.hideDefaultBranch, + alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false, filterRepoIds: [...filters.filterRepoIds], collapsedGroups: [...collapsedGroups], workspaceStatuses @@ -222,7 +225,8 @@ export function HostScreen({ setFilters({ filterRepoIds: new Set(next.filterRepoIds), hideSleeping: next.hideSleeping, - hideDefaultBranch: next.hideDefaultBranch + hideDefaultBranch: next.hideDefaultBranch, + alwaysShowDefaultBranch: next.alwaysShowDefaultBranch }) }, []) @@ -234,6 +238,9 @@ export function HostScreen({ if (!client) { return } + // alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it + // but has no toggle, so echoing its local default would silently revert a + // desktop opt-out on the first filter tap before ui.get lands (#8873). const payload: WorkspaceViewSettings = { groupBy: groupModeToDesktop(next.groupMode), sortBy: next.sortMode, diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 907f50ed974..52f0647c872 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -16,16 +16,13 @@ import { } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' -import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' +import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' -import { pickResumeWorktree } from '../src/worktree/resume-worktree' -import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client' -import { - markHomeWorktreeCatalogUnavailable, - type HomeWorktreeSummary, - type HostWorktreeInfo -} from '../src/worktree/home-worktree-info' +import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch' +import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total' +import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info' import type { RpcClient } from '../src/transport/rpc-client' +import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate' import { sendSingleFlightRequest } from '../src/transport/request-single-flight' import { useAllHostClients, @@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string { } } -type StatsSummary = { - totalAgentsSpawned: number - totalPRsCreated: number - totalAgentTimeMs: number - firstEventAt: number | null -} - type HomeTaskSettings = { visibleTaskProviders?: unknown } @@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number { function fetchStats( client: RpcClient, hostId: string, - setStats: (s: StatsSummary) => void, + setStats: ( + updater: (prev: Record) => Record + ) => void, disposed: () => boolean ) { sendSingleFlightRequest(client, hostId, 'stats.summary') @@ -132,61 +124,13 @@ function fetchStats( return } if (response.ok) { - setStats(response.result as StatsSummary) + // Keyed by host: the header totals every desktop instead of showing whoever replied last. + setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary })) } }) .catch(() => {}) } -function fetchWorktreeInfo( - client: RpcClient, - hostId: string, - setInfo: ( - updater: (prev: Record) => Record - ) => void, - disposed: () => boolean -) { - const markUnavailable = () => { - setInfo((prev) => { - const current = prev[hostId] - const next = markHomeWorktreeCatalogUnavailable(current, hostId) - return next === current ? prev : { ...prev, [hostId]: next } - }) - } - - sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) - .then((response) => { - if (disposed()) { - return - } - if (response.ok) { - const result = response.result as { worktrees: HomeWorktreeSummary[] } - const worktrees = result.worktrees ?? [] - setCachedWorktrees(hostId, worktrees) - const activeStatuses = new Set(['working', 'active', 'permission']) - const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status)) - // Mirror the desktop's focused workspace (see pickResumeWorktree). - const lastActive = pickResumeWorktree(worktrees) - setInfo((prev) => ({ - ...prev, - [hostId]: { - hostId, - totalWorktrees: worktrees.length, - activeCount: active.length, - lastActiveWorktree: lastActive - } - })) - } else { - markUnavailable() - } - }) - .catch(() => { - if (!disposed()) { - markUnavailable() - } - }) -} - function fetchAccountsSnapshot( client: RpcClient, hostId: string, @@ -262,6 +206,7 @@ function repoColor(name: string): string { export default function HomeScreen() { const router = useRouter() + const openMobileHostEdit = useOpenMobileHostEdit() const openMobileTasks = useOpenMobileTasks() const insets = useSafeAreaInsets() // Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad. @@ -272,7 +217,7 @@ export default function HomeScreen() { const [hostStates, setHostStates] = useState>({}) const [hostAttempts, setHostAttempts] = useState>({}) const [hostLastConnected, setHostLastConnected] = useState>({}) - const [stats, setStats] = useState(null) + const [statsByHost, setStatsByHost] = useState>({}) const [worktreeInfo, setWorktreeInfo] = useState>({}) const [accountsByHost, setAccountsByHost] = useState>({}) const [taskProvidersByHost, setTaskProvidersByHost] = useState>({}) @@ -285,6 +230,8 @@ export default function HomeScreen() { // Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md. const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts]) + // Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total. + const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds]) const allClients = useAllHostClients(hostIds) const hostPaths = useMemo( () => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])), @@ -375,8 +322,8 @@ export default function HomeScreen() { }) for (const entry of allClientsRef.current) { if (entry.client.getState() === 'connected') { - fetchStats(entry.client, entry.hostId, setStats, () => stale) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) + fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale) fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale) fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale) } @@ -457,60 +404,70 @@ export default function HomeScreen() { }) }, [allClients, hosts]) - // Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap. - useEffect(() => { - const cleanups: Array<() => void> = [] - for (const entry of allClients) { - let unsubNotif: (() => void) | null = null - let unsubAccounts: (() => void) | null = null - let statsFetched = false - const wireUp = (state: ConnectionState) => { - if (state === 'connected') { - if (!unsubNotif) { - unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) - } - if (!unsubAccounts) { - unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { - if (!payload || typeof payload !== 'object') { - return + // Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body + // because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the + // returned disposer owns every handle allocated here. + const wireHostSubscriptions = (entry: { + hostId: string + client: RpcClient + state: ConnectionState + }) => { + let unsubNotif: (() => void) | null = null + let unsubAccounts: (() => void) | null = null + const refetchGate = createHostConnectRefetchGate() + const wireUp = (state: ConnectionState) => { + const reconnected = refetchGate.observe(state) + if (state === 'connected') { + if (!unsubNotif) { + unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId) + } + if (!unsubAccounts) { + unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => { + if (!payload || typeof payload !== 'object') { + return + } + const evt = payload as { type?: string; snapshot?: unknown } + if (evt.type === 'ready' || evt.type === 'snapshot') { + try { + const snapshot = decodeAccountsSnapshot(evt.snapshot) + setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) + } catch { + // Keep the last proven snapshot; malformed remote data must + // not enter render state or crash the home host cards. } - const evt = payload as { type?: string; snapshot?: unknown } - if (evt.type === 'ready' || evt.type === 'snapshot') { - try { - const snapshot = decodeAccountsSnapshot(evt.snapshot) - setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot })) - } catch { - // Keep the last proven snapshot; malformed remote data must - // not enter render state or crash the home host cards. - } - } - }) - } - if (!statsFetched) { - statsFetched = true - fetchStats(entry.client, entry.hostId, setStats, () => false) - fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) - fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) - } - } else { - if (unsubNotif) { - unsubNotif() - unsubNotif = null - } - if (unsubAccounts) { - unsubAccounts() - unsubAccounts = null - } + } + }) + } + // Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host + // snapshot on every reconnect — a one-shot latch left the card on stale data forever. + if (reconnected) { + fetchStats(entry.client, entry.hostId, setStatsByHost, () => false) + void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false) + fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false) + } + } else { + if (unsubNotif) { + unsubNotif() + unsubNotif = null + } + if (unsubAccounts) { + unsubAccounts() + unsubAccounts = null } } - wireUp(entry.state) - const unsubState = entry.client.onStateChange(wireUp) - cleanups.push(() => { - unsubState() - unsubNotif?.() - unsubAccounts?.() - }) } + wireUp(entry.state) + const unsubState = entry.client.onStateChange(wireUp) + return () => { + unsubState() + unsubNotif?.() + unsubAccounts?.() + } + } + + // Re-runs per (hostId, client) pair; the socket stays open so it's cheap. + useEffect(() => { + const cleanups = allClients.map((entry) => wireHostSubscriptions(entry)) return () => { for (const c of cleanups) { c() @@ -750,7 +707,6 @@ export default function HomeScreen() { const state = hostStates[item.id] ?? 'connecting' const attempts = hostAttempts[item.id] ?? 0 const lastConnectedAt = hostLastConnected[item.id] ?? null - const info = worktreeInfo[item.id] const verdict = classifyConnection({ state, reconnectAttempts: attempts, @@ -763,12 +719,7 @@ export default function HomeScreen() { state={state} verdict={verdict} path={hostPaths[item.id] ?? 'lan'} - worktreeCounts={ - info && !info.catalogUnavailable - ? { total: info.totalWorktrees, active: info.activeCount } - : undefined - } - worktreeCountsUnavailable={info?.catalogUnavailable === true} + worktreeInfo={worktreeInfo[item.id]} onPress={() => router.push(`/h/${item.id}`)} onLongPress={() => { triggerMediumImpact() @@ -956,7 +907,7 @@ export default function HomeScreen() { onDismiss: () => setActionTarget(null), onReconnect: (hostId) => void forceReconnectHost(hostId), onDisconnect: closeHostClient, - onEdit: (hostId) => navigateToMobileHostEdit(router, hostId), + onEdit: openMobileHostEdit, onRemove: setConfirmRemove })} onClose={() => setActionTarget(null)} diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts index a591bb01d70..bbda033735e 100644 --- a/mobile/src/cache/home-snapshot-cache.ts +++ b/mobile/src/cache/home-snapshot-cache.ts @@ -5,25 +5,12 @@ // WebSocket reconnects and the first responses come back. import AsyncStorage from '@react-native-async-storage/async-storage' import type { AccountsSnapshot } from '../components/AccountUsage' +// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card +// needs it to know whether a rehydrated count is minutes or days old. +import type { HostWorktreeInfo } from '../worktree/home-worktree-info' const STORAGE_KEY = 'orca:home-snapshot:v1' -type WorktreeSummary = { - worktreeId: string - repo: string - branch: string - displayName: string - liveTerminalCount: number - status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' -} - -type HostWorktreeInfo = { - hostId: string - totalWorktrees: number - activeCount: number - lastActiveWorktree: WorktreeSummary | null -} - export type HomeSnapshot = { worktreeInfo: Record accountsByHost: Record diff --git a/mobile/src/components/MobileHostCard.test.tsx b/mobile/src/components/MobileHostCard.test.tsx new file mode 100644 index 00000000000..a1889793982 --- /dev/null +++ b/mobile/src/components/MobileHostCard.test.tsx @@ -0,0 +1,93 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ConnectionVerdict } from '../transport/connection-health' +import type { HostProfile } from '../transport/types' +import { + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from '../worktree/home-worktree-info' +import { MobileHostCard } from './MobileHostCard' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' })) +vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' })) + +const host: HostProfile = { + id: 'host-1', + name: 'Studio', + endpoint: 'ws://studio.local:8765', + deviceToken: 'token', + publicKeyB64: 'key', + lastConnected: 0 +} +const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' } +const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: Date.now() +} + +describe('MobileHostCard', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise { + await act(async () => { + renderer = create( + createElement(MobileHostCard, { + host, + state: 'connected', + verdict, + path: 'lan', + worktreeInfo, + onPress: () => {}, + onLongPress: () => {} + }) + ) + }) + return renderer!.root + .findAllByType('Text') + .flatMap((node) => node.children.filter((child) => typeof child === 'string')) + } + + it('renders the counts the host proved', async () => { + expect(await renderCard(loaded)).toContain('12 worktrees · 2 active') + }) + + it('keeps rendering the last proven counts after a failed refresh', async () => { + // The regression this card shipped once: the caller dropped the counts the + // failure path deliberately preserved. + expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('never asserts a count for a catalog that failed with nothing proven', async () => { + expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain( + 'Worktree list unavailable' + ) + }) + + it('shows no worktree line before the first read lands', async () => { + const lines = await renderCard(undefined) + + expect(lines).not.toContain('0 worktrees') + expect(lines).not.toContain('Worktree list unavailable') + }) +}) diff --git a/mobile/src/components/MobileHostCard.tsx b/mobile/src/components/MobileHostCard.tsx index 4e466922f4a..79a58bf760e 100644 --- a/mobile/src/components/MobileHostCard.tsx +++ b/mobile/src/components/MobileHostCard.tsx @@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client' import type { ConnectionState, HostProfile } from '../transport/types' import { colors, radii, spacing } from '../theme/mobile-theme' +import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info' import { StatusDot } from './StatusDot' export function MobileHostCard(props: { @@ -13,20 +14,15 @@ export function MobileHostCard(props: { state: ConnectionState verdict: ConnectionVerdict path: MobileConnectionPath - worktreeCounts?: { total: number; active: number } - // Why (STA-3123): the host is connected but its worktree catalog request failed, - // so the card must say "unavailable" instead of asserting a count of zero. - worktreeCountsUnavailable?: boolean + // Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts + // away (STA-3123 shipped that bug once already). + worktreeInfo?: HostWorktreeInfo onPress: () => void onLongPress: () => void }) { const connected = props.state === 'connected' const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind) - const worktreeSummary = props.worktreeCounts - ? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}` - : props.worktreeCountsUnavailable - ? 'Worktree list unavailable' - : null + const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo) return ( [styles.card, pressed && styles.cardPressed]} diff --git a/mobile/src/home-host-edit-navigation-source.test.ts b/mobile/src/home-host-edit-navigation-source.test.ts index 2f6242f7ec1..4597a138e24 100644 --- a/mobile/src/home-host-edit-navigation-source.test.ts +++ b/mobile/src/home-host-edit-navigation-source.test.ts @@ -5,8 +5,7 @@ const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'u describe('Home host edit navigation wiring', () => { it('uses the cold-navigator-safe edit transition', () => { - expect(homeSource).toMatch( - /onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/ - ) + expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/) + expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/) }) }) diff --git a/mobile/src/session/MobileNativeChatAsk.tsx b/mobile/src/session/MobileNativeChatAsk.tsx index b485818970c..86fcb0dc4a2 100644 --- a/mobile/src/session/MobileNativeChatAsk.tsx +++ b/mobile/src/session/MobileNativeChatAsk.tsx @@ -282,6 +282,7 @@ const styles = StyleSheet.create({ }, option: { flexDirection: 'row', + alignItems: 'center', gap: spacing.sm, padding: spacing.sm, borderRadius: radii.card, @@ -299,8 +300,7 @@ const styles = StyleSheet.create({ borderWidth: 1.5, borderColor: colors.textMuted, alignItems: 'center', - justifyContent: 'center', - marginTop: 1 + justifyContent: 'center' }, checkCircle: { borderRadius: 9 diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts new file mode 100644 index 00000000000..2680064c94b --- /dev/null +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -0,0 +1,193 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { MobileNativeChatOverlay } from './MobileNativeChatOverlay' +import type { MobileNativeChatController } from './use-mobile-native-chat-controller' + +vi.mock('react-native', () => ({ + StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} }, + View: 'View' +})) + +vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' })) + +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +/** One render of the route: chat visible or not, the transcript it currently + * holds, and the agent-status stream behind it. */ +type Tick = { + show?: boolean + messages?: NativeChatMessage[] + streamingText?: string + streamLive?: boolean + identity?: string +} + +function overlayElement(tick: Tick): ReturnType { + const controller = { + showNativeChat: tick.show ?? true, + nativeChatSession: { messages: tick.messages ?? [], status: 'ready' }, + nativeChatAgent: 'claude', + nativeChatAgentWorking: tick.streamLive ?? false, + nativeChatStreamingText: tick.streamingText, + nativeChatStreamLive: tick.streamLive ?? false, + nativeChatStreamScopeKey: tick.identity ?? 'tab-a', + chatPending: [], + chatComposerText: '', + setChatComposerText: vi.fn() + } as unknown as MobileNativeChatController + return createElement(MobileNativeChatOverlay, { + controller, + images: {} as never, + onMicPress: vi.fn(), + micActive: false, + dictationMode: 'toggle', + onMicPressIn: vi.fn(), + onMicPressOut: vi.fn(), + inputLockReason: null, + sendErrorMessage: null, + onClearSendError: vi.fn(), + keyboardInset: 0 + }) +} + +describe('MobileNativeChatOverlay streaming gate', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render(tick: Tick): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(overlayElement(tick)) + }) + } finally { + restore() + } + } + + async function update(tick: Tick): Promise { + await act(async () => { + renderer?.update(overlayElement(tick)) + }) + } + + /** The bubble text handed to the chat list, or `'hidden'` when chat is off. */ + function streaming(): string | null | 'hidden' { + const views = renderer!.root.findAll((node) => node.type === 'ChatView') + return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null) + } + + it('keeps streaming a reply that repeats the previous turn as a prefix', async () => { + const prior = [assistantTurn('a1', 'The tests pass.')] + await render({ messages: prior }) + expect(streaming()).toBeNull() + + await update({ messages: prior, streamingText: 'The tests', streamLive: true }) + + expect(streaming()).toBe('The tests') + }) + + it('drops the streaming bubble once the reply lands as its own turn', async () => { + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ + messages: [...prior, assistantTurn('a2', 'Done.')], + streamingText: 'Done.', + streamLive: true + }) + + expect(streaming()).toBeNull() + }) + + it('keeps the bubble across a peek at the terminal view', async () => { + // Toggling to the terminal unmounts the chat list and unsubscribes its + // transcript. The gate lives above that boundary, so the baseline survives + // and the repeated-prefix reply keeps streaming on the way back. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + await update({ show: false, messages: [], streamLive: true }) + expect(streaming()).toBe('hidden') + // Back on chat the session withholds its transcript until a fresh read + // settles, so the throttled stream text returns a round trip ahead of it. + await update({ messages: [], streamLive: true }) + await update({ messages: [], streamingText: 'Done.', streamLive: true }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBe('Done.') + }) + + it('keeps the bubble across a peek at the terminal taken between turns', async () => { + // Same toggle, but taken while idle: the transcript empties before the next + // turn starts, so the gate has to reject that empty tail as a baseline. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + + await update({ show: false, messages: [] }) + await update({ show: false, messages: [], streamLive: true }) + await update({ messages: [], streamLive: true }) + await update({ messages: [], streamingText: 'Done.', streamLive: true }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBe('Done.') + }) + + it('hides a repeated part whose own turn landed during a mid-turn gap', async () => { + // Between parts the status frame carries no assistant text (a tool call), so + // the stream goes textless while the turn is still live and the part that + // just finished lands in the transcript. Re-anchoring on that tick would + // adopt it as history and render it a second time. + const prior = [assistantTurn('a1', 'Done.')] + await render({ messages: prior }) + await update({ messages: prior, streamingText: 'Done.', streamLive: true }) + expect(streaming()).toBe('Done.') + + const landed = [...prior, assistantTurn('a2', 'Done.')] + await update({ messages: landed, streamLive: true }) + await update({ messages: landed, streamingText: 'Done.', streamLive: true }) + + expect(streaming()).toBeNull() + }) + + it("does not carry one chat's baseline into another stream identity", async () => { + const prior = [assistantTurn('a1', 'Shared answer text')] + await render({ messages: prior, identity: 'tab-a' }) + + await update({ + messages: prior, + streamingText: 'Shared answer', + streamLive: true, + identity: 'tab-b' + }) + + expect(streaming()).toBeNull() + }) +}) diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index b8f9cd9277b..77c5ab72fed 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,7 +1,10 @@ +import { useMemo } from 'react' import { StyleSheet, View } from 'react-native' import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView' +import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data' import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' import type { MobileNativeChatController } from './use-mobile-native-chat-controller' +import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble' type Props = { controller: MobileNativeChatController @@ -22,7 +25,9 @@ type Props = { } /** Keeps the terminal mounted underneath chat so its PTY subscription survives - * view toggles while the native surface owns the visible composer. */ + * view toggles while the native surface owns the visible composer. Also owns + * the streaming gate: this component stays mounted across those toggles, while + * the chat list below it does not. */ export function MobileNativeChatOverlay({ controller, images, @@ -36,19 +41,27 @@ export function MobileNativeChatOverlay({ onClearSendError, keyboardInset }: Props): React.JSX.Element | null { + const session = controller.nativeChatSession + const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages]) + const streaming = useMobileNativeChatStreamingBubble( + folded, + controller.nativeChatStreamingText, + controller.nativeChatStreamScopeKey, + controller.nativeChatStreamLive + ) if (!controller.showNativeChat) { return null } - const session = controller.nativeChatSession return ( ({ @@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => { }) type Overrides = { + messages?: Parameters[0]['messages'] + folded?: Parameters[0]['folded'] + streaming?: string | null sendErrorMessage?: string | null onClearSendError?: () => void inputLockReason?: 'disconnected' | 'waiting' | null @@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void { return () => spy.mockRestore() } -describe('MobileNativeChatView send-error banner', () => { +function assistantTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' } +} + +function chatViewElement(overrides: Overrides): ReturnType { + return createElement(MobileNativeChatView, { + messages: [], + folded: [], + status: 'ready', + streaming: null, + onSend: vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) +} + +describe('MobileNativeChatView', () => { let renderer: ReactTestRenderer | null = null beforeEach(() => { @@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => { const restore = suppressRendererWarning() try { await act(async () => { - renderer = create( - createElement(MobileNativeChatView, { - messages: [], - status: 'ready', - onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), - pending: [], - composerText: '', - onComposerTextChange: vi.fn(), - ...overrides - }) - ) + renderer = create(chatViewElement(overrides)) }) } finally { restore() } } + async function update(overrides: Overrides = {}): Promise { + await act(async () => { + renderer?.update(chatViewElement(overrides)) + }) + } + + /** Ids of the rows the list is currently rendering. */ + function listIds(): string[] { + const list = renderer!.root.find((node) => node.type === 'FlatList') + return (list.props.data as { id: string }[]).map((row) => row.id) + } + function banners(): ReactTestInstance[] { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } @@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => { expect(onClearSendError).toHaveBeenCalledOnce() }) + + // The gate that decides `streaming` lives in MobileNativeChatOverlay, which + // outlives this view; see MobileNativeChatOverlay.test.ts. + it('appends the gated streaming bubble after the folded transcript', async () => { + const folded = [assistantTurn('a1', 'The tests pass.')] + await render({ folded }) + expect(listIds()).toEqual(['a1']) + + await update({ folded, streaming: 'The tests' }) + + expect(listIds()).toEqual(['a1', 'streaming']) + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f14efc1f25f..1a2ae71ca66 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -16,7 +16,6 @@ import { colors } from '../theme/mobile-theme' import { styles } from './mobile-native-chat-view-styles' import { buildMobileNativeChatTransientData, - foldMobileNativeChatMessages, mobileNativeChatEmptyState, type MobileNativeChatPendingItem } from './mobile-native-chat-render-data' @@ -39,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' type Props = { + /** Raw transcript, only for telling "still loading" from "loaded and empty". */ messages: NativeChatMessage[] + /** `messages` with noise stripped and tool turns folded in, from the overlay. */ + folded: NativeChatMessage[] status: MobileNativeChatStatus error?: string /** Resolved agent for this chat; names the empty-state copy (desktop parity). */ @@ -47,9 +49,9 @@ type Props = { agentWorking?: boolean /** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */ onStop?: () => void - /** Live partial assistant text while a turn is still streaming (from the agent - * status hook). Shown as an in-progress bubble until the transcript catches up. */ - streamingText?: string + /** Live partial assistant text to show as an in-progress bubble, already gated + * by the overlay against the transcript catching up. */ + streaming: string | null hasMore?: boolean loadingEarlier?: boolean onLoadEarlier?: () => void @@ -102,12 +104,13 @@ type Props = { export function MobileNativeChatView({ messages, + folded, status, error, agent, agentWorking, onStop, - streamingText, + streaming, hasMore, loadingEarlier, onLoadEarlier, @@ -165,10 +168,9 @@ export function MobileNativeChatView({ // `data` is the list source: folded transcript + synthetic streaming bubble + // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. - const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages]) const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }), - [foldedMessages, streamingText, pending] + () => buildMobileNativeChatTransientData({ folded, streaming, pending }), + [folded, streaming, pending] ) // Follow the tail as the conversation grows and keep the newest message above diff --git a/mobile/src/session/mobile-native-chat-open-file.test.ts b/mobile/src/session/mobile-native-chat-open-file.test.ts index 2f8ceb936a7..e2bf001ffdf 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -36,6 +36,7 @@ describe('resolveMobileNativeChatWorktreePath', () => { .mockResolvedValueOnce({ ok: true, result: { + worktree: 'sibling-worktree', exists: true, isDirectory: false, openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } @@ -51,7 +52,7 @@ describe('resolveMobileNativeChatWorktreePath', () => { }) expect(sendRequest).toHaveBeenLastCalledWith('files.open', { - worktree: 'id:worktree', + worktree: 'id:sibling-worktree', relativePath: 'src/app.ts' }) }) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts index 44d6d23c066..6460c82849c 100644 --- a/mobile/src/session/mobile-native-chat-open-file.ts +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -1,12 +1,17 @@ import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' import type { RpcClient } from '../transport/rpc-client' -export async function resolveMobileNativeChatWorktreePath(args: { +type MobileNativeChatWorktreeTarget = { + worktreeId: string + relativePath: string +} + +async function resolveMobileNativeChatWorktreeTarget(args: { client: RpcClient worktreeId: string pathText: string terminal: string | null -}): Promise { +}): Promise { try { const response = await args.client.sendRequest('files.resolveTerminalPath', { worktree: `id:${args.worktreeId}`, @@ -20,28 +25,39 @@ export async function resolveMobileNativeChatWorktreePath(args: { if (!resolved.exists || resolved.isDirectory) { return null } - return resolved.openTarget?.kind === 'worktree-file' - ? resolved.openTarget.relativePath - : (resolved.relativePath ?? null) + const relativePath = + resolved.openTarget?.kind === 'worktree-file' + ? resolved.openTarget.relativePath + : (resolved.relativePath ?? null) + return relativePath + ? { worktreeId: resolved.worktree?.trim() || args.worktreeId, relativePath } + : null } catch { - // Callers fire-and-forget file opens; a disconnect/timeout must not become - // an unhandled rejection. return null } } +export async function resolveMobileNativeChatWorktreePath(args: { + client: RpcClient + worktreeId: string + pathText: string + terminal: string | null +}): Promise { + return (await resolveMobileNativeChatWorktreeTarget(args))?.relativePath ?? null +} + export async function openMobileNativeChatFile(args: { client: RpcClient worktreeId: string pathText: string terminal: string | null }): Promise { - const relativePath = await resolveMobileNativeChatWorktreePath(args) - if (relativePath) { + const target = await resolveMobileNativeChatWorktreeTarget(args) + if (target) { try { await args.client.sendRequest('files.open', { - worktree: `id:${args.worktreeId}`, - relativePath + worktree: `id:${target.worktreeId}`, + relativePath: target.relativePath }) } catch { // Best-effort open; failures surface as a no-op rather than an diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index 1b595683112..253f8c8b85a 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -13,6 +13,11 @@ import { markMobileNativeChatInputStale, resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' describe('sendMobileNativeChatPermissionResponse', () => { it('writes an approval as raw bytes without appending Return', async () => { @@ -64,6 +69,7 @@ describe('useMobileNativeChatPermissionSend', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { @@ -102,4 +108,42 @@ describe('useMobileNativeChatPermissionSend', () => { expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false }) expect(isMobileNativeChatInputStale('terminal')).toBe(true) }) + + it('rejects a choice while another composed write holds the terminal, then recovers', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } } + }) + function Harness(): null { + respond = useMobileNativeChatPermissionSend({ + client: { sendRequest } as unknown as RpcClient, + enabled: true, + handleRef: { current: 'terminal' }, + deviceTokenRef: { current: null }, + onSendError + }) + return null + } + act(() => { + renderer = create(createElement(Harness)) + }) + + // An image paste sequence is mid-flight into the same PTY: the choice + // keystroke must not interleave into it. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + await act(async () => { + await expect(respond?.('1')).resolves.toBe(false) + }) + expect(sendRequest).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Response not sent') + + releaseMobileNativeChatTerminalWrite('terminal') + await act(async () => { + await expect(respond?.('1')).resolves.toBe(true) + }) + // The choice released its own hold on the way out. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) }) diff --git a/mobile/src/session/mobile-native-chat-permission-send.ts b/mobile/src/session/mobile-native-chat-permission-send.ts index b06ff88fe48..7f58b0ab44e 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.ts @@ -4,6 +4,10 @@ import { sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' export function sendMobileNativeChatPermissionResponse(args: { client: RpcClient @@ -36,15 +40,26 @@ export function useMobileNativeChatPermissionSend(args: { args.onSendError('Response not sent (disconnected)') return false } + // A choice keystroke must not interleave into a mid-flight composed write + // (image paste, paced answer) on the same PTY. + if (!acquireMobileNativeChatTerminalWrite(terminal)) { + args.onSendError('Response not sent') + return false + } // No stale-input heal here (unlike the text/ask sends): a choice is an // `enter: false` key for an active overlay that swallows the clear, so it // would consume the marker still protecting the next real message. - const outcome = await sendMobileNativeChatPermissionResponse({ - client: args.client, - terminal, - deviceToken: args.deviceTokenRef.current, - text - }) + let outcome: MobileNativeChatSendOutcome + try { + outcome = await sendMobileNativeChatPermissionResponse({ + client: args.client, + terminal, + deviceToken: args.deviceTokenRef.current, + text + }) + } finally { + releaseMobileNativeChatTerminalWrite(terminal) + } if (outcome === 'unknown') { // Why: the response may have been delivered (ack lost / path cutover) — // a definite "not sent" would invite a double answer. diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index 4dabff8f600..b4a273d31ca 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { - buildMobileNativeChatData, + buildMobileNativeChatTransientData, + foldMobileNativeChatMessages, mobileNativeChatEmptyState } from './mobile-native-chat-render-data' @@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => { }) }) -describe('buildMobileNativeChatData', () => { +/** Mirrors the view: fold the raw transcript, then assemble the list. */ +function build( + messages: NativeChatMessage[], + streaming: string | null, + pending: Parameters[0]['pending'] +): NativeChatMessage[] { + return buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages(messages), + streaming, + pending + }).data +} + +describe('buildMobileNativeChatTransientData', () => { it('appends pending optimistic messages at the tail as user turns', () => { - const { data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'hello')], - streamingText: undefined, - pending: [{ id: 'p1', text: 'queued' }] - }) + const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }]) const last = data[data.length - 1] expect(last.id).toBe('p1') expect(last.role).toBe('user') @@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => { }) it('renders a pending send with images as text followed by image-ref thumbnails', () => { - const { data } = buildMobileNativeChatData({ - messages: [], - pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }] - }) + const data = build([], null, [ + { id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] } + ]) const last = data[data.length - 1] expect(last.role).toBe('user') expect(last.blocks).toEqual([ @@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => { }) it('renders an image-only pending send (no text) as just the thumbnail', () => { - const { data } = buildMobileNativeChatData({ - messages: [], - pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }] - }) + const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]) expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }]) }) @@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => { // Claude records an attached image as `[Image: source: /path]` + an // `[Image #1] `-prefixed caption turn; the fold must merge them into one // user turn with an image-ref block instead of showing raw marker text. - const { data } = buildMobileNativeChatData({ - messages: [ + const data = build( + [ user('u1', '[Image: source: /tmp/a.png]'), user('u2', '[Image #1] look at this'), assistant('a1', 'nice photo') ], - pending: [] - }) + null, + [] + ) const merged = data.find((message) => message.role === 'user') expect(merged?.blocks).toEqual([ { type: 'image-ref', path: '/tmp/a.png' }, @@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => { }) it('renders a lone image marker turn (no caption) as an image-ref block', () => { - const { data } = buildMobileNativeChatData({ - messages: [user('u1', '[Image: source: /tmp/a.png]')], - pending: [] - }) + const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, []) expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) - it('adds a synthetic streaming bubble while the partial text leads the transcript', () => { - const { streaming, data } = buildMobileNativeChatData({ - messages: [user('u1', 'hi')], - streamingText: 'thinking out loud', - pending: [] - }) - expect(streaming).toBe('thinking out loud') - expect(data.some((m) => m.id === 'streaming')).toBe(true) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { + // Whether text streams at all is the gate's call + // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. + const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }]) + expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1']) + expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }]) }) - it('shows a short new streaming reply even after a longer previous turn', () => { - // The last folded turn is a long completed reply; a short new stream must not - // be suppressed just for being shorter than the prior turn. - const { streaming, data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')], - streamingText: 'Ok', - pending: [] - }) - expect(streaming).toBe('Ok') - expect(data.some((m) => m.id === 'streaming')).toBe(true) - }) - - it('drops the streaming bubble once the real assistant turn already contains it', () => { - const { streaming, data } = buildMobileNativeChatData({ - messages: [assistant('a1', 'done answer')], - streamingText: 'done', - pending: [] - }) - expect(streaming).toBeNull() - expect(data.some((m) => m.id === 'streaming')).toBe(false) - }) - - it('returns no streaming bubble for empty/whitespace streaming text', () => { - expect( - buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming - ).toBeNull() - expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull() + it('omits the bubble when the gate withheld the streaming text', () => { + const data = build([assistant('a1', 'done answer')], null, []) + expect(data.some((message) => message.id === 'streaming')).toBe(false) }) }) diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index 068050fc43d..e11b9ac56f4 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = { images?: string[] } -/** Derive the list data from the raw transcript: fold tool turns into the - * assistant turn, optionally append a synthetic streaming bubble, then the - * route-owned optimistic "queued" messages at the tail. Returns the - * intermediate `folded`/`streaming` so the caller can memoize on them. */ -export function buildMobileNativeChatData({ - messages, - streamingText, - pending -}: { - messages: NativeChatMessage[] - streamingText?: string - pending: MobileNativeChatPendingItem[] -}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { - const folded = foldMobileNativeChatMessages(messages) - return buildMobileNativeChatTransientData({ folded, streamingText, pending }) -} - export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] { // Normalize first (desktop assembler parity): image marker turns fold into // image-ref blocks instead of rendering as raw `[Image: …]` text. return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages))) } +/** Assemble the list data the chat renders: the folded transcript, then a + * synthetic bubble for the streaming text the gate let through, then the + * route-owned optimistic "queued" messages at the tail. */ export function buildMobileNativeChatTransientData({ folded, - streamingText, + streaming, pending }: { folded: NativeChatMessage[] - streamingText?: string + /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ + streaming: string | null pending: MobileNativeChatPendingItem[] }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { - // Only show the streaming bubble while its text leads the transcript — once the - // real assistant turn lands with the same text, drop the synthetic one. - const streaming = deriveStreaming(folded, streamingText) const data: NativeChatMessage[] = [ ...folded, ...(streaming @@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({ ] return { folded, streaming, data } } - -function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null { - const text = streamingText?.trim() - if (!text) { - return null - } - const last = folded[folded.length - 1] - const lastText = - last?.role === 'assistant' - ? last.blocks - .filter((b) => b.type === 'text') - .map((b) => (b.type === 'text' ? b.text : '')) - .join('') - .trim() - : '' - // Hide the synthetic bubble only once the real turn has landed leading with the - // streamed text. A bare length compare would suppress a short new reply behind a - // longer previous turn; a completed prior turn won't start with the new prefix. - if (lastText.startsWith(text)) { - return null - } - return text -} diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.test.ts b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts new file mode 100644 index 00000000000..312be591ca9 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming, + type MobileNativeChatStreamingGate +} from './mobile-native-chat-streaming-gate' + +function assistant(id: string, text: string): NativeChatMessage { + return { + id, + role: 'assistant', + blocks: [{ type: 'text', text }], + timestamp: 0, + source: 'transcript' + } +} + +/** Run a sequence of (folded, streamingText) ticks through one gate. */ +function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): { + gate: MobileNativeChatStreamingGate + results: (string | null)[] +} { + let gate = createMobileNativeChatStreamingGate() + const results: (string | null)[] = [] + for (const tick of ticks) { + const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, { + streamLive: tick.live + }) + gate = step.gate + results.push(step.streaming) + } + return { gate, results } +} + +describe('deriveMobileNativeChatStreaming', () => { + it('shows a genuine reply that repeats the previous turn as a prefix', () => { + const prior = [assistant('a1', 'The tests pass.')] + const { results } = run([ + { folded: prior }, // idle tick anchors the pre-stream tail + { folded: prior, text: 'The' }, + { folded: prior, text: 'The tests' }, + { folded: prior, text: 'The tests pass.' } + ]) + expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.']) + }) + + it('hides the bubble once the real turn lands leading with the streamed text', () => { + const prior = [assistant('a1', 'earlier turn')] + const landed = [...prior, assistant('a2', 'fresh answer with a tail')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'fresh answer' }, + { folded: landed, text: 'fresh answer' } + ]) + expect(results).toEqual([null, 'fresh answer', null]) + }) + + it('suppresses an identical repeated reply once its own turn lands', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible + { folded: landed, text: 'Done.' } // its own turn landed — hide + ]) + expect(results).toEqual([null, 'Done.', null]) + }) + + it('keeps hiding for the rest of a segment after the turn lands', () => { + const prior = [assistant('a1', 'earlier')] + const landed = [...prior, assistant('a2', 'answer body')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'answer' }, + { folded: landed, text: 'answer' }, + { folded: landed, text: 'answer bo' } + ]) + expect(results).toEqual([null, 'answer', null, null]) + }) + + it('keeps the segment baseline through textless ticks while the turn is live', () => { + // Chat is hidden mid-stream: the transcript unsubscribes and the status + // stops reaching the gate, but the turn has not ended. Coming back, the + // stream text returns before the re-read transcript does. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: [], text: 'Done.', live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.']) + }) + + it('still hides after a hidden gap once the reply landed as its own turn', () => { + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Done.', live: true }, + { folded: [], live: true }, + { folded: landed, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.', null, null]) + }) + + it('hides a reply whose own turn landed before its status text arrived', () => { + // The pane stays `working` past the reply (a subagent or a background task + // is still live), and the transcript push beats the throttled status text. + // Anchoring on that textless tick would adopt the reply as pre-stream + // history and render it a second time as a bubble. + const prior = [assistant('a1', 'Done.')] + const landed = [...prior, assistant('a2', 'Done.')] + const { results } = run([ + { folded: prior, live: true }, + { folded: landed, live: true }, + { folded: landed, text: 'Done.', live: true }, + { folded: landed, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, null, null, null]) + }) + + it('keeps the pre-stream baseline across a hidden gap taken between turns', () => { + // Peeking at the terminal while idle tears the transcript down to empty. An + // empty tail is not history: adopting it strands the baseline and swallows + // the repeated-prefix reply that arrives next. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior }, + { folded: [] }, + { folded: [], live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, null, null, 'Done.']) + }) + + it('anchors on the first tail it sees when mounted mid-turn', () => { + // Opening a workspace whose agent is already working: that first textless + // tick is the only pre-stream history the gate will ever get. + const prior = [assistant('a1', 'Done.')] + const { results } = run([ + { folded: prior, live: true }, + { folded: prior, text: 'Done.', live: true } + ]) + expect(results).toEqual([null, 'Done.']) + }) + + it('anchors on a textless tick once the turn ends', () => { + const prior = [assistant('a1', 'first answer')] + const landed = [...prior, assistant('a2', 'second answer')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'second answer', live: true }, + { folded: landed }, + { folded: landed, text: 'second answer', live: true } + ]) + expect(results).toEqual([null, 'second answer', null, 'second answer']) + }) + + it('does not treat the previous turn as a segment start after re-anchoring', () => { + // The textless anchor clears the remembered text too. Keeping it would read + // the next turn's opener as a new segment, re-anchor onto the reply that + // just landed, and render it a second time as a bubble. + const prior = [assistant('a1', 'context')] + const firstLanded = [...prior, assistant('a2', 'Alpha done')] + const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'Alpha', live: true }, + { folded: firstLanded }, // turn ended — re-anchor onto a2 + { folded: secondLanded, text: 'Beta', live: true } // a3 already landed + ]) + expect(results).toEqual([null, 'Alpha', null, null]) + }) + + it('re-anchors when a new reply part replaces the stream mid-turn', () => { + const prior = [assistant('a1', 'context')] + const partOneLanded = [...prior, assistant('a2', 'part one full text')] + const { results } = run([ + { folded: prior }, + { folded: prior, text: 'part one' }, + { folded: partOneLanded, text: 'part one' }, // caught up — hide + // Part two is not an extension of part one: new segment, new baseline. + { folded: partOneLanded, text: 'part' } + ]) + expect(results).toEqual([null, 'part one', null, 'part']) + }) + + it('falls back to suppress-on-prefix when text arrives on the first tick', () => { + // No tail ever observed before the text: a duplicate bubble is worse than + // briefly hiding a mount-coincident repeated reply. + const landed = [assistant('a1', 'flushed part still streaming in status')] + const { results } = run([{ folded: landed, text: 'flushed part' }]) + expect(results).toEqual([null]) + }) + + it('is idempotent for a repeated tick', () => { + const prior = [assistant('a1', 'The tests pass.')] + const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }]) + const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests') + expect(again.streaming).toBe('The tests') + expect(again.gate).toBe(first.gate) + }) + + it('drops a prior chat baseline when the stream identity changes', () => { + // The other chat's tail must not license showing a bubble here — a swapped + // scope resets to the mid-stream fallback rather than reusing its baseline. + const repeatedId = [assistant('a1', 'new answer landed')] + let gate = createMobileNativeChatStreamingGate('tab-a') + gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate + + const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', { + scopeKey: 'tab-b' + }) + + expect(switched.streaming).toBeNull() + expect(switched.gate.scopeKey).toBe('tab-b') + expect(switched.gate.baselineTailId).toBeNull() + }) + + it('returns null for empty or whitespace streaming text', () => { + const prior = [assistant('a1', 'x')] + expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null]) + expect(run([{ folded: prior }]).results).toEqual([null]) + }) + + it('shows the first reply of an empty chat and hides it once the turn lands', () => { + const landed = [assistant('a1', 'Hello there')] + const { results } = run([ + { folded: [] }, + { folded: [], text: 'Hello' }, + { folded: landed, text: 'Hello' } + ]) + expect(results).toEqual([null, 'Hello', null]) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-streaming-gate.ts b/mobile/src/session/mobile-native-chat-streaming-gate.ts new file mode 100644 index 00000000000..a857803214c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-streaming-gate.ts @@ -0,0 +1,93 @@ +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' + +/** Decides whether the live streaming preview should render as a synthetic + * bubble. Text alone can't tell "the transcript caught up with this stream" + * from "a new reply happens to repeat the previous turn's prefix" — the old + * prefix test swallowed genuine repeated-prefix replies. The gate keeps the + * transcript tail observed when the current stream segment began: the bubble + * hides only when the tail MOVED during the segment and leads with the + * streamed text (the real turn landed), never for an older identical turn. */ +export type MobileNativeChatStreamingGate = { + /** Chat/session identity this baseline belongs to. */ + scopeKey: string | null + /** Streamed text seen on the previous tick ('' while idle). */ + prevText: string + /** Folded tail message id when the current segment began; null while the + * gate has never observed a transcript tail (text arrived on its very first + * tick), where the legacy suppress-on-prefix rule applies. */ + baselineTailId: string | null +} + +export function createMobileNativeChatStreamingGate( + scopeKey: string | null = null +): MobileNativeChatStreamingGate { + return { scopeKey, prevText: '', baselineTailId: null } +} + +function assistantTailText(tail: NativeChatMessage | undefined): string { + if (!tail || tail.role !== 'assistant') { + return '' + } + return tail.blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join('') + .trim() +} + +// Reuses the incoming gate object when nothing moved, so a caller can detect +// "no change" by reference (and a render-time state adjustment can settle). +function advanceGate( + gate: MobileNativeChatStreamingGate, + prevText: string, + baselineTailId: string | null +): MobileNativeChatStreamingGate { + return gate.prevText === prevText && gate.baselineTailId === baselineTailId + ? gate + : { ...gate, prevText, baselineTailId } +} + +/** Advance the gate one tick and derive the visible streaming text (null hides + * the bubble). Pure and idempotent for a repeated (text, tail) pair, so a + * re-render without new data cannot flip the decision. */ +export function deriveMobileNativeChatStreaming( + gate: MobileNativeChatStreamingGate, + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + options: { + scopeKey?: string | null + /** Whether the agent is still mid-turn. A textless tick then means "no + * observation this render", not "the stream ended". */ + streamLive?: boolean + } = {} +): { gate: MobileNativeChatStreamingGate; streaming: string | null } { + const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey + const scopedGate = + gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey) + const text = streamingText?.trim() ?? '' + const tail = folded.at(-1) + const tailId = tail?.id ?? null + if (!text) { + // Only a textless tick that carries a real tail and is outside a live turn + // is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle + // lull, the transcript landing the reply before its status text) would + // otherwise adopt that reply as history and render it a second time as a + // bubble; a torn-down transcript carries no tail at all. The exception is a + // gate that has never anchored — mounted mid-turn, the first real tail it + // sees is the best pre-stream history it will ever get. + const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null) + return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null } + } + // A stream that is not an extension of the previous tick is a new segment + // (next reply part); re-anchor to the tail that predates it. + const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText) + const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId + const tailLeadsWithStream = assistantTailText(tail).startsWith(text) + // A null baseline (text on the very first tick, no tail ever seen) is unequal + // to every real tail id, so this degrades to the legacy suppress-on-prefix rule. + const caughtUp = tailLeadsWithStream && tailId !== baselineTailId + return { + gate: advanceGate(scopedGate, text, baselineTailId), + streaming: caughtUp ? null : text + } +} diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts new file mode 100644 index 00000000000..c15ce3c826e --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' + +describe('mobile-native-chat-terminal-write-lock', () => { + afterEach(resetMobileNativeChatTerminalWritesForTests) + + it('allows composed writes on different terminals to proceed concurrently', () => { + expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(true) + + expect(acquireMobileNativeChatTerminalWrite('terminal-b')).toBe(true) + expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(false) + + releaseMobileNativeChatTerminalWrite('terminal-a') + releaseMobileNativeChatTerminalWrite('terminal-b') + }) +}) diff --git a/mobile/src/session/mobile-native-chat-terminal-write-lock.ts b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts new file mode 100644 index 00000000000..5624a384ed3 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-terminal-write-lock.ts @@ -0,0 +1,25 @@ +// Serializes composed native-chat write sequences (clear/paste/settle/submit, +// paced answer keystrokes) per HOST terminal. Two concurrent sequences into one +// PTY interleave their bytes; a second sender must be rejected up front, not +// woven in. Module scope for the same reason as the stale-input marker: the +// terminal outlives any one screen, and independent hooks share the same PTY. +const writeInFlightTerminals = new Set() + +/** Claim the terminal for one composed write sequence. False = another + * sequence is mid-flight; the caller must reject its send. */ +export function acquireMobileNativeChatTerminalWrite(terminal: string): boolean { + if (writeInFlightTerminals.has(terminal)) { + return false + } + writeInFlightTerminals.add(terminal) + return true +} + +export function releaseMobileNativeChatTerminalWrite(terminal: string): void { + writeInFlightTerminals.delete(terminal) +} + +/** Test-only: module scope outlives a single test's hooks. */ +export function resetMobileNativeChatTerminalWritesForTests(): void { + writeInFlightTerminals.clear() +} diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-terminal-file-tap-open.test.ts index fec1b5b663c..a958b977260 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.test.ts +++ b/mobile/src/session/mobile-terminal-file-tap-open.test.ts @@ -135,6 +135,56 @@ describe('openMobileTerminalFileTap', () => { expect(switchSessionTab).toHaveBeenCalledWith(openedTab) }) + it('opens a sibling terminal path through the resolved owning worktree', async () => { + const client = createClient([ + ok({ + worktree: 'wt-2', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'docs/readme.md', + absolutePath: '/repo-b/docs/readme.md' + } + }) + ]) + const pushPreviewRoute = vi.fn() + + openMobileTerminalFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: '/repo-b/docs/readme.md', + line: null, + column: null, + pushPreviewRoute, + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn() + }) + await Promise.resolve() + await Promise.resolve() + + expect(pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + hostId: 'host-1', + worktreeId: 'wt-2', + source: 'worktree', + relativePath: 'docs/readme.md' + }) + }) + expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything()) + }) + it('opens worktree-contained line references through the preview route', async () => { const client = createClient([ ok({ diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-terminal-file-tap-open.ts index 31a5c892d75..8d6e681b0f4 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.ts +++ b/mobile/src/session/mobile-terminal-file-tap-open.ts @@ -74,13 +74,17 @@ async function openMobileTerminalFileTapAsync @@ -40,17 +46,24 @@ describe('useMobileNativeChatAnswerSend', () => { let mountedClient: RpcClient | null = null let mountedOnSendError: ((message: string) => void) | null = null let mountedAgent: AgentType = 'claude' + // The route sends through useNativeChatAcceptedAction, whose accepted callback + // retires the shared send-error banner (use-mobile-native-chat-controller.ts). + let acceptedAnswerAsk: AnswerSend['answerAsk'] | null = null + let onAccepted = vi.fn() beforeEach(() => { + onAccepted = vi.fn() vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => renderer?.unmount()) renderer = null answerSend = null + acceptedAnswerAsk = null mountedClient = null mountedOnSendError = null mountedAgent = 'claude' @@ -68,6 +81,7 @@ describe('useMobileNativeChatAnswerSend', () => { streamIdentity: 'host\0worktree\0tab\0session', onSendError: mountedOnSendError! }) + acceptedAnswerAsk = useNativeChatAcceptedAction(answerSend.answerAsk, onAccepted) return null } @@ -396,4 +410,517 @@ describe('useMobileNativeChatAnswerSend', () => { await expect(result).resolves.toBe(false) expect(sendRequest).toHaveBeenCalledTimes(1) }) + + it('rejects an answer while another composed write holds the terminal', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + // An image paste sequence is mid-flight into the same PTY. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false) + expect(sendRequest).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + + // Once that sequence releases, answers flow again. + releaseMobileNativeChatTerminalWrite('terminal') + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + }) + + it('answers again on the same handle after an earlier answer already landed', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])).resolves.toBe(true) + // A landed answer resolves its turn FALSE — correct for a queued successor, + // fatal if the turn outlives the chain. Leaving it parked in the slot fences + // every later answer on this handle for the life of the hook, not just an + // overlapping one, so the ask card dies after its first use. + await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('fences a superseding answer after the cancelled chain moved the selector', async () => { + const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }]) + }) + // The first digit already advanced Claude to q2. Replaying a from-q1 key + // plan now would answer the wrong question. + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + // Both chains unwound: the terminal is free for the next composed write. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) + + it('does not write a successor after the prior in-flight key is accepted', async () => { + let resolveFirst: (response: unknown) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + + // The successor is queued behind the in-flight key, not racing it. + expect(sendRequest).toHaveBeenCalledTimes(1) + await act(async () => { + resolveFirst(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('lets a queued successor continue after the prior key is definitely rejected', async () => { + let resolveFirst: (response: unknown) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + resolveFirst({ + ...acceptedResponse(), + result: { send: { accepted: false } } + }) + await Promise.resolve() + }) + + // Nothing landed, so the selector never moved — the successor is safe. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) + + it('does not write a successor after the prior delivery becomes ambiguous', async () => { + let rejectFirst: (error: Error) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed'))) + await Promise.resolve() + }) + + // The key may have landed; a blind successor could double-step the selector. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('tells the user when a queued answer is fenced instead of dropping it silently', async () => { + const onSendError = vi.fn() + let rejectFirst: (error: Error) => void = () => undefined + const sendRequest = vi + .fn() + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject + }) + ) + .mockResolvedValue(acceptedResponse()) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed'))) + await Promise.resolve() + }) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + // The card re-enables on a false result, so an unreported fence looks exactly + // like a dead button. The superseded chain stays quiet — the newest answer + // owns the error surface, and it is the one the user is waiting on. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('tells the user when a dropped lease fences a queued answer whose predecessor landed', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + // A transport blip, not a user cancel: unlike Stop and ask-cancel, the lost + // input lease writes no Escape, so the card stays up with Submit re-enabled. + await setEnabled(false) + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + expect(sendRequest).toHaveBeenCalledTimes(1) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + // The first option key LANDED, so the live selector already moved. Reporting + // nothing invites a retry that double-steps it. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('keeps the terminal locked while a queued successor writes', async () => { + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + // Nothing landed, so the successor is cleared to write. + await act(async () => { + settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } }) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + // The successor's key is on the wire. The superseded chain unwinding behind it + // must NOT free the terminal, or an image paste interleaves into this sequence. + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(false) + + await act(async () => { + settle[1]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(true) + expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true) + releaseMobileNativeChatTerminalWrite('terminal') + }) + + it('does not retire the fence banner when the superseded answer lands', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // The healthy path: the first answer LANDS, which is also the case that fences + // hardest — its key moved the live selector. + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + // A superseded chain reporting success would clear the banner it just raised — + // the accepted hook runs after the fence, so the user would see nothing at all. + expect(onAccepted).not.toHaveBeenCalled() + expect(sendRequest).toHaveBeenCalledTimes(1) + }) + + it('does not retire the fence banner when a superseded pasted answer lands', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + + let first: Promise | undefined + let second: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The pasted shape commits with Enter, so a superseded chain is doubly unsafe + // to report as accepted — the answer it committed is not the one on screen. + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + expect(onAccepted).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('reports a landed answer that Stop cancelled with no successor waiting', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // Stop, an ask cancel, and a dropped input lease all bump the generation with + // NO successor chain behind them. + await act(async () => { + answerSend?.cancelPending() + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The key is on the PTY and nobody else owns the surface. Calling that a + // non-send leaves the card up and silent (fail() never runs on an accepted + // write), and the retry double-steps the selector this key already moved. + await expect(first).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('reports a landed pasted answer that Stop cancelled with no successor', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok') + + let first: Promise | undefined + await act(async () => { + first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + answerSend?.cancelPending() + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + + // The pasted shape already committed with Enter, so suppressing the success + // strands an answered card the user is invited to submit a second time. + await expect(first).resolves.toBe(true) + expect(onAccepted).toHaveBeenCalledTimes(1) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('fences a third answer behind an already-fenced successor, reporting once', async () => { + const onSendError = vi.fn() + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + let first: Promise | undefined + let second: Promise | undefined + let third: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + await act(async () => { + third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(1) + + await act(async () => { + settle[0]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + await expect(third).resolves.toBe(false) + // The middle chain sent nothing, so only the verdict it INHERITED can stop the + // third from replaying a from-scratch plan onto the advanced selector. + expect(sendRequest).toHaveBeenCalledTimes(1) + // Only the newest chain owns the error surface. + expect(onSendError).toHaveBeenCalledTimes(1) + expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying') + }) + + it('queues a late third answer behind the successor already on the wire', async () => { + const settle: Array<(response: unknown) => void> = [] + const sendRequest = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + settle.push(resolve) + }) + ) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + let first: Promise | undefined + let second: Promise | undefined + let third: Promise | undefined + await act(async () => { + first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + }) + await act(async () => { + second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }]) + await Promise.resolve() + }) + // Nothing landed, so the successor is cleared and puts its own key on the wire. + await act(async () => { + settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } }) + await Promise.resolve() + }) + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }]) + await Promise.resolve() + await Promise.resolve() + }) + // The first chain unwound while the second was mid-write: it must not have + // dropped the second's turn, or this one writes into the same PTY concurrently. + expect(sendRequest).toHaveBeenCalledTimes(2) + + await act(async () => { + settle[1]!(acceptedResponse()) + await Promise.resolve() + }) + await expect(first).resolves.toBe(false) + await expect(second).resolves.toBe(false) + await expect(third).resolves.toBe(false) + expect(sendRequest).toHaveBeenCalledTimes(2) + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 11a3a030788..b893f7ae071 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -14,6 +14,10 @@ import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' import { resolveNativeChatTranscriptAgent, shouldStepNativeChatAskAnswer @@ -72,6 +76,12 @@ export function useMobileNativeChatAnswerSend(args: { const generationRef = useRef(0) const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity }) activeRouteRef.current = { client, enabled, sessionId, streamIdentity } + // Per-terminal count of this hook's chains sharing one write-lock hold: a + // superseding answer inherits the cancelled chain's hold (it re-enters before + // the old chain unwinds), and only the last chain out releases the lock. + const writeHoldsRef = useRef(new Map()) + // Successors wait for the prior RPC and inherit any delivery ambiguity. + const writeTurnsRef = useRef(new Map>()) const delaysRef = useRef< Set<{ timer: ReturnType; resolve: (completed: boolean) => void }> >(new Set()) @@ -103,125 +113,191 @@ export function useMobileNativeChatAnswerSend(args: { if (!hasAskAnswer(prompt, selections)) { return false } + // One composed write sequence per terminal: an answer landing mid-flight + // in an image paste (or vice versa) would interleave bytes into the PTY. + // A superseding answer shares the cancelled chain's hold on this terminal + // (that chain has not unwound to its release yet). + const holds = writeHoldsRef.current + const heldCount = holds.get(handle) ?? 0 + if (heldCount === 0 && !acquireMobileNativeChatTerminalWrite(handle)) { + onSendError('Answer not sent') + return false + } + holds.set(handle, heldCount + 1) + const previousTurn = writeTurnsRef.current.get(handle) ?? Promise.resolve(true) + let finishTurn: (safeToContinue: boolean) => void = () => undefined + const turn = new Promise((resolve) => { + finishTurn = resolve + }) + writeTurnsRef.current.set(handle, turn) // A new answer supersedes any still-pending keystroke writes. cancelPending() const generation = generationRef.current let sawUnknownOutcome = false let sawAcceptedGroup = false - // One budget for the whole answer instead of a fresh timeout per keystroke - // group, which let an N-group selector hold the card for N × the send timeout. - // It bounds transport time only: each deliberate pacing wait is credited back - // below, so a long multi-question answer still gets a full budget to write in. - let deadline = openMobileNativeChatSendBudget() - const sendTerminal = async (body: string, enter: boolean): Promise => { - const activeRoute = activeRouteRef.current - if ( - !activeRoute.enabled || - activeRoute.client !== client || - activeRoute.sessionId !== sessionId || - activeRoute.streamIdentity !== streamIdentity || - handleRef.current !== handle - ) { - return false - } - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text: body, - enter, - deadline, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) - if (outcome === 'unknown') { - sawUnknownOutcome = true - } - if (outcome === 'accepted') { - sawAcceptedGroup = true - } - return outcome === 'accepted' - } - const wait = (ms: number): Promise => - new Promise((resolve) => { - const delay = { - timer: setTimeout(() => { - delaysRef.current.delete(delay) - resolve(generationRef.current === generation) - }, ms), - resolve - } - delaysRef.current.add(delay) - }) - const fail = (): false => { - if (generationRef.current === generation) { - // Why: keystrokes that may have landed (ack lost / path cutover) must - // not read as a definite failure — a blind resend could double-step - // the selector. An earlier group that WAS accepted is the same hazard - // in definite form: a multi-question answer whose shared budget ran out - // mid-sequence left the remote selector half-stepped, and telling the - // user nothing was sent invites a retry on top of the advanced state. - onSendError( - sawAcceptedGroup - ? 'Answer partly sent — check chat before retrying' - : sawUnknownOutcome - ? 'Answer unconfirmed — check chat before retrying' - : 'Answer not sent' - ) - } - return false - } - // Grok commits pasted labels; Claude and Codex need their selector-specific - // keystrokes paced so each step renders before the next lands. - if (!shouldStepNativeChatAskAnswer(agentRef.current)) { - // This shape pastes the label into the composer and commits it, so an - // orphaned image paste would be submitted along with the answer (#10228). - // The selector shapes below deliberately skip the heal: their keys are - // `enter: false` for an active overlay, and a single-select answer is a - // bare option digit that cannot submit the line at all, so clearing there - // would consume the marker still protecting the next real message. - // Desktop splits it identically — use-native-chat-interactive-send.ts - // routes only the pasted-label shape through the clearing sender. - if ( - !(await healMobileNativeChatStaleInput({ - client, - terminal: handle, - deviceToken: deviceTokenRef.current, - deadline - })) - ) { - if (generationRef.current === generation) { - onSendError('Answer not sent') + let predecessorSafe = true + try { + predecessorSafe = await previousTurn + if (!predecessorSafe) { + // Fenced. Report it: the card re-enables on a false result, so silence + // here is indistinguishable from a dead button. "Check chat" rather than + // a bare "not sent" because the PREVIOUS answer's keys may have landed. + // Gate on the turn slot, not the generation: a dropped input lease bumps + // the generation without writing the Escape that Stop and ask-cancel do, + // so the card is still up and silence there strands an advanced selector. + if (writeTurnsRef.current.get(handle) === turn) { + onSendError('Answer not sent — check chat before retrying') } return false } + // Superseded by a newer answer, which owns the error surface from here. if (generationRef.current !== generation) { return false } - return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() - } - const groups = - resolveNativeChatTranscriptAgent(agentRef.current) === 'codex' - ? buildCodexAskAnswerKeys(prompt, selections) - : buildAskAnswerKeys(prompt, selections) - for (let index = 0; index < groups.length; index += 1) { - if (generationRef.current !== generation) { - return false - } - const group = groups[index]! - const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) - if (!(await sendTerminal(body, false))) { - return fail() - } - if (index < groups.length - 1) { - if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + // One budget for the whole answer instead of a fresh timeout per keystroke + // group, which let an N-group selector hold the card for N × the send timeout. + // It bounds transport time only: each deliberate pacing wait is credited back + // below, so a long multi-question answer still gets a full budget to write in. + let deadline = openMobileNativeChatSendBudget() + const sendTerminal = async (body: string, enter: boolean): Promise => { + const activeRoute = activeRouteRef.current + if ( + !activeRoute.enabled || + activeRoute.client !== client || + activeRoute.sessionId !== sessionId || + activeRoute.streamIdentity !== streamIdentity || + handleRef.current !== handle + ) { return false } - // Pacing is deliberate, not transport latency — don't charge it to the budget. - deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS + const outcome = await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text: body, + enter, + deadline, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (outcome === 'unknown') { + sawUnknownOutcome = true + } + if (outcome === 'accepted') { + sawAcceptedGroup = true + } + return outcome === 'accepted' + } + const wait = (ms: number): Promise => { + // Already superseded: don't hold the successor for a full pacing step + // waiting on a timer whose only job is to report the cancellation. + if (generationRef.current !== generation) { + return Promise.resolve(false) + } + return new Promise((resolve) => { + const delay = { + timer: setTimeout(() => { + delaysRef.current.delete(delay) + resolve(generationRef.current === generation) + }, ms), + resolve + } + delaysRef.current.add(delay) + }) + } + const fail = (): false => { + if (generationRef.current === generation) { + // Why: keystrokes that may have landed (ack lost / path cutover) must + // not read as a definite failure — a blind resend could double-step + // the selector. An earlier group that WAS accepted is the same hazard + // in definite form: a multi-question answer whose shared budget ran out + // mid-sequence left the remote selector half-stepped, and telling the + // user nothing was sent invites a retry on top of the advanced state. + onSendError( + sawAcceptedGroup + ? 'Answer partly sent — check chat before retrying' + : sawUnknownOutcome + ? 'Answer unconfirmed — check chat before retrying' + : 'Answer not sent' + ) + } + return false + } + // Grok commits pasted labels; Claude and Codex need their selector-specific + // keystrokes paced so each step renders before the next lands. + if (!shouldStepNativeChatAskAnswer(agentRef.current)) { + // This shape pastes the label into the composer and commits it, so an + // orphaned image paste would be submitted along with the answer (#10228). + // The selector shapes below deliberately skip the heal: their keys are + // `enter: false` for an active overlay, and a single-select answer is a + // bare option digit that cannot submit the line at all, so clearing there + // would consume the marker still protecting the next real message. + // Desktop splits it identically — use-native-chat-interactive-send.ts + // routes only the pasted-label shape through the clearing sender. + if ( + !(await healMobileNativeChatStaleInput({ + client, + terminal: handle, + deviceToken: deviceTokenRef.current, + deadline + })) + ) { + if (generationRef.current === generation) { + onSendError('Answer not sent') + } + return false + } + if (generationRef.current !== generation) { + return false + } + // A chain a successor took over from must not report success either: an + // accepted answer retires the shared send-error banner, wiping the + // successor's fence. Test the turn slot, not the generation counter — + // Stop, ask-cancel and a dropped lease all bump the generation with no + // successor, and there a landed answer IS a success. + const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail() + return sent && writeTurnsRef.current.get(handle) === turn + } + const groups = + resolveNativeChatTranscriptAgent(agentRef.current) === 'codex' + ? buildCodexAskAnswerKeys(prompt, selections) + : buildAskAnswerKeys(prompt, selections) + for (let index = 0; index < groups.length; index += 1) { + if (generationRef.current !== generation) { + return false + } + const group = groups[index]! + const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text) + if (!(await sendTerminal(body, false))) { + return fail() + } + if (index < groups.length - 1) { + if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + // Pacing is deliberate, not transport latency — don't charge it to the budget. + deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS + } + } + // Taken over on the last key: same as above, the successor owns the surface. + return groups.length > 0 && writeTurnsRef.current.get(handle) === turn + } finally { + // Any accepted key changed the live selector, so a queued replacement + // cannot safely apply its from-scratch key plan to that new position. + finishTurn(predecessorSafe && !sawUnknownOutcome && !sawAcceptedGroup) + if (writeTurnsRef.current.get(handle) === turn) { + writeTurnsRef.current.delete(handle) + } + // Last chain out releases; a superseded chain unwinding late must not + // free the lock out from under the successor sharing its hold. + const remaining = (holds.get(handle) ?? 1) - 1 + if (remaining <= 0) { + holds.delete(handle) + releaseMobileNativeChatTerminalWrite(handle) + } else { + holds.set(handle, remaining) } } - return groups.length > 0 }, [ agentRef, diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 2b4fb0e9d0f..35318d7e7f2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ vi.mock('./use-mobile-native-chat-prompts', () => ({ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null }) })) +const answerSendArgs: { streamIdentity?: string }[] = [] vi.mock('./use-mobile-native-chat-answer-send', () => ({ - useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() }) + useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => { + answerSendArgs.push(args) + return { answerAsk: vi.fn(), cancelPending: vi.fn() } + } })) vi.mock('./mobile-native-chat-permission-send', () => ({ useMobileNativeChatPermissionSend: () => vi.fn() @@ -421,3 +425,99 @@ describe('useMobileNativeChatController launch-draft wiring', () => { expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true }) }) }) + +describe('useMobileNativeChatController streaming scope', () => { + let renderer: ReactTestRenderer | null = null + let controller: MobileNativeChatController | null = null + const clientStub = { sendRequest: vi.fn() } + + const workingTab = { + type: 'terminal', + id: 'tab-1', + terminal: 'term-1', + launchAgent: 'claude', + agentStatus: { + state: 'working', + agentType: 'claude', + providerSession: { id: 'session-1' } + }, + isActive: true + } + + function Harness(): null { + controller = useMobileNativeChatController({ + client: clientStub as unknown as RpcClient, + connState: 'connected', + hostId: 'h', + worktreeId: 'w', + activeSessionTab: workingTab as never, + activeSessionTabId: 'tab-1', + activeHandleRef: { current: 'term-1' }, + deviceTokenRef: { current: null }, + nativeChatTranscriptIsLocalReadable: true, + nativeChatInputLeaseReady: true, + onSendError: vi.fn(), + onSendResolved: vi.fn() + }) + return null + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + viewMode.isTabChatView = () => true + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...a) => { + if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) { + return + } + original(...a) + }) + try { + act(() => { + renderer = create(createElement(Harness)) + }) + } finally { + spy.mockRestore() + } + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + controller = null + viewMode.isTabChatView = () => true + }) + + it('holds the stream scope and liveness while the user peeks at the terminal', () => { + // Both feed the streaming gate, which lives above the chat view's mount and + // must not be reset by a view toggle. + expect(controller?.showNativeChat).toBe(true) + const scopeKey = controller?.nativeChatStreamScopeKey + expect(scopeKey).toContain('session-1') + expect(controller?.nativeChatStreamLive).toBe(true) + + viewMode.isTabChatView = () => false + act(() => renderer?.update(createElement(Harness))) + + expect(controller?.showNativeChat).toBe(false) + expect(controller?.nativeChatAgentWorking).toBe(false) + expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey) + expect(controller?.nativeChatStreamLive).toBe(true) + }) + + it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => { + // `streamIdentity` fences a delayed answer/stop to the route it was armed + // on, so it must still drop its session when chat closes — the scope key is + // the one that has to survive the toggle. Same string while chat is open. + const before = answerSendArgs.at(-1)?.streamIdentity + expect(before).toBe(controller?.nativeChatStreamScopeKey) + + viewMode.isTabChatView = () => false + act(() => renderer?.update(createElement(Harness))) + + const after = answerSendArgs.at(-1)?.streamIdentity + expect(after).not.toBe(before) + expect(after).not.toContain('session-1') + expect(controller?.nativeChatStreamScopeKey).toBe(before) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index fd0a564b19c..37f7b5399c2 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -49,6 +49,10 @@ export type MobileNativeChatController = { nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string + /** Agent mid-turn, regardless of whether chat is the visible view. */ + nativeChatStreamLive: boolean + /** Host/workspace/tab/session scope for stateful streaming suppression. */ + nativeChatStreamScopeKey: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType nativeChatAsk: ReturnType @@ -124,7 +128,12 @@ export function useMobileNativeChatController(args: { activeChatAgentRef.current = activeChatResolution?.agent ?? null const activeChatSessionId = activeChatResolution?.sessionId ?? null - const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}` + const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}` + // Same chat, but keyed off the tab rather than the view-gated resolution: + // `streamIdentity` goes session-less the moment the user peeks at the terminal, + // and a scope that flips on a view toggle throws the gate's baseline away. + const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}` const nativeChatSession = useMobileNativeChatSession({ client, @@ -160,6 +169,9 @@ export function useMobileNativeChatController(args: { const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null const nativeChatAgentWorking = nativeChatStatus?.state === 'working' + // Deliberately not gated on the chat view being visible: the streaming gate + // has to tell "hidden mid-turn" from "the turn ended". + const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working' // Throttle the streaming bubble: OpenCode emits a status frame per streamed // part, and each one re-renders and re-parses the whole accumulated markdown. const nativeChatStreamingText = useThrottledLatestValue( @@ -273,6 +285,8 @@ export function useMobileNativeChatController(args: { nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, + nativeChatStreamLive, + nativeChatStreamScopeKey: streamScopeKey, nativeChatPermission, nativeChatQuestion, nativeChatAsk, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index be2f9a3730e..13cb5d96c48 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock' import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments' // Fully stub the picker so the real expo/react-native chain never loads under @@ -88,9 +89,10 @@ describe('useMobileNativeChatImageAttachments', () => { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true pick.mockReset() - // Stale markers live at module scope now (they outlive the screen), so they - // also outlive a test. + // Stale markers and write locks live at module scope (they outlive the + // screen), so they also outlive a test. resetMobileNativeChatStaleInputForTests() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => renderer?.unmount()) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index a692b11acf1..38f9e462e46 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -26,6 +26,10 @@ import { isMobileNativeChatInputStale, markMobileNativeChatInputStale } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' type CurrentRef = { readonly current: T } type ShowToast = (message: string, durationMs?: number) => void @@ -127,8 +131,6 @@ export function useMobileNativeChatImageAttachments({ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect. const connStateRef = useRef(connState) connStateRef.current = connState - // Serialize clear/paste/submit ownership per terminal while allowing other tabs to send. - const sendInFlightTerminalsRef = useRef(new Set()) const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS @@ -219,15 +221,15 @@ export function useMobileNativeChatImageAttachments({ const sendNativeChat = useCallback( async (text: string): Promise => { + // Serialize clear/paste/submit ownership per terminal while allowing other + // tabs to send. Shared with the prompt-card writes (answer/permission), so + // a card tap can't interleave into a mid-flight paste sequence either. const operationTerminal = activeHandleRef.current - if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) { + if (operationTerminal && !acquireMobileNativeChatTerminalWrite(operationTerminal)) { onError?.() onSendError('Message not sent') return false } - if (operationTerminal) { - sendInFlightTerminalsRef.current.add(operationTerminal) - } // One budget for the whole user action. The paste loop, the settle, and the // text body that follows are a single send from the composer's point of view; // opening a budget per leg let `sending` run to twice the stated ceiling. @@ -347,7 +349,7 @@ export function useMobileNativeChatImageAttachments({ } } finally { if (operationTerminal) { - sendInFlightTerminalsRef.current.delete(operationTerminal) + releaseMobileNativeChatTerminalWrite(operationTerminal) } } }, diff --git a/mobile/src/session/use-mobile-native-chat-message-send.test.ts b/mobile/src/session/use-mobile-native-chat-message-send.test.ts index da36cdb925b..7c7c8e5814f 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.test.ts @@ -19,6 +19,11 @@ vi.mock('./mobile-native-chat-stale-input', () => ({ })) import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' type Send = ReturnType @@ -28,6 +33,7 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123' describe('useMobileNativeChatMessageSend', () => { let renderer: ReactTestRenderer | null = null let api: Send | null = null + let onSendError = vi.fn() const mount = ( readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null @@ -44,7 +50,7 @@ describe('useMobileNativeChatMessageSend', () => { restoreRejectedDraft: () => {}, acceptSend: () => {}, holdUnconfirmedSend: () => {}, - onSendError: () => {} + onSendError }) return null } @@ -70,6 +76,8 @@ describe('useMobileNativeChatMessageSend', () => { sendWithOutcome.mockResolvedValue('accepted') clearInputWrite.mockReset() clearInputWrite.mockResolvedValue(true) + onSendError = vi.fn() + resetMobileNativeChatTerminalWritesForTests() }) afterEach(() => { act(() => { @@ -173,4 +181,27 @@ describe('useMobileNativeChatMessageSend', () => { }) expect(sentArgs().resolvedLaunchDraft).toBeUndefined() }) + + it('rejects a question answer while another composed write holds the terminal', async () => { + mount(() => null) + // An image paste sequence is mid-flight into the same PTY. + expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) + + let result: boolean | undefined + await act(async () => { + result = await api!.answerQuestion('1') + }) + expect(result).toBe(false) + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Answer not sent') + + releaseMobileNativeChatTerminalWrite('term') + await act(async () => { + result = await api!.answerQuestion('1') + }) + expect(result).toBe(true) + // The answer released its own hold on the way out. + expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true) + releaseMobileNativeChatTerminalWrite('term') + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 4143af338c7..7847f83c8e0 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -7,6 +7,10 @@ import { type MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite +} from './mobile-native-chat-terminal-write-lock' import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear' @@ -210,11 +214,26 @@ export function useMobileNativeChatMessageSend(args: { [sendWithOutcome] ) - // A question answer is not composer text, so it never syncs the draft. + // A question answer is not composer text, so it never syncs the draft. It + // reaches this send directly (not through the image hook's locked path), so + // it takes the per-terminal write lock itself: an answer landing mid-flight + // in an image paste sequence would interleave bytes into the PTY. const answerQuestion = useCallback( - async (text: string): Promise => - (await sendMessage(text, undefined, false)) !== 'rejected', - [sendMessage] + async (text: string): Promise => { + const terminal = handleRef.current + if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) { + onSendError('Answer not sent') + return false + } + try { + return (await sendMessage(text, undefined, false)) !== 'rejected' + } finally { + if (terminal) { + releaseMobileNativeChatTerminalWrite(terminal) + } + } + }, + [handleRef, onSendError, sendMessage] ) return { send, sendWithOutcome, answerQuestion } diff --git a/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts new file mode 100644 index 00000000000..632fa0b2282 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-streaming-bubble.ts @@ -0,0 +1,31 @@ +import { useState } from 'react' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + createMobileNativeChatStreamingGate, + deriveMobileNativeChatStreaming +} from './mobile-native-chat-streaming-gate' + +/** Live streaming-bubble text for the chat list. The gate remembers which + * transcript tail predates the current stream segment, so a reply that repeats + * the previous turn's prefix still shows while streaming. Call this from a + * component that outlives the chat list itself: the baseline has to survive the + * view toggles that unmount it, or the next segment reverts to prefix-matching. + * `streamLive` keeps those textless gaps from reading as an idle stream. */ +export function useMobileNativeChatStreamingBubble( + folded: readonly NativeChatMessage[], + streamingText: string | undefined, + scopeKey: string, + streamLive: boolean +): string | null { + const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey)) + const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, { + scopeKey, + streamLive + }) + if (step.gate !== gate) { + // Render-time state adjustment (derived-state pattern): the advance is + // idempotent for a repeated (text, tail) pair, so this settles in one pass. + setGate(step.gate) + } + return step.streaming +} diff --git a/mobile/src/stats/home-stats-total.test.ts b/mobile/src/stats/home-stats-total.test.ts new file mode 100644 index 00000000000..035f6c7476f --- /dev/null +++ b/mobile/src/stats/home-stats-total.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { totalHomeStats, type HomeStatsSummary } from './home-stats-total' + +function stats(overrides: Partial = {}): HomeStatsSummary { + return { + totalAgentsSpawned: 10, + totalPRsCreated: 2, + totalAgentTimeMs: 60_000, + firstEventAt: 1_700_000_000_000, + ...overrides + } +} + +describe('totalHomeStats', () => { + it('has nothing to show before any host answers', () => { + expect(totalHomeStats({}, ['host-1'])).toBeNull() + }) + + it('passes a single host through unchanged', () => { + expect(totalHomeStats({ 'host-1': stats() }, ['host-1'])).toEqual(stats()) + }) + + it('sums every desktop instead of letting the last reply win', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + }, + ['host-1', 'host-2'] + ) + + expect(total).toMatchObject({ + totalAgentsSpawned: 15, + totalPRsCreated: 3, + totalAgentTimeMs: 90_000 + }) + }) + + it('drops a removed desktop from the total', () => { + // Replies are cached for the life of the process, so the entry outlives the pairing. + const byHost = { + 'host-1': stats(), + 'host-2': stats({ totalAgentsSpawned: 5, totalPRsCreated: 1, totalAgentTimeMs: 30_000 }) + } + + expect(totalHomeStats(byHost, ['host-1'])).toEqual(stats()) + expect(totalHomeStats(byHost, [])).toBeNull() + }) + + it('keeps the earliest known first event', () => { + const total = totalHomeStats( + { + 'host-1': stats({ firstEventAt: 2_000 }), + 'host-2': stats({ firstEventAt: null }), + 'host-3': stats({ firstEventAt: 1_000 }) + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total?.firstEventAt).toBe(1_000) + }) + + it('reports no first event when no host has one', () => { + expect( + totalHomeStats({ 'host-1': stats({ firstEventAt: null }) }, ['host-1'])?.firstEventAt + ).toBeNull() + }) + + it('ignores a malformed reply instead of poisoning the header', () => { + const total = totalHomeStats( + { + 'host-1': stats(), + 'host-2': null as unknown as HomeStatsSummary, + 'host-3': { totalAgentsSpawned: 'lots' } as unknown as HomeStatsSummary + }, + ['host-1', 'host-2', 'host-3'] + ) + + expect(total).toEqual(stats()) + }) +}) diff --git a/mobile/src/stats/home-stats-total.ts b/mobile/src/stats/home-stats-total.ts new file mode 100644 index 00000000000..72264872561 --- /dev/null +++ b/mobile/src/stats/home-stats-total.ts @@ -0,0 +1,51 @@ +export type HomeStatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +/** + * Why: the home header shows one lifetime-usage row for every paired desktop. Each host answers + * stats.summary for itself, so a single shared slot made the row flip to whichever host replied + * last — visible churn now that every reconnect re-reads. Sum instead; one host still totals itself. + * + * Summing only `hostIds` keeps an unpaired desktop out of the total: replies are cached per host + * for the life of the process, so an entry outlives the host it describes. + */ +export function totalHomeStats( + byHost: Record, + hostIds: readonly string[] +): HomeStatsSummary | null { + const hosts = hostIds.filter((id) => id in byHost).map((id) => byHost[id]) + if (hosts.length === 0) { + return null + } + const total: HomeStatsSummary = { + totalAgentsSpawned: 0, + totalPRsCreated: 0, + totalAgentTimeMs: 0, + firstEventAt: null + } + for (const host of hosts) { + // The rows come straight off the wire unvalidated; a malformed desktop reply must not + // NaN out or crash the header for every other host. + if (!host || typeof host !== 'object') { + continue + } + total.totalAgentsSpawned += finiteOrZero(host.totalAgentsSpawned) + total.totalPRsCreated += finiteOrZero(host.totalPRsCreated) + total.totalAgentTimeMs += finiteOrZero(host.totalAgentTimeMs) + if (typeof host.firstEventAt === 'number' && Number.isFinite(host.firstEventAt)) { + total.firstEventAt = + total.firstEventAt === null + ? host.firstEventAt + : Math.min(total.firstEventAt, host.firstEventAt) + } + } + return total +} + +function finiteOrZero(value: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0 +} diff --git a/mobile/src/transport/host-connect-refetch-gate.test.ts b/mobile/src/transport/host-connect-refetch-gate.test.ts new file mode 100644 index 00000000000..17b3d68ae24 --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from './host-connect-refetch-gate' +import type { ConnectionState } from './types' + +function refetchesFor(states: ConnectionState[]): number { + const gate = createHostConnectRefetchGate() + return states.filter((state) => gate.observe(state)).length +} + +describe('createHostConnectRefetchGate', () => { + it('fetches once on the first connect', () => { + expect(refetchesFor(['connecting', 'handshaking', 'connected'])).toBe(1) + }) + + it('fetches again after a dropped socket comes back', () => { + expect( + refetchesFor(['connected', 'reconnecting', 'connecting', 'handshaking', 'connected']) + ).toBe(2) + }) + + it('does not storm while the connection holds', () => { + expect(refetchesFor(['connected', 'connected', 'connected'])).toBe(1) + }) + + it('never fetches while the host stays down', () => { + expect(refetchesFor(['connecting', 'disconnected', 'reconnecting', 'auth-failed'])).toBe(0) + }) +}) diff --git a/mobile/src/transport/host-connect-refetch-gate.ts b/mobile/src/transport/host-connect-refetch-gate.ts new file mode 100644 index 00000000000..4ce2ba885fc --- /dev/null +++ b/mobile/src/transport/host-connect-refetch-gate.ts @@ -0,0 +1,18 @@ +import type { ConnectionState } from './types' + +// Why: a logical client survives socket drops, so screens that fetch once per client latched +// after the first connect and never refreshed after a background/handoff reconnect. Gate the +// refetch on the transition INTO 'connected' — one read per reconnect, no polling timer. +export function createHostConnectRefetchGate(): { + observe: (state: ConnectionState) => boolean +} { + let connected = false + return { + observe(state) { + const nowConnected = state === 'connected' + const crossedIntoConnected = nowConnected && !connected + connected = nowConnected + return crossedIntoConnected + } + } +} diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index 66ca63c5efa..2e3e8a9f5b2 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -1,29 +1,95 @@ import { describe, expect, it, vi } from 'vitest' -import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation' +import { + mobileHostEditHostRoute, + mobileHostEditRoute, + navigateToMobileHostEdit, + type MobileHostEditNavigationState +} from './host-edit-navigation' -describe('mobileHostEditRoute', () => { - it('keeps the dynamic host segment explicit for a cold host navigator', () => { - expect(mobileHostEditRoute('host-1')).toEqual({ - pathname: '/h/[hostId]/edit', - params: { hostId: 'host-1' } - }) - }) +function navigationHarness(initialState: MobileHostEditNavigationState) { + let stateListener = () => {} + let state = initialState + const unsubscribeState = vi.fn() + const navigation = { + addListener: vi.fn((_event: 'state', listener: () => void) => { + stateListener = listener + return unsubscribeState + }), + getState: () => state + } + return { + navigation, + setState(nextState: MobileHostEditNavigationState) { + state = nextState + stateListener() + }, + unsubscribeState + } +} - it('mounts a cold host navigator before replacing its index with edit', () => { - let nextFrame: FrameRequestCallback | null = null +describe('mobile host edit navigation', () => { + it('waits for the expected host route to commit before replacing it with Edit', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() const replace = vi.fn() - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - nextFrame = callback - return 1 - }) - navigateToMobileHostEdit({ push, replace }, 'host-1') - expect(push).toHaveBeenCalledWith('/h/host-1') + navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1') + + expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(replace).not.toHaveBeenCalled() - nextFrame?.(0) - expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1')) - vi.unstubAllGlobals() + harness.setState({ + index: 1, + routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] + }) + + expect(harness.unsubscribeState).toHaveBeenCalledOnce() + expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1')) + }) + + it('does not replace an unrelated host route', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) + + expect(replace).not.toHaveBeenCalled() + }) + + it('cancels a pending replacement when navigation leaves the host flow', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) + + harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) + controller.cancel() + harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + + expect(harness.unsubscribeState).toHaveBeenCalledOnce() + expect(replace).not.toHaveBeenCalled() + }) + + it('unsubscribes when mounting the host throws synchronously', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const error = new Error('navigation failed') + + expect(() => + navigateToMobileHostEdit( + harness.navigation, + { + push: () => { + throw error + }, + replace: vi.fn() + }, + 'host-1' + ) + ).toThrow(error) + expect(harness.unsubscribeState).toHaveBeenCalledOnce() }) }) diff --git a/mobile/src/transport/host-edit-navigation.ts b/mobile/src/transport/host-edit-navigation.ts index 19dbf703dec..765e36d7b5a 100644 --- a/mobile/src/transport/host-edit-navigation.ts +++ b/mobile/src/transport/host-edit-navigation.ts @@ -1,8 +1,31 @@ -type HostEditRouter = { +export type MobileHostEditNavigationState = Readonly<{ + index: number + routes: readonly MobileHostEditNavigationRoute[] +}> + +export type MobileHostEditNavigationRoute = Readonly<{ + name: string + params?: Readonly<{ hostId?: unknown }> +}> + +export type MobileHostEditRootNavigation = { + addListener: (event: 'state', listener: () => void) => () => void + getState: () => MobileHostEditNavigationState +} + +export type MobileHostEditRouter = { push: (href: `/h/${string}`) => void replace: (href: ReturnType) => void } +export type MobileHostEditNavigationController = Readonly<{ + cancel: () => void +}> + +export function mobileHostEditHostRoute(hostId: string): `/h/${string}` { + return `/h/${encodeURIComponent(hostId)}` +} + export function mobileHostEditRoute(hostId: string) { return { pathname: '/h/[hostId]/edit' as const, @@ -10,10 +33,49 @@ export function mobileHostEditRoute(hostId: string) { } } -export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void { - // Why: a cold nested host navigator resolves a deep push to its index route. - router.push(`/h/${hostId}`) - requestAnimationFrame(() => { +export function navigateToMobileHostEdit( + navigation: MobileHostEditRootNavigation, + router: MobileHostEditRouter, + hostId: string +): MobileHostEditNavigationController { + let active = true + let hostRouteSeen = false + let unsubscribeState = () => {} + const dispose = () => { + if (!active) { + return + } + active = false + unsubscribeState() + } + + // Why: cold Expo deep links resolve to index; target Edit after the host route commits. + const onState = () => { + if (!active) { + return + } + const state = navigation.getState() + const currentRoute = state.routes[state.index] + if (currentRoute?.name !== 'h') { + if (hostRouteSeen) { + dispose() + } + return + } + hostRouteSeen = true + if (currentRoute.params?.hostId !== hostId) { + return + } + dispose() router.replace(mobileHostEditRoute(hostId)) - }) + } + + try { + unsubscribeState = navigation.addListener('state', onState) + router.push(mobileHostEditHostRoute(hostId)) + } catch (error) { + dispose() + throw error + } + return { cancel: dispose } } diff --git a/mobile/src/transport/mobile-direct-return-probe.ts b/mobile/src/transport/mobile-direct-return-probe.ts new file mode 100644 index 00000000000..dfb0572aa38 --- /dev/null +++ b/mobile/src/transport/mobile-direct-return-probe.ts @@ -0,0 +1,83 @@ +import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' +import type { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' +import type { RpcClient } from './rpc-client' +import type { HostProfile } from './types' +import type { MobileConnectionPath } from './stable-logical-rpc-client' + +const DIRECT_PROBE_INTERVAL_MS = 15_000 + +// While the runtime channel rides the relay, periodically probe the direct +// endpoint and migrate back once hysteresis proves it stable. +export class DirectReturnProbe { + private timer: ReturnType | null = null + + constructor( + private readonly deps: { + now: () => number + setTimer: typeof setTimeout + clearTimer: typeof clearTimeout + openDirect: (endpoint: string) => RpcClient + }, + private readonly hooks: { + hysteresis: MobileEndpointHysteresis + host: () => HostProfile + canSchedule: () => boolean + canAttempt: () => boolean + beginOperation: () => void + migrate: (client: RpcClient, path: MobileConnectionPath) => Promise + onDirectMigrated: () => Promise + afterProbe: () => void + } + ) {} + + schedule(delayMs = DIRECT_PROBE_INTERVAL_MS): void { + if (!this.hooks.canSchedule() || this.timer) { + return + } + this.timer = this.deps.setTimer(() => { + this.timer = null + void this.probe() + }, delayMs) + } + + clear(): void { + if (this.timer) { + this.deps.clearTimer(this.timer) + this.timer = null + } + } + + private async probe(): Promise { + if (!this.hooks.canAttempt() || !this.hooks.hysteresis.canProbe(this.deps.now())) { + this.schedule() + return + } + this.hooks.beginOperation() + let successful: Awaited> = null + try { + successful = await openAuthenticatedDirectEndpoint( + this.hooks.host(), + this.deps.openDirect, + 12_000 + ) + if (!successful) { + this.hooks.hysteresis.recordDirectFailure(this.deps.now()) + return + } + if (!this.hooks.hysteresis.recordDirectSuccess(this.deps.now())) { + successful.client.close() + return + } + await this.hooks.migrate(successful.client, successful.path) + successful = null + this.hooks.hysteresis.recordMigration(this.deps.now()) + await this.hooks.onDirectMigrated() + } finally { + successful?.client.close() + // Why: a relay drop or backoff timer can arrive while the probe owns the + // operation mutex; afterProbe releases it and replays deferred recovery. + this.hooks.afterProbe() + this.schedule() + } + } +} diff --git a/mobile/src/transport/mobile-endpoint-lifecycle.ts b/mobile/src/transport/mobile-endpoint-lifecycle.ts index 57881022a56..d41898152c3 100644 --- a/mobile/src/transport/mobile-endpoint-lifecycle.ts +++ b/mobile/src/transport/mobile-endpoint-lifecycle.ts @@ -90,6 +90,7 @@ function createSupervisor( readBundle: readMobileRelayCredentialBundle, writeBundle: writeMobileRelayCredentialBundle, saveHost, + onLog, now: Date.now, randomBytes: ExpoCrypto.getRandomBytes, setTimer: setTimeout, diff --git a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts index d26e0706f70..0098de6e079 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor-contract.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor-contract.ts @@ -3,7 +3,7 @@ import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bund import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import type { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' import type { RpcClient } from './rpc-client' -import type { HostProfile } from './types' +import type { ConnectionLogSink, HostProfile } from './types' export type MobileEndpointSupervisorDependencies = { openDirect: (endpoint: string) => RpcClient @@ -20,4 +20,5 @@ export type MobileEndpointSupervisorDependencies = { randomBytes: (length: number) => Uint8Array setTimer: typeof setTimeout clearTimer: typeof clearTimeout + onLog?: ConnectionLogSink } diff --git a/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts new file mode 100644 index 00000000000..d483c6536e8 --- /dev/null +++ b/mobile/src/transport/mobile-endpoint-supervisor-test-fakes.ts @@ -0,0 +1,169 @@ +import { vi } from 'vitest' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor' +import type { RpcClient } from './rpc-client' +import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +export class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (_method: string, _params?: unknown): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +export class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null, + private readonly resumeExpiry = Date.now() + 30 * 24 * 3_600_000, + private readonly renewed = true + ) { + super(state) + } + // Why: production-realistic defaults — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => this.resumeExpiry + getResumeConfirmation = () => ({ + v: 1 as const, + reqId: 'confirm-1', + currentVersion: 2, + acceptedAs: this.renewed ? ('current' as const) : ('grace' as const), + renewed: this.renewed, + resumeExpiresAt: this.resumeExpiry + }) + getFailure = () => this.failure +} + +export class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + private generation = 1 + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.generation += 1 + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path + getGeneration = () => this.generation +} + +export const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} +export const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, + { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } + ], + relayHostId: relay.relayHostId, + relay +} +export const bundle: MobileRelayCredentialBundle = { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: 'A'.repeat(43), + hash: 'B'.repeat(43), + version: 2, + expiresAt: Number.MAX_SAFE_INTEGER + } +} + +export function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundle), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length) => new Uint8Array(length).fill(1), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +export function mockCredentialRotation(logical: FakeLogicalClient): void { + let installResult: Record | null = null + logical.sendRequest.mockImplementation(async (method, params) => { + const request = params as { installReqId?: string; reqId?: string } + if (method === 'pairing.provisionRelay') { + installResult = { + v: 1, + reqId: request.reqId, + authorizationMode: 'authenticated-direct', + currentVersion: 3, + resumeExpiresAt: Date.now() + 300_000, + graceExpiresAt: Date.now() + 60_000 + } + return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } + } + return { + id: 'rpc-1', + ok: true, + result: { + v: 1, + relay, + installStatus: installResult + ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } + : { v: 1, reqId: request.installReqId, state: 'not-found' } + }, + _meta: { runtimeId: 'runtime-1' } + } + }) +} diff --git a/mobile/src/transport/mobile-endpoint-supervisor.test.ts b/mobile/src/transport/mobile-endpoint-supervisor.test.ts index 3ec05f5ef1e..d1b8b6918cd 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.test.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.test.ts @@ -2,177 +2,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' import { hashMobileRelayCredential } from './mobile-relay-credential-hash' import { RelayOuterError } from './mobile-relay-e2ee-link' -import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { - MobileEndpointSupervisor, - type MobileEndpointSupervisorDependencies -} from './mobile-endpoint-supervisor' -import type { RpcClient } from './rpc-client' -import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client' -import type { ConnectionState, HostProfile, RpcResponse } from './types' + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + FakeSession, + host, + mockCredentialRotation, + relay +} from './mobile-endpoint-supervisor-test-fakes' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) -class FakeSession implements RpcClient { - readonly sendRequest = vi.fn( - async (_method: string, _params?: unknown): Promise => ({ - id: 'rpc-1', - ok: true, - result: {}, - _meta: { runtimeId: 'runtime-1' } - }) - ) - readonly subscribe = vi.fn(() => () => {}) - readonly updateTerminalSubscriptionViewport = vi.fn() - readonly notifyForeground = vi.fn() - readonly close = vi.fn() - private readonly listeners = new Set<(state: ConnectionState) => void>() - - constructor(private state: ConnectionState) {} - - getState = () => this.state - getReconnectAttempt = () => 0 - getLastConnectedAt = () => null - onStateChange = (listener: (state: ConnectionState) => void) => { - this.listeners.add(listener) - return () => this.listeners.delete(listener) - } - - publishState(state: ConnectionState): void { - this.state = state - for (const listener of this.listeners) { - listener(state) - } - } -} - -class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { - constructor( - state: ConnectionState, - private readonly failure: Error | null = null, - private readonly lease = Date.now() + 120_000 - ) { - super(state) - } - getLeaseExpiresAt = () => this.lease - getResumeConfirmation = () => ({ - v: 1 as const, - reqId: 'confirm-1', - currentVersion: 2, - acceptedAs: 'current' as const, - renewed: true, - resumeExpiresAt: Date.now() + 300_000 - }) - getFailure = () => this.failure -} - -class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { - private path: MobileConnectionPath - private generation = 1 - - constructor(state: ConnectionState, path: MobileConnectionPath) { - super(state) - this.path = path - } - - migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { - if (session.getState() !== 'connected') { - session.close() - throw new Error(`replacement session ${session.getState()}`) - } - this.path = path - this.generation += 1 - this.publishState('connected') - }) - suspendActiveSession = vi.fn(() => this.publishState('disconnected')) - getActivePath = () => this.path - getGeneration = () => this.generation -} - -const relay = { - v: 1 as const, - directorUrl: 'https://relay.onorca.dev', - cellUrl: 'https://relay-c1.onorca.dev', - assignmentEpoch: 7, - relayHostId: 'AbCdEf0123_-xyZ9', - e2eeFraming: 2 as const -} -const host: HostProfile = { - id: 'host-1', - name: 'Blue Whale', - endpoint: 'ws://192.168.1.10:6768', - deviceToken: 'device-token', - publicKeyB64: 'A'.repeat(44), - lastConnected: 1, - endpoints: [ - { id: 'direct-primary', kind: 'lan', url: 'ws://192.168.1.10:6768' }, - { id: 'relay-primary', kind: 'relay', url: 'wss://relay-c1.onorca.dev/v1/connect/id' } - ], - relayHostId: relay.relayHostId, - relay -} -const bundle: MobileRelayCredentialBundle = { - v: 1, - hostId: host.id, - deviceToken: host.deviceToken, - current: { - token: 'A'.repeat(43), - hash: 'B'.repeat(43), - version: 2, - expiresAt: Number.MAX_SAFE_INTEGER - } -} - -function dependencies( - overrides: Partial = {} -): MobileEndpointSupervisorDependencies { - return { - openDirect: vi.fn(() => new FakeSession('connected')), - openRelay: vi.fn(() => new FakeRelaySession('connected')), - resolveRelay: vi.fn(async ({ relay }) => relay), - readBundle: vi.fn(async () => bundle), - writeBundle: vi.fn(async () => {}), - saveHost: vi.fn(async () => {}), - now: Date.now, - randomBytes: (length) => new Uint8Array(length).fill(1), - setTimer: setTimeout, - clearTimer: clearTimeout, - ...overrides - } -} - -function mockCredentialRotation(logical: FakeLogicalClient): void { - let installResult: Record | null = null - logical.sendRequest.mockImplementation(async (method, params) => { - const request = params as { installReqId?: string; reqId?: string } - if (method === 'pairing.provisionRelay') { - installResult = { - v: 1, - reqId: request.reqId, - authorizationMode: 'authenticated-direct', - currentVersion: 3, - resumeExpiresAt: Date.now() + 300_000, - graceExpiresAt: Date.now() + 60_000 - } - return { id: 'rpc-2', ok: true, result: installResult, _meta: { runtimeId: 'runtime-1' } } - } - return { - id: 'rpc-1', - ok: true, - result: { - v: 1, - relay, - installStatus: installResult - ? { v: 1, reqId: request.installReqId, state: 'committed', result: installResult } - : { v: 1, reqId: request.installReqId, state: 'not-found' } - }, - _meta: { runtimeId: 'runtime-1' } - } - }) -} - describe('mobile endpoint supervisor', () => { beforeEach(() => { vi.useFakeTimers() @@ -716,6 +561,7 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) @@ -744,12 +590,13 @@ describe('mobile endpoint supervisor', () => { .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4408))) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), resolveRelay: vi.fn(() => resolvePending) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) await vi.waitFor(() => expect(deps.resolveRelay).toHaveBeenCalledOnce()) supervisor.setForeground(false) finishResolve?.(relay) @@ -785,11 +632,14 @@ describe('mobile endpoint supervisor', () => { .fn() .mockReturnValueOnce(new FakeRelaySession('connected', null, Date.now() + 31_000)) .mockImplementation(() => new FakeRelaySession('disconnected', new RelayOuterError(4404))) - const deps = dependencies({ openRelay }) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) await vi.advanceTimersByTimeAsync(5000) @@ -797,6 +647,27 @@ describe('mobile endpoint supervisor', () => { supervisor.stop() }) + it('skips forced rotation for a session resumed without renewal', async () => { + // Why: renewed=false means a re-resume provably returns the same unchanged + // deadline; rotating anyway churned one session replacement per clamp floor. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn( + () => new FakeRelaySession('connected', null, Date.now() + 31_000, false) + ) + const deps = dependencies({ + openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + expect(openRelay).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledTimes(1) + supervisor.stop() + }) + it('keeps a fatal lease-replacement gate after the active relay later drops', async () => { const logical = new FakeLogicalClient('disconnected', 'lan') const openRelay = vi @@ -808,12 +679,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) // The old relay can outlive its rejected lease replacement, then close separately. @@ -833,12 +705,13 @@ describe('mobile endpoint supervisor', () => { .mockImplementation(() => new FakeRelaySession('connected')) const deps = dependencies({ openRelay, + openDirect: vi.fn(() => new FakeSession('disconnected')), randomBytes: () => new Uint8Array([128, 0]) }) const supervisor = new MobileEndpointSupervisor(logical, host, deps) await supervisor.start() - await vi.advanceTimersByTimeAsync(1000) + await vi.advanceTimersByTimeAsync(60_000) expect(openRelay).toHaveBeenCalledTimes(2) supervisor.setForeground(true) diff --git a/mobile/src/transport/mobile-endpoint-supervisor.ts b/mobile/src/transport/mobile-endpoint-supervisor.ts index c8b3cdbcfb1..be295fdcdda 100644 --- a/mobile/src/transport/mobile-endpoint-supervisor.ts +++ b/mobile/src/transport/mobile-endpoint-supervisor.ts @@ -1,5 +1,5 @@ -import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe' import type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' +import { DirectReturnProbe } from './mobile-direct-return-probe' import { RelayReconnectController } from './mobile-relay-reconnect-controller' import { RelayLeaseRotationTimer } from './mobile-relay-lease-rotation-timer' import { MobileEndpointHysteresis } from './mobile-endpoint-hysteresis' @@ -9,6 +9,8 @@ import { persistRelayHost, toError } from './mobile-endpoint-supervisor-support' +import { selectDialableRelayCredentials } from './mobile-relay-credential-selection' +import { createRelayRecoveryLog, type RelayRecoveryLog } from './mobile-relay-recovery-log' import { applyResumeConfirmation, mobileRelayCredentialNeedsRotation, @@ -20,7 +22,6 @@ import type { HostProfile } from './types' export type { MobileEndpointSupervisorDependencies } from './mobile-endpoint-supervisor-contract' -const DIRECT_PROBE_INTERVAL_MS = 15_000 const DIRECT_OBSERVATION_MS = 30_000 const MINIMUM_DWELL_MS = 60_000 const FAILURE_COOLDOWN_MS = 60_000 @@ -32,11 +33,12 @@ export class MobileEndpointSupervisor { private operationInFlight = false private credentialRotationInFlight = false private relayRotationPending = false - private probeTimer: ReturnType | null = null private unsubscribeState: (() => void) | null = null private readonly hysteresis: MobileEndpointHysteresis private readonly relayReconnect: RelayReconnectController private readonly leaseRotation: RelayLeaseRotationTimer + private readonly logRelay: RelayRecoveryLog + private readonly directProbe: DirectReturnProbe constructor( private readonly logical: StableLogicalRpcClient, @@ -49,24 +51,52 @@ export class MobileEndpointSupervisor { failureCooldownMs: FAILURE_COOLDOWN_MS, minimumDwellMs: MINIMUM_DWELL_MS }) + this.logRelay = createRelayRecoveryLog(dependencies.now, dependencies.onLog) this.relayReconnect = new RelayReconnectController(dependencies, this.recoverRelay.bind(this)) this.leaseRotation = new RelayLeaseRotationTimer(dependencies, () => { this.relayRotationPending = true void this.recoverRelay(true) }) + this.directProbe = new DirectReturnProbe(dependencies, { + hysteresis: this.hysteresis, + host: () => this.host, + canSchedule: () => + !this.stopped && this.foreground && this.logical.getActivePath() === 'relay', + canAttempt: () => !this.stopped && this.foreground && !this.operationInFlight, + beginOperation: () => { + this.operationInFlight = true + }, + migrate: (client, path) => this.logical.migrateTo(client, path), + onDirectMigrated: async () => { + this.leaseRotation.clear() + this.relayRotationPending = false + await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) + }, + afterProbe: () => { + this.operationInFlight = false + if (this.relayRotationPending || this.logical.getState() !== 'connected') { + void this.recoverRelay(this.relayRotationPending) + } + } + }) } async start(): Promise { this.bundle = await this.dependencies.readBundle(this.host.id).catch(() => null) - if (this.stopped || !this.bundle || !this.host.relay) { + if (this.stopped || !this.host.relay) { return } + if (!this.bundle) { + // Why: a Keychain race at open must not kill relay recovery for the whole + // process lifetime; each recovery attempt re-reads the durable bundle. + this.logRelay('credential bundle unavailable at start; recovery will re-read') + } this.unsubscribeState = this.logical.onStateChange((state) => { if (state === 'connected') { if (this.logical.getActivePath() !== 'relay') { void this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) } - this.scheduleDirectProbe() + this.directProbe.schedule() } else { // Why: the direct client enters reconnecting after its first failed // dial and may never publish disconnected while its retry loop lives. @@ -78,7 +108,7 @@ export class MobileEndpointSupervisor { // are still loading, before the supervisor subscribes to state changes. await this.recoverRelay() } else { - this.scheduleDirectProbe() + this.directProbe.schedule() } } @@ -87,11 +117,11 @@ export class MobileEndpointSupervisor { this.foreground = foreground if (foreground) { this.relayReconnect.handleForeground(this.logical, wasForeground) - this.scheduleDirectProbe(0) + this.directProbe.schedule(0) } else { // Why: background phones must not hold billed relay data splices. this.relayReconnect.suspendActiveRelay(this.logical) - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -101,7 +131,7 @@ export class MobileEndpointSupervisor { this.stopped = true this.unsubscribeState?.() this.unsubscribeState = null - this.clearDirectProbeTimer() + this.directProbe.clear() this.relayReconnect.clear() this.leaseRotation.clear() } @@ -112,7 +142,6 @@ export class MobileEndpointSupervisor { this.stopped || !this.foreground || this.operationInFlight || - !this.bundle || !this.host.relay || (!forceReplacement && !this.relayReconnect.needsRecovery(this.logical.getState())) ) { @@ -121,23 +150,42 @@ export class MobileEndpointSupervisor { // Why: revival and lease timers can overlap resume failures; one shared cooldown // prevents PEER_DROPPED/LIMIT_EXCEEDED reconnect churn. if (this.relayReconnect.shouldDefer()) { + this.logRelay('recovery deferred by cooldown or gate') return } this.operationInFlight = true let lastError: Error | null = null let retryAfterOperation = false try { - const credentials = this.relayReconnect.eligibleCredentials( - this.bundle.current, - this.bundle.grace - ) - for (const credential of credentials) { + const selection = await selectDialableRelayCredentials({ + bundle: this.bundle, + controller: this.relayReconnect, + readBundle: () => this.dependencies.readBundle(this.host.id), + onAdoptedFresherBundle: () => this.logRelay('adopted fresher durable credential bundle') + }) + this.bundle = selection.bundle + if (selection.credentials.length === 0) { + // Why: "expired" vs "missing" separates a sleep-past-expiry phone + // (needs re-pair or LAN) from a Keychain failure in field reports. + this.logRelay( + selection.bundle + ? 'relay credential expired or rejected; slow reprobe armed' + : 'no relay credential bundle; slow reprobe armed' + ) + this.relayReconnect.armCredentialReprobe() + return + } + for (const credential of selection.credentials) { const result = await this.tryRelayCredential(credential) if (result.ok) { retryAfterOperation = this.logical.getState() !== 'connected' return } lastError = result.error + this.logRelay( + 'relay dial failed', + `${result.error.name}: ${String(result.error.message).slice(0, 80)}` + ) if (this.relayReconnect.shouldTryGraceAfterRelayFailure(result.error)) { // Why: a rejected version stays invalid; retry only the grace credential. this.relayReconnect.recordRejectedCredential(credential.version) @@ -145,12 +193,10 @@ export class MobileEndpointSupervisor { break } } - if (credentials.length > 0) { - // Why: cleanup may happen while a relay dial is awaiting the network; - // record its outcome without recreating a foreground retry timer. - const scheduleRetry = !forceReplacement && this.foreground && !this.stopped - this.relayReconnect.registerFailure(lastError, scheduleRetry) - } + // Why: cleanup may happen while a relay dial is awaiting the network; + // record its outcome without recreating a foreground retry timer. + const scheduleRetry = !forceReplacement && this.foreground && !this.stopped + this.relayReconnect.registerFailure(lastError, scheduleRetry) } finally { this.operationInFlight = false if (forceReplacement && this.relayRotationPending && !this.stopped && this.foreground) { @@ -207,6 +253,7 @@ export class MobileEndpointSupervisor { } this.relayRotationPending = false this.hysteresis.recordMigration(this.dependencies.now()) + this.logRelay('runtime channel migrated to relay') const confirmation = session.getResumeConfirmation() if (confirmation) { this.bundle = applyResumeConfirmation(this.bundle, credential.version, confirmation) @@ -215,71 +262,24 @@ export class MobileEndpointSupervisor { await this.dependencies.writeBundle(this.bundle).catch(() => {}) } // Why: async persistence can finish after stop/background; never recreate a stale timer. + // Why: rotate against the resume credential's expiry, never the hello's + // leaseExpiresAt — that field is the cell's ~10s attach-reservation + // deadline, and using it forced a session replacement every second. + // Why: renewed=false means a re-resume provably returns the same + // unchanged deadline — rotating then just churns one replacement per + // clamp floor until a fresh credential arrives over direct or disk. this.leaseRotation.scheduleFromLease( - this.stopped || !this.foreground ? null : session.getLeaseExpiresAt() + this.stopped || !this.foreground || confirmation?.renewed === false + ? null + : (confirmation?.resumeExpiresAt ?? session.getResumeExpiresAt()) ) - this.scheduleDirectProbe() + this.directProbe.schedule() return { ok: true } } catch (error) { return { ok: false, error: session.getFailure() ?? toError(error) } } } - private scheduleDirectProbe(delayMs = DIRECT_PROBE_INTERVAL_MS): void { - if ( - this.stopped || - !this.foreground || - this.logical.getActivePath() !== 'relay' || - this.probeTimer - ) { - return - } - this.probeTimer = this.dependencies.setTimer(() => { - this.probeTimer = null - void this.probeDirect() - }, delayMs) - } - - private async probeDirect(): Promise { - if ( - this.stopped || - !this.foreground || - this.operationInFlight || - !this.hysteresis.canProbe(this.dependencies.now()) - ) { - this.scheduleDirectProbe() - return - } - this.operationInFlight = true - let successful: Awaited> = null - try { - const openDirect = this.dependencies.openDirect - successful = await openAuthenticatedDirectEndpoint(this.host, openDirect, 12_000) - if (!successful) { - this.hysteresis.recordDirectFailure(this.dependencies.now()) - return - } - if (!this.hysteresis.recordDirectSuccess(this.dependencies.now())) { - successful.client.close() - return - } - await this.logical.migrateTo(successful.client, successful.path) - successful = null - this.hysteresis.recordMigration(this.dependencies.now()) - this.leaseRotation.clear() - this.relayRotationPending = false - await this.rotateCredentialIfNeeded(this.relayReconnect.resetForDirectConnection()) - } finally { - successful?.client.close() - this.operationInFlight = false - // Why: a relay drop or backoff timer can arrive while the direct probe owns the mutex. - if (this.relayRotationPending || this.logical.getState() !== 'connected') { - void this.recoverRelay(this.relayRotationPending) - } - this.scheduleDirectProbe() - } - } - private async rotateCredentialIfNeeded(force = false): Promise { if ( this.stopped || @@ -321,11 +321,4 @@ export class MobileEndpointSupervisor { } } } - - private clearDirectProbeTimer(): void { - if (this.probeTimer) { - this.dependencies.clearTimer(this.probeTimer) - this.probeTimer = null - } - } } diff --git a/mobile/src/transport/mobile-relay-credential-selection.ts b/mobile/src/transport/mobile-relay-credential-selection.ts new file mode 100644 index 00000000000..dcaca2080dd --- /dev/null +++ b/mobile/src/transport/mobile-relay-credential-selection.ts @@ -0,0 +1,42 @@ +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { RelayReconnectController } from './mobile-relay-reconnect-controller' + +// Why: pairing recovery, re-pairing, or a raced rotation can land a fresh +// durable bundle after the supervisor snapshotted its copy; a gated retry must +// see it instead of dialing (or refusing to dial) on stale state forever. +// +// Adoption is decided by OUTCOME, not by version: renewals extend expiresAt +// without bumping current.version, and a re-pair restarts the version counter, +// so version comparison cannot tell fresh from stale. The durable bundle is +// adopted exactly when it yields a dialable (unexpired, non-rejected) +// credential while the in-memory one does not — which also means a revoked +// version can never be resurrected from a stale disk copy. +export async function selectDialableRelayCredentials(args: { + bundle: MobileRelayCredentialBundle | null + controller: RelayReconnectController + readBundle: () => Promise + onAdoptedFresherBundle: () => void +}): Promise<{ + bundle: MobileRelayCredentialBundle | null + credentials: MobileRelayCredentialBundle['current'][] +}> { + const memory = args.bundle + const memoryCredentials = memory + ? args.controller.eligibleCredentials(memory.current, memory.grace) + : [] + if (memoryCredentials.length > 0) { + // Why: in-memory can be newer than disk (a resume confirmation whose + // durable write failed); never let a stale disk copy shadow it. + return { bundle: memory, credentials: memoryCredentials } + } + const disk = await args.readBundle().catch(() => null) + if (disk) { + const diskCredentials = args.controller.eligibleCredentials(disk.current, disk.grace) + if (diskCredentials.length > 0) { + args.controller.acceptFreshCredential(disk.current.version) + args.onAdoptedFresherBundle() + return { bundle: disk, credentials: diskCredentials } + } + } + return { bundle: memory ?? disk, credentials: [] } +} diff --git a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts index bf2a3dd4d1c..19aedfe8be1 100644 --- a/mobile/src/transport/mobile-relay-lease-rotation-timer.ts +++ b/mobile/src/transport/mobile-relay-lease-rotation-timer.ts @@ -2,6 +2,13 @@ // little before the deadline (and retry shortly if a forced rotation didn't land) // so the session never lapses. Owns the single lease/rotation timer slot. const LEASE_ROTATION_MARGIN_MS = 30_000 +// Why: both ends must be clamped. The floor bounds any bad deadline to one +// forced rotation per minute instead of a sub-second loop; the ceiling keeps +// the delay far below setTimeout's 32-bit limit (a 30-day resume TTL minus the +// margin overflows int32 and fires at 1ms), at the cost of a harmless +// re-resume every few hours on long-lived sessions. +const LEASE_ROTATION_MIN_DELAY_MS = 60_000 +const LEASE_ROTATION_MAX_DELAY_MS = 6 * 60 * 60 * 1000 export type RelayLeaseRotationDependencies = { now: () => number @@ -23,9 +30,12 @@ export class RelayLeaseRotationTimer { if (!leaseExpiresAt) { return } - const delay = Math.max( - 1000, - leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + const delay = Math.min( + LEASE_ROTATION_MAX_DELAY_MS, + Math.max( + LEASE_ROTATION_MIN_DELAY_MS, + leaseExpiresAt - this.dependencies.now() - LEASE_ROTATION_MARGIN_MS + ) ) this.arm(delay) } diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts index d630f0db68b..4f50878c6d5 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.test.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.test.ts @@ -6,7 +6,9 @@ import { RelayReconnectController } from './mobile-relay-reconnect-controller' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) -vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) describe('relay reconnect controller', () => { beforeEach(() => { @@ -47,16 +49,146 @@ describe('relay reconnect controller', () => { expect(onRetry).not.toHaveBeenCalled() }) - it('waits for an external signal after rejected E2EE authentication', () => { + it('reprobes slowly after rejected E2EE authentication instead of parking forever', () => { + // Why: on a relay-only phone a permanent gate is a permanent outage — the + // desktop can commit pairing credentials moments after the first rejection. const onRetry = vi.fn() const reconnect = createController(onRetry) reconnect.registerFailure(new MobileE2EEAuthenticationError()) expect(reconnect.shouldDefer()).toBe(true) - expect(vi.getTimerCount()).toBe(0) - vi.advanceTimersByTime(60_000) expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(59_000) + expect(onRetry).not.toHaveBeenCalled() + vi.advanceTimersByTime(1_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // The reprobe tick passes the gate exactly once, then defers again. + expect(reconnect.shouldDefer()).toBe(false) + expect(reconnect.shouldDefer()).toBe(true) + }) + + it('keeps the fresh-credential gate reprobing after each failed gated attempt', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new RelayOuterError(4401)) + expect(reconnect.shouldDefer()).toBe(true) + + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt fails again with only rejected credentials on hand. + reconnect.registerFailure(new RelayOuterError(4401)) + + // The gated cadence escalates: the second reprobe waits twice as long. + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('resets the gated cadence when the app returns to the foreground', () => { + // Why: reopening the app is the strongest "conditions changed" signal a + // phone produces — it must not wait out an escalated 15-minute tick even + // when it cannot lift the credential gate itself. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new RelayOuterError(4401)) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + // A failed gated attempt escalates the next tick beyond the base cadence. + reconnect.registerFailure(new RelayOuterError(4401)) + + reconnect.clear() + reconnect.handleForeground(logical, false) + expect(onRetry).toHaveBeenCalledTimes(2) + + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(3) + }) + + it('mints the gate pass when arming a no-bundle reprobe under a held gate', () => { + // Why: an external-signal gate never records rejected versions, so the + // no-dialable-bundle path must still mint the tick's pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + expect(reconnect.shouldDefer()).toBe(true) + vi.advanceTimersByTime(60_000) + expect(onRetry).toHaveBeenCalledTimes(1) + expect(reconnect.shouldDefer()).toBe(false) + // The gated attempt finds no dialable credential at all and re-arms. + reconnect.armCredentialReprobe() + + vi.advanceTimersByTime(120_000) + expect(onRetry).toHaveBeenCalledTimes(2) + expect(reconnect.shouldDefer()).toBe(false) + }) + + it('does not arm a gate reprobe timer when the supervisor declined retries', () => { + // Why: scheduleRetry=false means background or stopped — a tick firing + // there wakes nothing useful, and the next foreground resume re-arms. + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError(), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4401), false) + expect(vi.getTimerCount()).toBe(0) + reconnect.registerFailure(new RelayOuterError(4429), false) + expect(vi.getTimerCount()).toBe(0) + vi.runAllTimers() + expect(onRetry).not.toHaveBeenCalled() + + // Resume restores the gated cadence instead of stalling forever. + reconnect.handleForeground(logical, false) + expect(reconnect.shouldDefer()).toBe(true) + expect(vi.getTimerCount()).toBe(1) + }) + + it('does not let an orphaned reprobe timer swallow the next fast backoff', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + const logical = { getState: () => 'disconnected' } as never + + reconnect.registerFailure(new MobileE2EEAuthenticationError()) + // A foreground revival nudge clears the external-signal gate and its timer. + reconnect.handleForeground(logical, true) + expect(onRetry).toHaveBeenCalledTimes(1) + + // A plain transport failure must schedule its own fast retry, not wait + // out a leftover 60s reprobe timer. + reconnect.registerFailure(new RelayOuterError(4429)) + vi.advanceTimersByTime(600) + expect(onRetry).toHaveBeenCalledTimes(2) + }) + + it('lifts the fresh-credential gate when a durable bundle carries a new version', () => { + const onRetry = vi.fn() + const reconnect = createController(onRetry) + + reconnect.recordRejectedCredential(2) + reconnect.armCredentialReprobe() + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(2) + expect(reconnect.shouldDefer()).toBe(true) + + reconnect.acceptFreshCredential(3) + expect(reconnect.shouldDefer()).toBe(false) + expect( + reconnect.eligibleCredentials( + { token: 'fresh', version: 3, expiresAt: Number.MAX_SAFE_INTEGER }, + null + ) + ).toHaveLength(1) }) it('upgrades host-revival gating to fresh credentials without later downgrading it', () => { diff --git a/mobile/src/transport/mobile-relay-reconnect-controller.ts b/mobile/src/transport/mobile-relay-reconnect-controller.ts index 6f586c17499..9ec5c1398bc 100644 --- a/mobile/src/transport/mobile-relay-reconnect-controller.ts +++ b/mobile/src/transport/mobile-relay-reconnect-controller.ts @@ -6,18 +6,10 @@ import { import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { RelayOuterError } from './mobile-relay-e2ee-link' +import { RELAY_STABLE_CONNECTION_MS, RelayRetryDelays } from './mobile-relay-retry-delays' import type { StableLogicalRpcClient } from './stable-logical-rpc-client' import type { ConnectionState } from './types' -// Why: relay resume closes and silent cellular NAT rebinds otherwise cause -// immediate re-dials that ping-pong the phone between connected and disconnected. -const RELAY_BACKOFF_MIN_MS = 250 -const RELAY_BACKOFF_BASE_MS = 500 -const RELAY_BACKOFF_CEILING_MS = 30_000 -const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS -const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 -const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 - export type RelayReconnectDependencies = { now: () => number randomBytes: (length: number) => Uint8Array @@ -34,21 +26,29 @@ export class RelayReconnectController { private timer: ReturnType | null = null private activeSession: MobileRelayRpcSession | null = null private recoveryGate: RecoveryGate | null = null + private gateReprobePending = false + private gateReprobeStreak = 0 private readonly rejectedCredentialVersions = new Set() + private readonly delays: RelayRetryDelays constructor( private readonly dependencies: RelayReconnectDependencies, private readonly onRetry: (forceReplacement?: boolean) => void - ) {} + ) { + this.delays = new RelayRetryDelays(dependencies.randomBytes) + } handleForeground(logical: StableLogicalRpcClient, wasForeground: boolean): void { if (!wasForeground) { - // Why: an app resume is a fresh signal, unlike repeated network-flap nudges. + // Why: an app resume is a fresh signal, unlike repeated network-flap + // nudges — it resets the gated cadence even when it cannot lift the + // credential gate, so reopening the app never waits out a 15min tick. + this.gateReprobeStreak = 0 if (this.recoveryGate !== 'fresh-credential') { this.reset() } } else if (this.recoveryGate === 'external-signal') { - this.recoveryGate = null + this.liftGate() } if ( wasForeground && @@ -87,8 +87,7 @@ export class RelayReconnectController { this.activeSession = session this.activeRelayConnectedAt = this.dependencies.now() this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } resetForDirectConnection(): boolean { @@ -97,10 +96,14 @@ export class RelayReconnectController { this.activeSession = null this.activeRelayConnectedAt = null if (needsCredentialRefresh) { - // Why: the rejected credential stays unusable until its replacement is durable. + // Why: the rejected credential stays unusable until its replacement is + // durable. No reprobe timer here — direct is live, rotation over it + // clears the gate, and any later state failure re-arms via shouldDefer. this.consecutiveFailures = 0 this.nextAttemptAt = 0 this.recoveryGate = 'fresh-credential' + this.gateReprobePending = false + this.gateReprobeStreak = 0 this.clearTimer() } else { this.reset() @@ -128,10 +131,43 @@ export class RelayReconnectController { if (eligible.length === 0 && this.rejectedCredentialVersions.size > 0) { this.recoveryGate = 'fresh-credential' this.clearTimer() + this.scheduleGateReprobe() } return eligible } + // For callers that found no dialable credential at all: keep a slow retry + // alive so a later durable write can recover. + armCredentialReprobe(): void { + if (this.rejectedCredentialVersions.size > 0) { + this.recoveryGate = 'fresh-credential' + this.clearTimer() + this.scheduleGateReprobe() + return + } + if (this.recoveryGate) { + // Why: under a held gate the tick must mint its pass token — a plain + // cooldown tick bounces off shouldDefer and doubles the effective cadence. + this.clearTimer() + this.scheduleGateReprobe() + return + } + // Why: a merely missing or expired bundle must not enter the credential + // gate — that gate forces a rotation on the next direct connect. A plain + // cooldown retries the read on the same escalating cadence. + const delay = this.delays.gateReprobeDelayMs(this.gateReprobeStreak) + this.nextAttemptAt = this.dependencies.now() + delay + this.clearTimer() + this.scheduleReprobeTick(delay, false) + } + + // A durable bundle whose current version is not rejected reopens the gate. + acceptFreshCredential(version: number): void { + if (this.recoveryGate === 'fresh-credential' && !this.rejectedCredentialVersions.has(version)) { + this.liftGate() + } + } + recordRejectedCredential(version: number): void { this.rejectedCredentialVersions.add(version) } @@ -154,6 +190,12 @@ export class RelayReconnectController { // re-dial. Arms the self-scheduled retry so recovery still happens on its own. shouldDefer(): boolean { if (this.recoveryGate) { + if (this.gateReprobePending) { + // Why: the slow reprobe tick gets exactly one attempt through the gate. + this.gateReprobePending = false + return false + } + this.scheduleGateReprobe() return true } if (this.dependencies.now() < this.nextAttemptAt) { @@ -173,8 +215,12 @@ export class RelayReconnectController { this.recoveryGate === 'fresh-credential' || (this.recoveryGate === 'external-signal' && recovery?.kind !== 'disable-relay-credential') ) { - // Why: only the gate's external signal can make a known-fatal recovery retryable. + // Why: a failed reprobe stays gated, but the slow cadence must keep going + // — unless the supervisor is backgrounded/stopped; resume re-arms it. this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } const now = this.dependencies.now() @@ -189,18 +235,29 @@ export class RelayReconnectController { this.activeRelayConnectedAt = null this.consecutiveFailures += 1 const delay = - recovery?.kind === 'retry-after-host-offline' ? this.hostOfflineDelayMs() : this.delayMs() + recovery?.kind === 'retry-after-host-offline' + ? this.delays.hostOfflineDelayMs() + : this.delays.transportDelayMs(this.consecutiveFailures) this.nextAttemptAt = now + delay if (error instanceof MobileE2EEAuthenticationError) { - // Why: pairing state cannot change on a timer; polling only wakes the radio. + // Why: an E2EE rejection is usually pairing revocation, but it also fires + // transiently right after pairing while the desktop commits credentials — + // reprobe slowly instead of waiting forever for a UI nudge. this.recoveryGate = 'external-signal' this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'disable-relay-credential') { - // Why: a rejected outer credential cannot recover until direct connectivity refreshes it. + // Why: never redial a rejected credential fast, but keep a slow reprobe + // alive — the caller re-reads durable state before each gated attempt. this.recoveryGate = 'fresh-credential' this.clearTimer() + if (scheduleRetry) { + this.scheduleGateReprobe() + } return } if (recovery?.kind === 'retry-after-host-offline') { @@ -235,12 +292,12 @@ export class RelayReconnectController { this.consecutiveFailures = 0 this.activeRelayConnectedAt = null this.nextAttemptAt = 0 - this.recoveryGate = null - this.clearTimer() + this.liftGate() } clear(): void { this.clearTimer() + this.gateReprobePending = false this.activeSession = null this.activeRelayConnectedAt = null } @@ -263,20 +320,34 @@ export class RelayReconnectController { }, delay) } - private delayMs(): number { - const exponent = Math.max(0, this.consecutiveFailures - 1) - const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) - // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. - return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + private scheduleGateReprobe(): void { + this.scheduleReprobeTick(this.delays.gateReprobeDelayMs(this.gateReprobeStreak), true) } - private hostOfflineDelayMs(): number { - const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS - return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + private scheduleReprobeTick(delay: number, mintToken: boolean): void { + if (this.timer) { + return + } + this.timer = this.dependencies.setTimer(() => { + this.timer = null + // Why: the streak advances once per fired tick — arm attempts within one + // cycle recompute the same delay instead of triple-escalating it. + this.gateReprobeStreak = Math.min(this.gateReprobeStreak + 1, 8) + // Why: the tick token is only minted while its gate still holds; a later + // gate must not spend a stale token and bypass its own cooldown. + if (mintToken && this.recoveryGate) { + this.gateReprobePending = true + } + this.onRetry() + }, delay) } - private jitterFraction(): number { - const [high, low] = this.dependencies.randomBytes(2) - return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + // Why: clearing a gate must also drop its timer, pending tick, and cadence — + // an orphaned reprobe timer would otherwise swallow the next fast backoff. + private liftGate(): void { + this.recoveryGate = null + this.gateReprobePending = false + this.gateReprobeStreak = 0 + this.clearTimer() } } diff --git a/mobile/src/transport/mobile-relay-recovery-log.ts b/mobile/src/transport/mobile-relay-recovery-log.ts new file mode 100644 index 00000000000..9b373cd112a --- /dev/null +++ b/mobile/src/transport/mobile-relay-recovery-log.ts @@ -0,0 +1,22 @@ +import type { ConnectionLogSink } from './types' + +export type RelayRecoveryLog = (message: string, detail?: string) => void + +// Why: relay recovery failed silently in production for weeks; every decision +// must reach logcat and the in-app connection log. +export function createRelayRecoveryLog( + now: () => number, + onLog?: ConnectionLogSink +): RelayRecoveryLog { + let sequence = 0 + return (message, detail) => { + console.log(`[relay] ${message}`, detail ?? '') + onLog?.({ + id: `relay-${++sequence}`, + ts: now(), + level: 'info', + message: `Relay: ${message}`, + ...(detail ? { detail } : {}) + }) + } +} diff --git a/mobile/src/transport/mobile-relay-retry-delays.ts b/mobile/src/transport/mobile-relay-retry-delays.ts new file mode 100644 index 00000000000..6ff145acdb4 --- /dev/null +++ b/mobile/src/transport/mobile-relay-retry-delays.ts @@ -0,0 +1,45 @@ +// Why: relay resume closes and silent cellular NAT rebinds otherwise cause +// immediate re-dials that ping-pong the phone between connected and disconnected. +const RELAY_BACKOFF_MIN_MS = 250 +const RELAY_BACKOFF_BASE_MS = 500 +const RELAY_BACKOFF_CEILING_MS = 30_000 +export const RELAY_STABLE_CONNECTION_MS = RELAY_BACKOFF_CEILING_MS +const RELAY_HOST_OFFLINE_RETRY_MIN_MS = 5_000 +const RELAY_HOST_OFFLINE_RETRY_MAX_MS = 15_000 +// Why: gates must slow recovery down, never end it — a relay-only phone has no +// direct path to refresh credentials, so a timerless gate is a permanent outage. +// The cadence escalates and jitters so a permanently revoked device is not a +// forever one-minute beacon and a fleet-wide gating event cannot phase-align. +const RELAY_GATE_REPROBE_BASE_MS = 60_000 +const RELAY_GATE_REPROBE_CEILING_MS = 15 * 60_000 + +// Every relay retry delay in one place: transport backoff, known-offline +// polling, and the escalating gated reprobe cadence, all jittered. +export class RelayRetryDelays { + constructor(private readonly randomBytes: (length: number) => Uint8Array) {} + + transportDelayMs(consecutiveFailures: number): number { + const exponent = Math.max(0, consecutiveFailures - 1) + const cap = Math.min(RELAY_BACKOFF_CEILING_MS, RELAY_BACKOFF_BASE_MS * 2 ** exponent) + // Full jitter (uniform in [0, cap)), floored so retries never busy-loop. + return Math.max(RELAY_BACKOFF_MIN_MS, Math.floor(cap * this.jitterFraction())) + } + + hostOfflineDelayMs(): number { + const range = RELAY_HOST_OFFLINE_RETRY_MAX_MS - RELAY_HOST_OFFLINE_RETRY_MIN_MS + return RELAY_HOST_OFFLINE_RETRY_MIN_MS + Math.floor(range * this.jitterFraction()) + } + + gateReprobeDelayMs(streak: number): number { + const base = Math.min( + RELAY_GATE_REPROBE_CEILING_MS, + RELAY_GATE_REPROBE_BASE_MS * 2 ** Math.min(streak, 8) + ) + return Math.floor(base * (0.75 + 0.5 * this.jitterFraction())) + } + + private jitterFraction(): number { + const [high, low] = this.randomBytes(2) + return (((high ?? 0) << 8) | (low ?? 0)) / 0x1_00_00 + } +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.test.ts b/mobile/src/transport/mobile-relay-rpc-session.test.ts index 54ae8387411..f5fa49a785e 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.test.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.test.ts @@ -119,7 +119,7 @@ describe('mobile relay RPC session', () => { }) expect(confirmationRequest.params).not.toHaveProperty('relayDeviceId') expect(confirmationRequest.params).not.toHaveProperty('acceptedCredentialVersion') - expect(session.getLeaseExpiresAt()).toEqual(expect.any(Number)) + expect(session.getAttachDeadlineAt()).toEqual(expect.any(Number)) }) it('rejects a mismatched outer credential version and closes the physical link', () => { diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a614e833387..ac065012203 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -19,7 +19,10 @@ type PendingRequest = { } export type MobileRelayRpcSession = RpcClient & { - getLeaseExpiresAt(): number | null + // The cell's attach-reservation deadline (~10s). Diagnostics only — never + // schedule anything from it; rotation keys off getResumeExpiresAt(). + getAttachDeadlineAt(): number | null + getResumeExpiresAt(): number | null getResumeConfirmation(): DeviceResumeConfirmed | null getFailure(): Error | null } @@ -40,7 +43,8 @@ export function connectMobileRelayRpcSession(args: { let state: ConnectionState = 'connecting' let requestCounter = 0 let lastConnectedAt: number | null = null - let leaseExpiresAt: number | null = null + let attachDeadlineAt: number | null = null + let resumeExpiresAt: number | null = null let resumeConfirmation: DeviceResumeConfirmed | null = null let failure: Error | null = null let closed = false @@ -65,7 +69,8 @@ export function connectMobileRelayRpcSession(args: { fail(new Error('relay resume credential version mismatch')) return } - leaseExpiresAt = hello.leaseExpiresAt + attachDeadlineAt = hello.leaseExpiresAt + resumeExpiresAt = hello.resumeExpiresAt publishState('handshaking') }, onAuthenticated: () => void confirmResume(), @@ -109,7 +114,8 @@ export function connectMobileRelayRpcSession(args: { streams.clear() publishState('disconnected') }, - getLeaseExpiresAt: () => leaseExpiresAt, + getAttachDeadlineAt: () => attachDeadlineAt, + getResumeExpiresAt: () => resumeExpiresAt, getResumeConfirmation: () => resumeConfirmation, getFailure: () => failure } @@ -131,6 +137,7 @@ export function connectMobileRelayRpcSession(args: { throw new Error('relay resume confirmation missing') } resumeConfirmation = result.resumeConfirmation + resumeExpiresAt = result.resumeConfirmation.resumeExpiresAt lastConnectedAt = Date.now() publishState('connected') } catch (error) { diff --git a/mobile/src/transport/mobile-relay-runtime-failover.test.ts b/mobile/src/transport/mobile-relay-runtime-failover.test.ts new file mode 100644 index 00000000000..dcb8ba194e2 --- /dev/null +++ b/mobile/src/transport/mobile-relay-runtime-failover.test.ts @@ -0,0 +1,454 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect, type RpcClient } from './rpc-client' +import { + createStableLogicalRpcClient, + type MobileConnectionPath, + type StableLogicalRpcClient +} from './stable-logical-rpc-client' +import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import type { MobileRelayCredentialBundle } from './mobile-relay-credential-bundle' +import type { MobileRelayRpcSession } from './mobile-relay-rpc-session' +import { + MobileEndpointSupervisor, + type MobileEndpointSupervisorDependencies +} from './mobile-endpoint-supervisor' +import type { ConnectionState, HostProfile, RpcResponse } from './types' + +// Regression suite for the 2026-08 field failure: a phone paired over the +// relay whose direct LAN endpoint is unreachable (Tailscale off) dialed the +// LAN endpoint forever and never recovered a relay runtime session. + +vi.mock('react-native', () => ({ Platform: { OS: 'android' } })) +vi.mock('expo-secure-store', () => ({ + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' +})) +vi.mock('expo-crypto', () => ({ + getRandomBytes: (length: number) => new Uint8Array(length) +})) + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => plaintext, + decrypt: (raw: string) => raw, + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class FakeSession implements RpcClient { + readonly sendRequest = vi.fn( + async (): Promise => ({ + id: 'rpc-1', + ok: true, + result: {}, + _meta: { runtimeId: 'runtime-1' } + }) + ) + readonly subscribe = vi.fn(() => () => {}) + readonly updateTerminalSubscriptionViewport = vi.fn() + readonly notifyForeground = vi.fn() + readonly close = vi.fn() + private readonly listeners = new Set<(state: ConnectionState) => void>() + + constructor(private state: ConnectionState) {} + + getState = () => this.state + getReconnectAttempt = () => 0 + getLastConnectedAt = () => null + onStateChange = (listener: (state: ConnectionState) => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + publishState(state: ConnectionState): void { + this.state = state + for (const listener of this.listeners) { + listener(state) + } + } +} + +class FakeRelaySession extends FakeSession implements MobileRelayRpcSession { + constructor( + state: ConnectionState, + private readonly failure: Error | null = null + ) { + super(state) + } + // Why: production-realistic constants — fictional fake values hid three + // live defects in this subsystem (latch, churn, int32 timer overflow). + getAttachDeadlineAt = () => Date.now() + 10_000 + getResumeExpiresAt = () => Date.now() + 30 * 24 * 3_600_000 + getResumeConfirmation = () => null + getFailure = () => this.failure +} + +class FakeLogicalClient extends FakeSession implements StableLogicalRpcClient { + private path: MobileConnectionPath + + constructor(state: ConnectionState, path: MobileConnectionPath) { + super(state) + this.path = path + } + + migrateTo = vi.fn(async (session: RpcClient, path: MobileConnectionPath) => { + if (session.getState() !== 'connected') { + session.close() + throw new Error(`replacement session ${session.getState()}`) + } + this.path = path + this.publishState('connected') + }) + suspendActiveSession = vi.fn(() => this.publishState('disconnected')) + getActivePath = () => this.path +} + +const relay = { + v: 1 as const, + directorUrl: 'https://relay.onorca.dev', + cellUrl: 'https://relay-c1.onorca.dev', + assignmentEpoch: 7, + relayHostId: 'AbCdEf0123_-xyZ9', + e2eeFraming: 2 as const +} + +const DIRECT_ENDPOINT = 'ws://100.88.90.25:6768' + +const host: HostProfile = { + id: 'host-1', + name: 'Blue Whale', + endpoint: DIRECT_ENDPOINT, + deviceToken: 'device-token', + publicKeyB64: 'A'.repeat(44), + lastConnected: 1, + endpoints: [ + { id: 'direct-primary', kind: 'lan', url: DIRECT_ENDPOINT }, + { + id: 'relay-primary', + kind: 'relay', + url: 'wss://relay-c1.onorca.dev/v1/connect/id' + } + ], + relayHostId: relay.relayHostId, + relay +} + +function bundleWith(version: number, expiresAt: number): MobileRelayCredentialBundle { + return { + v: 1, + hostId: host.id, + deviceToken: host.deviceToken, + current: { + token: `token-v${version}`.padEnd(43, 'A'), + hash: 'B'.repeat(43), + version, + expiresAt + } + } +} + +function dependencies( + overrides: Partial = {} +): MobileEndpointSupervisorDependencies { + return { + openDirect: vi.fn(() => new FakeSession('connected')), + openRelay: vi.fn(() => new FakeRelaySession('connected')), + resolveRelay: vi.fn(async ({ relay }) => relay), + readBundle: vi.fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)), + writeBundle: vi.fn(async () => {}), + saveHost: vi.fn(async () => {}), + now: Date.now, + randomBytes: (length: number) => new Uint8Array(length), + setTimer: setTimeout, + clearTimer: clearTimeout, + ...overrides + } +} + +describe('relay runtime recovery without direct connectivity', () => { + beforeEach(() => { + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('recovers from a rejected outer credential once a fresher bundle is durable', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(2, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, the fresh-credential gate latched here with no timer and no exit. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 3 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers when the credential bundle was unreadable at supervisor start', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(null) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + // Pre-fix, a null first read killed relay recovery for the process lifetime. + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers from an expired-at-start bundle after a fresh credential lands', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi.fn(async () => expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + readBundle.mockImplementation(async () => bundleWith(3, Number.MAX_SAFE_INTEGER)) + // Pre-fix, an expired bundle produced a silent no-op with nothing scheduled. + await vi.advanceTimersByTimeAsync(60_000) + + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('retries after an E2EE authentication rejection without a UI nudge', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + // Pre-fix, this state waited indefinitely for a foreground/navigation event. + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a durable renewal that extends expiry without bumping the version', async () => { + // Why: resume confirmations and pairing recovery renew expiresAt while + // keeping current.version — version comparison cannot see this freshness. + const logical = new FakeLogicalClient('disconnected', 'lan') + const expired = bundleWith(2, Date.now() - 1) + const readBundle = vi + .fn(async () => bundleWith(2, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(expired) + .mockResolvedValueOnce(expired) + const deps = dependencies({ readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(deps.openRelay).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(60_000) + expect(deps.openRelay).toHaveBeenCalledOnce() + expect(deps.openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 2 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('adopts a re-paired credential whose version counter restarted', async () => { + // Why: re-pairing overwrites the same keychain slot with a NEW credential + // record whose counter restarts at 1 — lower than the rejected version. + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce(new FakeRelaySession('disconnected', new RelayOuterError(4401))) + .mockImplementation(() => new FakeRelaySession('connected')) + const readBundle = vi + .fn(async () => bundleWith(1, Number.MAX_SAFE_INTEGER)) + .mockResolvedValueOnce(bundleWith(4, Number.MAX_SAFE_INTEGER)) + const deps = dependencies({ openRelay, readBundle }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(60_000) + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay).toHaveBeenLastCalledWith( + relay, + expect.objectContaining({ version: 1 }), + expect.any(String) + ) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('does not churn relay sessions off the cell attach-reservation deadline', async () => { + // Why: the relay-hello's leaseExpiresAt is a ~10s attach deadline. Keying + // rotation off it replaced the session every second, killing any RPC + // slower than the cycle (the field symptom: "Worktree list unavailable"). + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn(() => new FakeRelaySession('connected')) + // Direct stays unreachable, as in the field — return probes must not + // confuse the churn measurement by migrating back to direct. + const openDirect = vi.fn(() => new FakeSession('disconnected')) + const deps = dependencies({ openRelay, openDirect }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(openRelay).toHaveBeenCalledOnce() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) + + it('recovers immediately on a background/foreground cycle after an E2EE rejection', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi + .fn() + .mockReturnValueOnce( + new FakeRelaySession('disconnected', new MobileE2EEAuthenticationError()) + ) + .mockImplementation(() => new FakeRelaySession('connected')) + const deps = dependencies({ openRelay }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(0) + expect(openRelay).toHaveBeenCalledOnce() + + supervisor.setForeground(false) + supervisor.setForeground(true) + await vi.advanceTimersByTimeAsync(0) + + expect(openRelay).toHaveBeenCalledTimes(2) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) + +// The field failure's first symptom: a real direct rpc-client dialing an +// unroutable LAN endpoint (instant 1006) while relay credentials sit unused. +class DeadSocket { + static constructed: string[] = [] + onopen: (() => void) | null = null + onclose: ((event: { code?: number; reason?: string }) => void) | null = null + onerror: ((event: unknown) => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + readyState = 0 + + constructor(readonly url: string) { + DeadSocket.constructed.push(url) + setTimeout(() => { + this.readyState = 3 + this.onclose?.({ code: 1006, reason: '' }) + }, 50) + } + + send(): void {} + close(): void { + this.readyState = 3 + } +} + +describe('failover with a real direct rpc-client', () => { + beforeEach(() => { + DeadSocket.constructed = [] + vi.stubGlobal('WebSocket', DeadSocket) + vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-02T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('migrates the logical client to relay after the direct dial fails', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies() + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) + + it('reaches relay even when the bundle read settles after the first direct failure', async () => { + const logical = createStableLogicalRpcClient( + connect(DIRECT_ENDPOINT, host.deviceToken, host.publicKeyB64), + 'tailscale' as MobileConnectionPath + ) + const deps = dependencies({ + readBundle: vi.fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)) + return bundleWith(2, Number.MAX_SAFE_INTEGER) + }) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + const started = supervisor.start() + await vi.advanceTimersByTimeAsync(300) + await started + await vi.advanceTimersByTimeAsync(3_000) + + expect(deps.openRelay).toHaveBeenCalled() + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + logical.close() + }) +}) diff --git a/mobile/src/transport/use-open-mobile-host-edit.ts b/mobile/src/transport/use-open-mobile-host-edit.ts new file mode 100644 index 00000000000..68c20c5ae3b --- /dev/null +++ b/mobile/src/transport/use-open-mobile-host-edit.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef } from 'react' +import { useNavigation, useRouter } from 'expo-router' +import { + navigateToMobileHostEdit, + type MobileHostEditNavigationController, + type MobileHostEditRootNavigation +} from './host-edit-navigation' + +export function useOpenMobileHostEdit(): (hostId: string) => void { + const navigation = useNavigation() + const router = useRouter() + const pendingRef = useRef(null) + + useEffect( + () => () => { + pendingRef.current?.cancel() + pendingRef.current = null + }, + [] + ) + + return useCallback( + (hostId) => { + pendingRef.current?.cancel() + pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId) + }, + [navigation, router] + ) +} diff --git a/mobile/src/worktree/home-host-worktree-fetch.test.ts b/mobile/src/worktree/home-host-worktree-fetch.test.ts new file mode 100644 index 00000000000..5799de96366 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, it } from 'vitest' +import { createHostConnectRefetchGate } from '../transport/host-connect-refetch-gate' +import type { RpcClient } from '../transport/rpc-client' +import { + createStableLogicalRpcClient, + LogicalClientCutoverError +} from '../transport/stable-logical-rpc-client' +import type { ConnectionState, RpcResponse } from '../transport/types' +import { fetchHomeHostWorktreeInfo } from './home-host-worktree-fetch' +import type { HostWorktreeInfo } from './home-worktree-info' + +type FakeSession = { + client: RpcClient + calls: number + settle: (response: RpcResponse | Error) => void +} + +function fakeSession(): FakeSession { + const pending: Array<(response: RpcResponse | Error) => void> = [] + const fake: FakeSession = { + calls: 0, + settle(response: RpcResponse | Error) { + const next = pending.shift() + next?.(response) + }, + client: { + sendRequest: () => { + fake.calls += 1 + return new Promise((resolve, reject) => { + pending.push((response) => + response instanceof Error ? reject(response) : resolve(response) + ) + }) + }, + subscribe: () => () => {}, + updateTerminalSubscriptionViewport: () => {}, + getState: (): ConnectionState => 'connected', + getReconnectAttempt: () => 0, + getLastConnectedAt: () => null, + onStateChange: () => () => {}, + notifyForeground: () => {}, + close: () => {} + } + } + return fake +} + +// Drains the promise chain so a retry queued in a .catch has reached the transport. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function catalogResponse(count: number, active: number): RpcResponse { + const worktrees = Array.from({ length: count }, (_, index) => ({ + worktreeId: `wt-${index}`, + repo: 'orca', + branch: `branch-${index}`, + displayName: `Workspace ${index}`, + liveTerminalCount: 0, + status: index < active ? ('working' as const) : ('done' as const) + })) + return { ok: true, result: { worktrees } } as RpcResponse +} + +function infoStore() { + let state: Record = {} + return { + get current() { + return state + }, + setInfo(updater: (prev: Record) => Record) { + state = updater(state) + } + } +} + +const notDisposed = () => false + +describe('fetchHomeHostWorktreeInfo', () => { + it('keeps the last proven counts when the in-flight read rejects', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await failed + + expect(store.current['host-1']).toEqual({ + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: expect.objectContaining({ worktreeId: 'wt-0' }), + catalogUnavailable: true, + staleCounts: true, + // Age-stamped so the card can stop calling day-old counts "last known". + countsProvenAt: expect.any(Number) + }) + }) + + it('stamps when the host proved the counts', async () => { + const store = infoStore() + const host = fakeSession() + const before = Date.now() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(3, 1)) + await loaded + + const provenAt = store.current['host-1'].countsProvenAt + expect(provenAt).toBeGreaterThanOrEqual(before) + expect(provenAt).toBeLessThanOrEqual(Date.now()) + }) + + it('marks a host whose catalog never loaded as unavailable, not empty', async () => { + const store = infoStore() + const host = fakeSession() + + const failed = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle({ ok: false, error: { code: 'internal' } } as RpcResponse) + await failed + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 0, + catalogUnavailable: true + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + }) + + it('re-reads the catalog on reconnect and clears the stale flag', async () => { + const store = infoStore() + const host = fakeSession() + const gate = createHostConnectRefetchGate() + + const connect = async (response: RpcResponse | Error) => { + if (!gate.observe('connected')) { + return + } + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(response) + await done + } + + await connect(catalogResponse(12, 2)) + // Socket dies mid-poll: counts survive, flagged stale. + const dropped = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(new Error('socket closed mid-request')) + await dropped + gate.observe('reconnecting') + expect(store.current['host-1'].staleCounts).toBe(true) + + await connect(catalogResponse(13, 3)) + + expect(store.current['host-1']).toMatchObject({ + totalWorktrees: 13, + activeCount: 3 + }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + // Two connects + the dropped poll: the gate must not re-read while the link holds. + expect(host.calls).toBe(3) + await connect(catalogResponse(13, 3)) + expect(host.calls).toBe(3) + }) + + it('re-reads through a relay→direct cutover, which never leaves connected', async () => { + const store = infoStore() + const relay = fakeSession() + const direct = fakeSession() + const logical = createStableLogicalRpcClient(relay.client, 'relay') + const gate = createHostConnectRefetchGate() + const gateFires: ConnectionState[] = [] + logical.onStateChange((state) => { + if (gate.observe(state)) { + gateFires.push(state) + } + }) + gate.observe(logical.getState()) + + const loaded = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + relay.settle(catalogResponse(12, 2)) + await loaded + + // The supervisor's direct probe migrates while a refresh is on the wire. + const interrupted = fetchHomeHostWorktreeInfo(logical, 'host-1', store.setInfo, notDisposed) + await logical.migrateTo(direct.client, 'lan') + await flush() + + expect(direct.calls).toBe(1) + direct.settle(catalogResponse(13, 3)) + await interrupted + + // The gate can't cover this: migrateTo republishes 'connected' from 'connected'. + expect(gateFires).toEqual([]) + expect(store.current['host-1']).toMatchObject({ totalWorktrees: 13, activeCount: 3 }) + expect(store.current['host-1'].staleCounts).toBeUndefined() + expect(store.current['host-1'].catalogUnavailable).toBeUndefined() + }) + + it('gives up on a host that keeps cutting over so the card still reports the failure', async () => { + const store = infoStore() + const host = fakeSession() + + const loaded = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, notDisposed) + host.settle(catalogResponse(12, 2)) + await loaded + + const cutoverStorm = fetchHomeHostWorktreeInfo( + host.client, + 'host-1', + store.setInfo, + notDisposed + ) + for (let attempt = 0; attempt < 6; attempt += 1) { + host.settle(new LogicalClientCutoverError()) + await flush() + } + await cutoverStorm + + // 1 original + CUTOVER_RETRY_LIMIT retries, then the failure is surfaced. + expect(host.calls).toBe(4) + expect(store.current['host-1'].staleCounts).toBe(true) + }) + + it('ignores a response that lands after the screen is disposed', async () => { + const store = infoStore() + const host = fakeSession() + + const done = fetchHomeHostWorktreeInfo(host.client, 'host-1', store.setInfo, () => true) + host.settle(new Error('socket closed mid-request')) + await done + + expect(store.current['host-1']).toBeUndefined() + }) +}) diff --git a/mobile/src/worktree/home-host-worktree-fetch.ts b/mobile/src/worktree/home-host-worktree-fetch.ts new file mode 100644 index 00000000000..7c6dafe6b14 --- /dev/null +++ b/mobile/src/worktree/home-host-worktree-fetch.ts @@ -0,0 +1,79 @@ +import { setCachedWorktrees } from '../cache/worktree-cache' +import { sendSingleFlightRequest } from '../transport/request-single-flight' +import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { + markHomeWorktreeCatalogUnavailable, + type HomeWorktreeSummary, + type HostWorktreeInfo +} from './home-worktree-info' +import { pickResumeWorktree } from './resume-worktree' +import { WORKTREE_PS_FULL_LIMIT } from './worktree-catalog-snapshot-client' + +const ACTIVE_STATUSES = new Set(['working', 'active', 'permission']) +// Why: a relay↔direct cutover rejects in-flight reads without ever leaving 'connected', so the +// connect gate never re-arms. Re-issue on the replacement session; cap it so a migration loop +// can't spin. See runtime-capability-probe.ts for the same hazard on status.get. +const CUTOVER_RETRY_LIMIT = 2 + +export type HostWorktreeInfoSetter = ( + updater: (prev: Record) => Record +) => void + +/** Reads one host's worktree catalog for the Home card, preserving proven counts on failure. */ +export function fetchHomeHostWorktreeInfo( + client: RpcClient, + hostId: string, + setInfo: HostWorktreeInfoSetter, + disposed: () => boolean +): Promise { + const markUnavailable = (): void => { + setInfo((prev) => { + const current = prev[hostId] + const next = markHomeWorktreeCatalogUnavailable(current, hostId) + return next === current ? prev : { ...prev, [hostId]: next } + }) + } + + const attempt = (cutoverRetriesLeft: number): Promise => + sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT }) + .then((response) => { + if (disposed()) { + return + } + if (!response.ok) { + markUnavailable() + return + } + const result = response.result as { worktrees?: HomeWorktreeSummary[] } + const worktrees = result.worktrees ?? [] + setCachedWorktrees(hostId, worktrees) + const active = worktrees.filter((w) => w.status && ACTIVE_STATUSES.has(w.status)) + // Mirror the desktop's focused workspace (see pickResumeWorktree). + const lastActive = pickResumeWorktree(worktrees) + setInfo((prev) => ({ + ...prev, + [hostId]: { + hostId, + totalWorktrees: worktrees.length, + activeCount: active.length, + lastActiveWorktree: lastActive, + countsProvenAt: Date.now() + } + })) + }) + .catch((error: unknown) => { + if (disposed()) { + return + } + // A cutover raises only after migrateTo installed an authenticated replacement, so the + // read was interrupted, not answered — ask again instead of latching "Last known". + if (cutoverRetriesLeft > 0 && isLogicalClientCutoverError(error)) { + return attempt(cutoverRetriesLeft - 1) + } + // Any other rejection (socket died mid-request) is a failed refresh, not an empty host. + markUnavailable() + }) + + return attempt(CUTOVER_RETRY_LIMIT) +} diff --git a/mobile/src/worktree/home-worktree-info.test.ts b/mobile/src/worktree/home-worktree-info.test.ts index ebbfe379e99..5cb80d007dd 100644 --- a/mobile/src/worktree/home-worktree-info.test.ts +++ b/mobile/src/worktree/home-worktree-info.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { markHomeWorktreeCatalogUnavailable, type HostWorktreeInfo } from './home-worktree-info' +import { + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS, + homeHostWorktreeSummary, + markHomeWorktreeCatalogUnavailable, + type HostWorktreeInfo +} from './home-worktree-info' describe('markHomeWorktreeCatalogUnavailable', () => { it('marks a host unavailable when no catalog has loaded', () => { @@ -28,7 +33,8 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toEqual({ ...current, - catalogUnavailable: true + catalogUnavailable: true, + staleCounts: true }) }) @@ -44,3 +50,73 @@ describe('markHomeWorktreeCatalogUnavailable', () => { expect(markHomeWorktreeCatalogUnavailable(current, 'host-1')).toBe(current) }) }) + +describe('homeHostWorktreeSummary', () => { + const provenAt = 1_700_000_000_000 + const loaded: HostWorktreeInfo = { + hostId: 'host-1', + totalWorktrees: 12, + activeCount: 2, + lastActiveWorktree: null, + countsProvenAt: provenAt + } + + it('summarizes a freshly loaded catalog', () => { + expect(homeHostWorktreeSummary(loaded, provenAt)).toBe('12 worktrees · 2 active') + expect( + homeHostWorktreeSummary({ ...loaded, totalWorktrees: 1, activeCount: 0 }, provenAt) + ).toBe('1 worktree') + }) + + it('keeps showing the last proven counts after a failed refresh', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + expect(homeHostWorktreeSummary(afterFailure, provenAt + 30_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('keeps the last proven counts however long ago the refresh failed', () => { + const afterFailure = markHomeWorktreeCatalogUnavailable(loaded, 'host-1') + + // A session that stays connected past the live window then hits one failed refresh is the + // headline case: the counts are history, not nothing. + expect( + homeHostWorktreeSummary(afterFailure, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(afterFailure, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('stops calling aged-out counts live even while a refresh is only pending', () => { + // A snapshot rehydrated from a previous app session describes a host we have not reached yet. + expect(homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS)).toBe( + '12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(loaded, provenAt + HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + 1) + ).toBe('Last known: 12 worktrees · 2 active') + expect(homeHostWorktreeSummary(loaded, provenAt + 3 * 86_400_000)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + }) + + it('treats counts persisted before age stamping as no longer live', () => { + const preUpgrade: HostWorktreeInfo = { ...loaded, countsProvenAt: undefined } + + expect(homeHostWorktreeSummary(preUpgrade, provenAt)).toBe( + 'Last known: 12 worktrees · 2 active' + ) + expect( + homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(preUpgrade, 'host-1'), provenAt) + ).toBe('Last known: 12 worktrees · 2 active') + }) + + it('reports unavailable only when no catalog ever loaded', () => { + expect(homeHostWorktreeSummary(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toBe( + 'Worktree list unavailable' + ) + expect(homeHostWorktreeSummary(undefined)).toBeNull() + }) +}) diff --git a/mobile/src/worktree/home-worktree-info.ts b/mobile/src/worktree/home-worktree-info.ts index 867e266e89e..a417636dcaf 100644 --- a/mobile/src/worktree/home-worktree-info.ts +++ b/mobile/src/worktree/home-worktree-info.ts @@ -15,8 +15,17 @@ export type HostWorktreeInfo = { activeCount: number lastActiveWorktree: HomeWorktreeSummary | null catalogUnavailable?: boolean + // The counts are the last proven ones, kept across a failed refresh. + staleCounts?: boolean + // When the host last answered with these counts (ms epoch); absent in snapshots an older + // build persisted, which is why an unstamped count counts as expired rather than fresh. + countsProvenAt?: number } +// Why: the home snapshot is persisted across launches, so counts could otherwise be rendered as +// live while describing a workspace set proven days ago. Past this window they are only history. +export const HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS = 10 * 60_000 + export function markHomeWorktreeCatalogUnavailable( current: HostWorktreeInfo | undefined, hostId: string @@ -25,7 +34,9 @@ export function markHomeWorktreeCatalogUnavailable( return current } if (current) { - return { ...current, catalogUnavailable: true } + // Why: `current` predates this failure, so its counts are proven host truth — a dropped + // socket must not erase them, only flag them as no longer live. + return { ...current, catalogUnavailable: true, staleCounts: true } } return { hostId, @@ -35,3 +46,32 @@ export function markHomeWorktreeCatalogUnavailable( catalogUnavailable: true } } + +/** The host card's worktree line, or null when nothing is known yet. */ +export function homeHostWorktreeSummary( + info: HostWorktreeInfo | undefined, + now: number = Date.now() +): string | null { + if (!info) { + return null + } + // Why (STA-3123): a catalog that never loaded must not assert a count the host has not proven. + if (info.catalogUnavailable && !info.staleCounts) { + return 'Worktree list unavailable' + } + const counts = `${info.totalWorktrees} worktree${info.totalWorktrees === 1 ? '' : 's'}${ + info.activeCount > 0 ? ` · ${info.activeCount} active` : '' + }` + // Age bounds liveness, not the counts themselves: a failed refresh — or a rehydrated snapshot the + // host has not re-confirmed — is still the last thing it told us, so keep it and drop the claim + // that it is current. + return info.staleCounts || !provenRecently(info, now) ? `Last known: ${counts}` : counts +} + +function provenRecently(info: HostWorktreeInfo, now: number): boolean { + // A snapshot written before countsProvenAt existed has an unknowable age; treat it as expired. + return ( + typeof info.countsProvenAt === 'number' && + now - info.countsProvenAt <= HOME_WORKTREE_COUNTS_LIVE_MAX_AGE_MS + ) +} diff --git a/mobile/src/worktree/host-worktree-refresh.test.ts b/mobile/src/worktree/host-worktree-refresh.test.ts index fb7e48a7a48..d0b8cf01dab 100644 --- a/mobile/src/worktree/host-worktree-refresh.test.ts +++ b/mobile/src/worktree/host-worktree-refresh.test.ts @@ -86,8 +86,8 @@ describe('startHostWorktreeRefresh', () => { // Mount force-fetch. expect(fetchRepoMetadata).toHaveBeenCalledTimes(1) - // Foregrounded: repo.list rides the interval as a convergence safety-net for desktop - // Settings edits that never emit a runtime reposChanged (the callee self-throttles). + // Foregrounded: repo.list rides the interval as a convergence safety-net for hosts on an + // older build, whose Settings edits emit no runtime reposChanged (the callee self-throttles). await vi.advanceTimersByTimeAsync(9_000) expect(fetchWorktrees).toHaveBeenCalledTimes(4) expect(fetchRepoMetadata).toHaveBeenCalledTimes(4) diff --git a/mobile/src/worktree/host-worktree-refresh.ts b/mobile/src/worktree/host-worktree-refresh.ts index 264b3c6e266..525fc335b8f 100644 --- a/mobile/src/worktree/host-worktree-refresh.ts +++ b/mobile/src/worktree/host-worktree-refresh.ts @@ -39,11 +39,11 @@ export function startHostWorktreeRefresh({ return } void fetchWorktrees() - // Why: desktop Settings repo edits (icon/color/name, repo removal) notify only the - // renderer IPC, not the runtime clientEvents stream, so `reposChanged` never reaches - // mobile. Keep a periodic repo.list as the convergence safety-net; fetchRepoMetadata - // self-throttles to REPO_METADATA_REFRESH_MS (60s), so this is ~1 request/min while - // foregrounded — the AppState gate is what removes the waste (both stop while backgrounded). + // Why: desktop Settings repo edits (icon/color/name, repo removal) now emit `reposChanged` + // (#11994), but a host on an older build does not; keep this periodic repo.list as the + // convergence safety-net. fetchRepoMetadata self-throttles to REPO_METADATA_REFRESH_MS + // (60s), so this is ~1 request/min while foregrounded — the AppState gate is what removes + // the waste (both stop while backgrounded). void fetchRepoMetadata() }, WORKTREE_REFRESH_MS) const unsubscribe = client.subscribe( diff --git a/mobile/src/worktree/workspace-list-sections.test.ts b/mobile/src/worktree/workspace-list-sections.test.ts index d6d04d43d3e..d1fc267c09f 100644 --- a/mobile/src/worktree/workspace-list-sections.test.ts +++ b/mobile/src/worktree/workspace-list-sections.test.ts @@ -106,6 +106,95 @@ describe('filterWorktrees', () => { ).toEqual([featureNamedMain]) }) + it('keeps a sleeping main worktree visible under hide-sleeping by default (#8873)', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + const feature = worktree({ worktreeId: 'feature', isMainWorktree: false }) + + expect( + filterWorktrees( + [main, feature], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([main]) + }) + + it('keeps a sleeping folder workspace visible under hide-sleeping', () => { + const folder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:workspace-1', + branch: '', + isMainWorktree: true + }) + + expect( + filterWorktrees( + [folder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([folder]) + }) + + it('re-hides the sleeping main worktree when the desktop setting is off', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { + filterRepoIds: new Set(), + hideSleeping: true, + hideDefaultBranch: false, + alwaysShowDefaultBranch: false + }, + '' + ) + ).toEqual([]) + }) + + it('falls back to the branch heuristic for hosts that omit isMainWorktree', () => { + const legacyMain = worktree({ worktreeId: 'legacy-main', branch: 'refs/heads/master' }) + + expect( + filterWorktrees( + [legacyMain], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyMain]) + }) + + it('keeps a sleeping folder workspace on hosts that omit isMainWorktree', () => { + // A folder workspace has no branch, so the legacy branch heuristic can never + // recognise it — without its own arm, #8873 still reproduces on old desktops. + const legacyFolder = worktree({ + workspaceKind: 'folder-workspace', + worktreeId: 'folder:legacy-1', + branch: '' + }) + + expect( + filterWorktrees( + [legacyFolder], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: false }, + '' + ) + ).toEqual([legacyFolder]) + }) + + it('lets hide-default-branch still win over the sleeping exemption', () => { + const main = worktree({ worktreeId: 'main', branch: 'main', isMainWorktree: true }) + + expect( + filterWorktrees( + [main], + { filterRepoIds: new Set(), hideSleeping: true, hideDefaultBranch: true }, + '' + ) + ).toEqual([]) + }) + it('keeps folder workspaces when default branch hiding is enabled', () => { const folder = worktree({ workspaceKind: 'folder-workspace', diff --git a/mobile/src/worktree/workspace-list-sections.ts b/mobile/src/worktree/workspace-list-sections.ts index bdb9e4783e3..d798a7fe5fa 100644 --- a/mobile/src/worktree/workspace-list-sections.ts +++ b/mobile/src/worktree/workspace-list-sections.ts @@ -65,6 +65,19 @@ function isDefaultBranchWorkspace(w: Worktree): boolean { return branch === 'main' || branch === 'master' } +/** + * Whether "Hide sleeping" must keep this row — the project's entry point (#8873). + * Falls back to the branch heuristic for hosts that predate isMainWorktree; a + * folder workspace is always its project's entry point, and isDefaultBranchWorkspace + * rejects it by design, so it needs its own legacy arm or #8873 still reproduces. + */ +function isSleepingSweepExempt(w: Worktree, alwaysShowDefaultBranch: boolean | undefined): boolean { + if (alwaysShowDefaultBranch === false) { + return false + } + return w.isMainWorktree ?? (w.workspaceKind === 'folder-workspace' || isDefaultBranchWorkspace(w)) +} + function orderMainWorktreeFirst(worktrees: Worktree[]): Worktree[] { const mainWorktrees = worktrees.filter((worktree) => worktree.isMainWorktree) if (mainWorktrees.length === 0) { @@ -80,7 +93,9 @@ export function filterWorktrees( ): Worktree[] { let result = worktrees.filter((w) => !w.isArchived) if (filters.hideSleeping) { - result = result.filter(isWorktreeActive) + result = result.filter( + (w) => isSleepingSweepExempt(w, filters.alwaysShowDefaultBranch) || isWorktreeActive(w) + ) } if (filters.hideDefaultBranch) { result = result.filter((w) => !isDefaultBranchWorkspace(w)) diff --git a/mobile/src/worktree/workspace-list-types.ts b/mobile/src/worktree/workspace-list-types.ts index 4dfbc74fa53..4ddb135affe 100644 --- a/mobile/src/worktree/workspace-list-types.ts +++ b/mobile/src/worktree/workspace-list-types.ts @@ -52,6 +52,8 @@ export type FilterState = { filterRepoIds: Set hideSleeping: boolean hideDefaultBranch: boolean + /** Absent means on: #8873's exemption must fail open on older host payloads. */ + alwaysShowDefaultBranch?: boolean } export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] } diff --git a/mobile/src/worktree/workspace-view-settings.ts b/mobile/src/worktree/workspace-view-settings.ts index 0987915645a..32a4ac73a63 100644 --- a/mobile/src/worktree/workspace-view-settings.ts +++ b/mobile/src/worktree/workspace-view-settings.ts @@ -16,6 +16,7 @@ export type WorkspaceViewSettings = { sortBy?: 'name' | 'smart' | 'recent' | 'repo' | 'manual' hideSleepingWorkspaces?: boolean hideDefaultBranchWorkspace?: boolean + alwaysShowDefaultBranchWorkspace?: boolean filterRepoIds?: string[] collapsedGroups?: string[] workspaceStatuses?: WorkspaceStatusDefinition[] @@ -60,6 +61,7 @@ export type MobileViewState = { sortMode: MobileSortMode hideSleeping: boolean hideDefaultBranch: boolean + alwaysShowDefaultBranch: boolean filterRepoIds: string[] collapsedGroups: string[] workspaceStatuses: readonly WorkspaceStatusDefinition[] @@ -83,6 +85,8 @@ export function applyDesktopViewSettings( sortMode: sortMode ?? current.sortMode, hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping, hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch, + alwaysShowDefaultBranch: + settings.alwaysShowDefaultBranchWorkspace ?? current.alwaysShowDefaultBranch, filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds, collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups, workspaceStatuses diff --git a/mobile/vitest.config.ts b/mobile/vitest.config.ts index 9e7e3ecc59d..e39f90eedf1 100644 --- a/mobile/vitest.config.ts +++ b/mobile/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ oxc: vitestOxcConfig, test: { environment: 'node', - include: ['src/**/*.test.ts'] + // .tsx too: component tests exist (react-test-renderer + mocked react-native) and were + // silently never collected, so render-level regressions shipped untested. + include: ['src/**/*.test.ts', 'src/**/*.test.tsx'] } }) diff --git a/package.json b/package.json index 3a86ecfaf71..e8ec89daae0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.168-rc.1", + "version": "1.4.169-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/src/cli/handlers/worktree-linear-issue-link.ts b/src/cli/handlers/worktree-linear-issue-link.ts index 2285da4f01a..c8e1020d847 100644 --- a/src/cli/handlers/worktree-linear-issue-link.ts +++ b/src/cli/handlers/worktree-linear-issue-link.ts @@ -1,17 +1,15 @@ -import { parseLinearIssueInput } from '../../shared/linear-links' +import { + buildLinearIssueLinkUpdates, + LINEAR_ISSUE_LINK_CLEARED, + type LinearIssueLinkUpdates +} from '../../shared/linear-links' import { RuntimeClientError } from '../runtime-client' -type LinearIssueLinkParams = { - linkedLinearIssue: string | null - linkedLinearIssueWorkspaceId: string | null - linkedLinearIssueOrganizationUrlKey: string | null -} - export function getOptionalLinearIssueLinkFlag( flags: Map, name: string, options: { allowNull?: boolean } = {} -): LinearIssueLinkParams | undefined { +): LinearIssueLinkUpdates | undefined { const value = getPresentStringFlag(flags, name) if (value === undefined) { return undefined @@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag( 'Omit --linear-issue on create, or pass a Linear issue identifier or URL.' ) } - return { - linkedLinearIssue: null, - linkedLinearIssueWorkspaceId: null, - linkedLinearIssueOrganizationUrlKey: null - } + return { ...LINEAR_ISSUE_LINK_CLEARED } } - const parsed = parseLinearIssueInput(value) - if (!parsed) { + // Why: the shared builder treats empty input as "clear", which is right for a + // text field the user can blank but wrong for a flag — `--linear-issue " "` + // is a mistyped argument, not a request to unlink. Only the literal `null` + // clears, and that is handled above. + const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value) + if (!updates) { throw new RuntimeClientError( 'invalid_argument', 'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.' ) } - return { - linkedLinearIssue: parsed.identifier, - // Why: changing a link must not keep a workspace id from a previous issue. - // The org key from URLs is enough for current-resolution to safely rehydrate it. - linkedLinearIssueWorkspaceId: null, - linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null - } + return updates } function getPresentStringFlag( diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index bca1910cb10..2dad82ffdd1 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1245,6 +1245,9 @@ describe('orca cli worktree awareness', () => { linkedIssue: undefined, linkedLinearIssue: 'STA-335', linkedLinearIssueWorkspaceId: null, + // Why: a bare identifier carries no org, and the two scoping fields + // describe the issue being replaced. Inheriting a stale org key would + // pin the lookup to a workspace this issue does not live in. linkedLinearIssueOrganizationUrlKey: null, comment: undefined, runHooks: false, diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts new file mode 100644 index 00000000000..14235654d37 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' +import { createHookStatusSessionTabsInvalidator } from './hook-status-session-tabs-invalidation' + +function working( + overrides: Partial = {}, + payload: Partial = {} +): AgentHookEventPayload { + return { + paneKey: 'tab:leaf', + connectionId: null, + payload: { state: 'working', prompt: 'fix the tests', agentType: 'claude', ...payload }, + ...overrides + } +} + +describe('createHookStatusSessionTabsInvalidator', () => { + it('invalidates the first time a pane reports', () => { + const changed = createHookStatusSessionTabsInvalidator() + + expect(changed(working())).toBe(true) + }) + + it('stays quiet while the same status keeps being pinged', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working())).toBe(false) + }) + + it.each([ + ['state', { state: 'waiting' as const }], + ['prompt', { prompt: 'ship it' }], + ['agentType', { agentType: 'codex' }], + ['toolName', { toolName: 'Bash' }], + ['interactivePrompt', { interactivePrompt: '{"questions":[]}' }], + ['interrupted', { interrupted: true }] + ])('invalidates when %s changes', (_field, payload) => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({}, payload))).toBe(true) + }) + + it('ignores resume-identity rows, which the provider-session path owns', () => { + const changed = createHookStatusSessionTabsInvalidator() + + expect(changed(working({ providerSessionOnly: true }))).toBe(false) + }) + + it('tracks panes independently', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + + expect(changed(working({ paneKey: 'tab:other' }))).toBe(true) + expect(changed(working())).toBe(false) + }) + + it('re-arms a forgotten pane so an identical relaunch still invalidates', () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working()) + changed.forgetPane('tab:leaf') + + expect(changed(working())).toBe(true) + }) + + it("names an SSH host's panes so a disconnect can republish each of them", () => { + const changed = createHookStatusSessionTabsInvalidator() + changed(working({ connectionId: 'conn-1' })) + changed(working({ paneKey: 'tab:remote', connectionId: 'conn-1' })) + changed(working({ paneKey: 'tab:local' })) + + expect(changed.forgetConnection('conn-1').sort()).toEqual(['tab:leaf', 'tab:remote']) + expect(changed(working({ paneKey: 'tab:local' }))).toBe(false) + }) +}) diff --git a/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts new file mode 100644 index 00000000000..59b259fa849 --- /dev/null +++ b/src/main/agent-hooks/hook-status-session-tabs-invalidation.ts @@ -0,0 +1,52 @@ +import type { AgentHookEventPayload } from '../../shared/agent-hook-listener' +import type { ParsedAgentStatusPayload } from '../../shared/agent-status-types' + +type KnownStatus = { connectionId: string | null; payload: ParsedAgentStatusPayload } + +/** Reports whether a hook status event changed anything the `session.tabs` + * projection publishes, so a repeated same-state ping costs no snapshot rebuild. + * Mirrors `retainAgentRowSnapshot`'s change set so both carriers invalidate alike. */ +export function createHookStatusSessionTabsInvalidator(): { + (event: AgentHookEventPayload): boolean + forgetPane: (paneKey: string) => void + forgetConnection: (connectionId: string) => string[] +} { + const known = new Map() + const invalidator = (event: AgentHookEventPayload): boolean => { + // Why: resume-identity rows carry transport placeholders, not status; the + // provider-session invalidator owns their republish. + if (event.providerSessionOnly === true) { + return false + } + const previous = known.get(event.paneKey)?.payload + const next = event.payload + known.set(event.paneKey, { connectionId: event.connectionId, payload: next }) + return ( + !previous || + previous.state !== next.state || + previous.prompt !== next.prompt || + (previous.agentType ?? null) !== (next.agentType ?? null) || + (previous.toolName ?? null) !== (next.toolName ?? null) || + (previous.interactivePrompt ?? null) !== (next.interactivePrompt ?? null) || + (previous.interrupted ?? false) !== (next.interrupted ?? false) + ) + } + // Why: a cleared pane must re-arm, else the memo swallows the first event of the + // next agent when it happens to match the one that just went away. + invalidator.forgetPane = (paneKey: string): void => { + known.delete(paneKey) + } + // Why: an SSH disconnect clears a whole host's rows at once and names no pane, so + // the caller needs the pane list back to republish each affected workspace. + invalidator.forgetConnection = (connectionId: string): string[] => { + const forgotten: string[] = [] + for (const [paneKey, status] of known) { + if (status.connectionId === connectionId) { + known.delete(paneKey) + forgotten.push(paneKey) + } + } + return forgotten + } + return invalidator +} diff --git a/src/main/agent-hooks/manual-compact-hook-stream.test.ts b/src/main/agent-hooks/manual-compact-hook-stream.test.ts new file mode 100644 index 00000000000..22b1b0605c2 --- /dev/null +++ b/src/main/agent-hooks/manual-compact-hook-stream.test.ts @@ -0,0 +1,367 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RelayAgentHookServer } from '../../relay/agent-hook-server' +import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay' +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111') +const PROMPT_ID_1 = '22222222-2222-4222-8222-222222222222' +const PROMPT_ID_2 = '33333333-3333-4333-8333-333333333333' +const SESSION = { key: 'session_id' as const, id: 'session-a' } + +type ClaudeHook = { + hook_event_name: string + prompt?: string + prompt_id: string + session_id: string + trigger?: 'manual' | 'auto' + agent_id?: string + agent_type?: string +} + +function claudeHook( + hookEventName: string, + promptId: string, + extra: Partial = {} +): ClaudeHook { + return { + hook_event_name: hookEventName, + prompt_id: promptId, + session_id: 'session-a', + ...extra + } +} + +function manualEnvelope(hookEventName: string, state: 'working' | 'done') { + return { + source: 'claude' as const, + paneKey: PANE_KEY, + hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, + hookEventName, + providerPromptId: hookEventName === 'UserPromptSubmit' ? PROMPT_ID_2 : PROMPT_ID_1, + compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const), + providerSession: SESSION, + payload: { state, prompt: 'work before compact', agentType: 'claude' as const } + } +} + +function postHook(port: number, token: string, payload: Record) { + return fetch(`http://127.0.0.1:${port}/hook/claude`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Orca-Agent-Hook-Token': token + }, + body: JSON.stringify({ paneKey: PANE_KEY, payload }) + }) +} + +describe('manual Claude compact hook stream', () => { + const servers: { stop: () => void }[] = [] + const temporaryPaths: string[] = [] + + afterEach(() => { + for (const server of servers.splice(0)) { + server.stop() + } + for (const path of temporaryPaths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + it('settles an exact local HTTP lifecycle and rejects a duplicate completion', async () => { + const server = new AgentHookServer() + servers.push(server) + await server.start({ env: 'production' }) + const env = server.buildPtyEnv() + const events: string[] = [] + const unsubscribe = server.subscribeEnrichedStatus((event) => { + events.push(`${event.hookEventName}:${event.payload.state}`) + }) + + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) + ) + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('SubagentStart', PROMPT_ID_1, { + agent_id: 'compact-agent', + agent_type: 'general-purpose' + }) + ) + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('SubagentStop', PROMPT_ID_1, { + agent_id: 'compact-agent', + agent_type: 'general-purpose' + }) + ) + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + await postHook( + Number(env.ORCA_AGENT_HOOK_PORT), + env.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + + expect(events).toEqual([ + 'UserPromptSubmit:working', + 'PreCompact:working', + 'SubagentStart:working', + 'SubagentStop:working', + 'PostCompact:done' + ]) + expect(server.getStatusSnapshot()).toEqual([ + expect.objectContaining({ + state: 'done', + prompt: 'work before compact', + agentType: 'claude' + }) + ]) + unsubscribe() + }) + + it('preserves the manual identity over relay and rejects stale transport identities', async () => { + const main = new AgentHookServer() + const forwarded: AgentHookRelayEnvelope[] = [] + const emitted: string[] = [] + const connectionId = 'conn-a' + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-relay-')) + temporaryPaths.push(endpointDir) + const relay = new RelayAgentHookServer({ + endpointDir, + token: 'manual-compact-token', + forward: (envelope) => { + forwarded.push(envelope) + main.ingestRemote(envelope, connectionId) + } + }) + servers.push(main, relay) + const unsubscribe = main.subscribeEnrichedStatus((event) => { + emitted.push(`${event.hookEventName}:${event.payload.state}`) + }) + await relay.start({ publishEndpoint: false }) + const coordinates = relay.getCoordinates() + + await postHook( + coordinates.port, + coordinates.token, + claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) + ) + // A restarted relay has no prior listener cache; main still owns the status boundary. + relay.clearPaneState(PANE_KEY) + await postHook( + coordinates.port, + coordinates.token, + claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + const validPost = { + ...forwarded.at(-1)!, + hookEventName: 'PostCompact', + payload: { + state: 'done' as const, + prompt: 'work before compact', + agentType: 'claude' as const + } + } + + main.ingestRemote({ ...validPost, providerPromptId: PROMPT_ID_2 }, 'conn-a') + main.ingestRemote({ ...validPost, providerPromptId: undefined }, 'conn-a') + main.ingestRemote({ ...validPost, providerSession: undefined }, 'conn-a') + main.ingestRemote({ ...validPost, source: 'codex' }, 'conn-a') + main.ingestRemote(validPost, 'conn-b') + + expect(emitted).toEqual(['UserPromptSubmit:working', 'PreCompact:working']) + expect(main.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'work before compact' + }) + + await postHook( + coordinates.port, + coordinates.token, + claudeHook('SubagentStart', PROMPT_ID_1, { + agent_id: 'compact-agent', + agent_type: 'general-purpose' + }) + ) + await postHook( + coordinates.port, + coordinates.token, + claudeHook('SubagentStop', PROMPT_ID_1, { + agent_id: 'compact-agent', + agent_type: 'general-purpose' + }) + ) + await postHook( + coordinates.port, + coordinates.token, + claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + + expect(forwarded.at(-1)).toMatchObject({ + source: 'claude', + providerPromptId: PROMPT_ID_1, + compactTrigger: 'manual', + providerSession: SESSION, + hookEventName: 'PostCompact' + }) + expect(emitted.at(-1)).toBe('PostCompact:done') + expect(emitted.slice(-3)).toEqual([ + 'SubagentStart:working', + 'SubagentStop:working', + 'PostCompact:done' + ]) + expect(main.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'work before compact' + }) + unsubscribe() + }) + + it('invalidates a compact completion when later provider work owns the pane', () => { + const server = new AgentHookServer() + servers.push(server) + server.ingestRemote(manualEnvelope('UserPromptSubmit', 'working'), 'conn-a') + server.ingestRemote(manualEnvelope('PreCompact', 'working'), 'conn-a') + server.ingestRemote( + { + source: 'codex', + paneKey: PANE_KEY, + hookEventName: 'UserPromptSubmit', + hasExplicitPrompt: true, + payload: { state: 'working', prompt: 'new work', agentType: 'codex' } + }, + 'conn-a' + ) + server.ingestRemote({ ...manualEnvelope('PreCompact', 'working'), isReplay: true }, 'conn-a') + server.ingestRemote(manualEnvelope('PostCompact', 'done'), 'conn-a') + + expect(server.getStatusSnapshot()[0]).toMatchObject({ + state: 'working', + prompt: 'new work', + agentType: 'claude' + }) + expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + source: 'codex', + hookEventName: 'UserPromptSubmit' + }) + }) + + it('keeps automatic compact hooks working without settling the turn', async () => { + const main = new AgentHookServer() + const forwarded: AgentHookRelayEnvelope[] = [] + const endpointDir = mkdtempSync(join(tmpdir(), 'orca-auto-compact-relay-')) + temporaryPaths.push(endpointDir) + const relay = new RelayAgentHookServer({ + endpointDir, + forward: (envelope) => { + forwarded.push(envelope) + main.ingestRemote(envelope, 'conn-auto') + } + }) + servers.push(main, relay) + await relay.start({ publishEndpoint: false }) + const { port, token } = relay.getCoordinates() + + await postHook( + port, + token, + claudeHook('UserPromptSubmit', PROMPT_ID_1, { prompt: 'continue the task' }) + ) + await postHook(port, token, claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'auto' })) + await postHook(port, token, claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'auto' })) + + expect(forwarded.map((event) => event.hookEventName)).toEqual([ + 'UserPromptSubmit', + 'PreCompact', + 'PostCompact' + ]) + expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType: 'claude' }) + expect(main._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + hookEventName: 'PostCompact', + compactTrigger: undefined + }) + }) + + it('rejects unproven compact sources at the main relay boundary', () => { + const server = new AgentHookServer() + servers.push(server) + server.ingestRemote( + { + ...manualEnvelope('PreCompact', 'working'), + source: 'kimi', + payload: { state: 'working', prompt: '', agentType: 'kimi' } + }, + 'conn-kimi' + ) + server.ingestRemote( + { ...manualEnvelope('PreCompact', 'working'), compactTrigger: undefined }, + 'conn-auto' + ) + + expect(server.getStatusSnapshot()).toEqual([]) + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + }) + + it('hydrates a manual PreCompact identity and accepts only its exact local completion', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-compact-restore-')) + temporaryPaths.push(userDataPath) + const first = new AgentHookServer() + servers.push(first) + await first.start({ env: 'production', userDataPath }) + const firstEnv = first.buildPtyEnv() + await postHook( + Number(firstEnv.ORCA_AGENT_HOOK_PORT), + firstEnv.ORCA_AGENT_HOOK_TOKEN, + claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' }) + ) + await postHook( + Number(firstEnv.ORCA_AGENT_HOOK_PORT), + firstEnv.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + first.stop() + + const restored = new AgentHookServer() + servers.push(restored) + await restored.start({ env: 'production', userDataPath }) + const restoredEnv = restored.buildPtyEnv() + await postHook( + Number(restoredEnv.ORCA_AGENT_HOOK_PORT), + restoredEnv.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PostCompact', PROMPT_ID_2, { trigger: 'manual' }) + ) + expect(restored.getStatusSnapshot()[0]).toMatchObject({ state: 'working' }) + + await postHook( + Number(restoredEnv.ORCA_AGENT_HOOK_PORT), + restoredEnv.ORCA_AGENT_HOOK_TOKEN, + claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' }) + ) + expect(restored.getStatusSnapshot()[0]).toMatchObject({ + state: 'done', + prompt: 'work before compact', + agentType: 'claude' + }) + }) +}) diff --git a/src/main/agent-hooks/manual-compact-status-cleanup.test.ts b/src/main/agent-hooks/manual-compact-status-cleanup.test.ts new file mode 100644 index 00000000000..71df7f2dfb3 --- /dev/null +++ b/src/main/agent-hooks/manual-compact-status-cleanup.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' + +import { makePaneKey } from '../../shared/stable-pane-id' +import { AgentHookServer } from './server' + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) })) + +const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111') + +function compactEvent(hookEventName: string, state: 'working' | 'done') { + return { + source: 'claude' as const, + paneKey: PANE_KEY, + hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, + hookEventName, + providerPromptId: + hookEventName === 'UserPromptSubmit' + ? '22222222-2222-4222-8222-222222222222' + : '33333333-3333-4333-8333-333333333333', + compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const), + providerSession: { key: 'session_id' as const, id: 'session-a' }, + payload: { state, prompt: 'work before compact', agentType: 'claude' as const } + } +} + +describe('manual compact status cleanup', () => { + it('retires authority with pane, tab, and server cleanup', () => { + const server = new AgentHookServer() + const begin = (): void => { + server.ingestRemote(compactEvent('UserPromptSubmit', 'working'), 'conn-a') + server.ingestRemote(compactEvent('PreCompact', 'working'), 'conn-a') + } + + begin() + server.clearPaneState(PANE_KEY) + server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a') + expect(server.getStatusSnapshot()).toEqual([]) + + begin() + server.dropStatusEntriesByTabPrefix('manual-compact') + server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a') + expect(server.getStatusSnapshot()).toEqual([]) + + server.stop() + expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0) + }) +}) diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 71fa1a6a0fc..0f9e0a7812d 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -22,11 +22,14 @@ import { markCodexLeadTurnInterrupted, MAX_PANE_KEY_LEN, movePaneCacheState, + canAcceptClaudeCompactTransition, + normalizeClaudePromptId, normalizeHookPayload, parseFormEncodedBody, readRequestBody, reapRestoredClaudeSubagentsForDeadPane, reconcileRemoteCodexState, + resolveCachedClaudeCompactOwnership, resolveHookSource, preparePendingGrokResultDiscovery, seedClaudeSubagentRosterFromSnapshots, @@ -41,7 +44,11 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots } from '../../shared/claude-subagent-roster' -import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay' +import { + isAgentHookSource, + restoreShedStatusFields, + type AgentHookSource +} from '../../shared/agent-hook-relay' import { CLAUDE_STATUSLINE_PATHNAME, parseClaudeStatusLineBody, @@ -293,13 +300,23 @@ function sanitizeHydratedEntry( if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) { return null } + const source = isAgentHookSource(record.source) ? record.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto') + ? record.compactTrigger + : undefined return { paneKey, + source, tabId: typeof tabId === 'string' ? tabId : undefined, worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined, connectionId, hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined, hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined, + providerPromptId, + compactTrigger, toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined, toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined, toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined, @@ -570,6 +587,7 @@ export class AgentHookServer { private onAgentStatus: ((payload: EnrichedAgentHookEventPayload) => void) | null = null private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null + private paneStatusClearListeners = new Set() private statusChangeListeners = new Set() private providerSessionChangeListeners = new Set() // Why: setListener is a single slot owned by the main-window fanout; the @@ -654,6 +672,29 @@ export class AgentHookServer { this.onPaneStatusCleared = listener } + /** Multi-subscriber tap on pane status clears. Unlike `setPaneStatusClearListener` + * (a single slot the main window owns and drops on close) this survives window + * teardown and exists at all under headless serve, which never opens one. */ + subscribePaneStatusClear(listener: PaneStatusClearListener): () => void { + this.paneStatusClearListeners.add(listener) + return () => { + this.paneStatusClearListeners.delete(listener) + } + } + + private emitPaneStatusCleared(clear: AgentStatusClearIpcPayload): void { + this.onPaneStatusCleared?.(clear) + for (const listener of this.paneStatusClearListeners) { + // Why: callers are pane/connection teardown paths; one throwing subscriber must + // not strand the rest, matching every other fan-out here. + try { + listener(clear) + } catch (err) { + console.error('[agent-hooks] pane-status-clear listener threw', err) + } + } + } + /** Snapshot of cached statuses in IPC shape. Used by `agentStatus:getSnapshot` after tabs hydrate so the * dashboard catches up on hook events that fired during startup. */ getStatusSnapshot(): AgentStatusIpcPayload[] { @@ -1212,7 +1253,8 @@ export class AgentHookServer { if (!identity.inheritedFromActivePane) { this.maybeTrackAgentPromptSent(effectivePayload, previous) } - const enriched = this.attachStatusTiming(effectivePayload, now) + const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload) + const enriched = this.attachStatusTiming(cachedPayload, now) this.runtimeObservedStatusPaneKeys.add(enriched.paneKey) this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() @@ -1633,7 +1675,7 @@ export class AgentHookServer { this.scheduleStatusPersist() this.notifyStatusChangeListeners() for (const paneKey of clearedStatusPaneKeys) { - this.onPaneStatusCleared?.({ paneKey }) + this.emitPaneStatusCleared({ paneKey }) } } } @@ -1810,6 +1852,9 @@ export class AgentHookServer { hasExplicitPrompt?: boolean promptInteractionKey?: string hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown toolUseId?: string toolAgentId?: string toolAgentType?: string @@ -1871,6 +1916,14 @@ export class AgentHookServer { typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0 ? envelope.hookEventName.trim() : undefined + const source = isAgentHookSource(envelope.source) ? envelope.source : undefined + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined + const compactTrigger = + source === 'claude' && + (envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto') + ? envelope.compactTrigger + : undefined const statusDisposition = this.getAgentStatusDisposition(paneKey, { hookEventName, isReplay: envelope.isReplay === true @@ -1906,11 +1959,50 @@ export class AgentHookServer { return } // Why: restore a shed roster only when its digest and turn identity still match the cache. - const normalizedPayload = restoreShedStatusFields( + let normalizedPayload = restoreShedStatusFields( validatedPayload, envelope.shedFields, this.state.lastStatusByPaneKey.get(paneKey)?.payload ) + const previousStatus = this.state.lastStatusByPaneKey.get(paneKey) + if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') { + if ( + source !== 'claude' || + compactTrigger === undefined || + normalizedPayload.agentType !== source + ) { + return + } + if ( + hookEventName === 'PreCompact' && + envelope.isReplay === true && + (previousStatus?.hookEventName !== 'PreCompact' || + previousStatus.compactTrigger !== compactTrigger || + previousStatus.providerPromptId !== providerPromptId) + ) { + return + } + if ( + !canAcceptClaudeCompactTransition(previousStatus, { + source, + connectionId: trimmedConnectionId, + hookEventName, + providerPromptId, + compactTrigger, + providerSession + }) + ) { + return + } + } + if ( + source === 'claude' && + compactTrigger !== undefined && + normalizedPayload.prompt.length === 0 && + previousStatus?.payload.prompt + ) { + normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt } + } if ( envelope.providerSessionOnly === true && !isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType) @@ -1930,6 +2022,7 @@ export class AgentHookServer { }) const event: AgentHookEventPayload = { paneKey, + source, launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, @@ -1937,6 +2030,8 @@ export class AgentHookServer { hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, promptInteractionKey, hookEventName, + providerPromptId, + compactTrigger, toolUseId, toolAgentId, toolAgentType, @@ -2177,7 +2272,7 @@ export class AgentHookServer { this.notifyStatusChangeListeners() } // Why: always send the cutoff even with no matched entry — another host may have overwritten this pane's row. - this.onPaneStatusCleared?.({ + this.emitPaneStatusCleared({ transient: true, connectionId: normalizedConnectionId, clearedAt @@ -2319,7 +2414,7 @@ export class AgentHookServer { this.runtimeObservedStatusPaneKeys.delete(resolvedPaneKey) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onPaneStatusCleared?.({ paneKey: resolvedPaneKey }) + this.emitPaneStatusCleared({ paneKey: resolvedPaneKey }) } } diff --git a/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts new file mode 100644 index 00000000000..810322a53c9 --- /dev/null +++ b/src/main/attribution/terminal-attribution-win32-dash-argument.test.ts @@ -0,0 +1,100 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { applyTerminalAttributionEnv } from './terminal-attribution' + +// Why (#12046 follow-up): `powershell.exe -File +})) + +vi.mock('@/components/repo/RepoBadgeLabel', () => ({ + RepoBadgeMark: () => +})) + +vi.mock('@/components/cmd-j/palette-host-badge', () => ({ + getPaletteHostBadge: () => null +})) + +vi.mock('@/components/ui/command', async () => { + const React = await import('react') + return { + CommandDialog: ({ children, open }: { children: React.ReactNode; open?: boolean }) => + open ?
{children}
: null, + CommandInput: ({ + value, + onValueChange, + placeholder + }: { + value?: string + onValueChange?: (next: string) => void + placeholder?: string + }) => { + setCommandQuery = onValueChange ?? null + return ( + onValueChange?.(event.currentTarget.value)} + /> + ) + }, + CommandList: React.forwardRef(function CommandList( + { children }: { children: React.ReactNode }, + ref: React.ForwardedRef + ) { + return ( +
+ {children} +
+ ) + }), + CommandEmpty: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + CommandItem: ({ + children, + onSelect, + value + }: { + children: React.ReactNode + onSelect?: (value: string) => void + value?: string + }) => ( + + ) + } +}) + +const initialAppState = useAppStore.getInitialState() +let testRoot: Root +let testContainer: HTMLDivElement +let setCommandQuery: ((next: string) => void) | null = null + +function makeRepo(): Repo { + return { + id: 'repo-1', + path: '/repos/repo-1', + displayName: 'Repo 1', + badgeColor: '#000000', + addedAt: 0 + } +} + +function makeWorktree( + id: string, + displayName: string, + overrides: Partial = {} +): Worktree { + return { + id, + repoId: 'repo-1', + path: `/tmp/${id}`, + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: false, + displayName, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +async function flushEffects(): Promise { + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) +} + +async function renderPalette(overrides: Partial): Promise { + useAppStore.setState({ + activeModal: 'worktree-palette', + activeWorktreeId: null, + repos: [makeRepo()], + tabsByWorktree: {}, + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + unifiedTabsByWorktree: {}, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + // Why explicit: the sweep exemption is what these cases probe, so it must + // not ride on whatever the store default happens to be. + alwaysShowDefaultBranchWorkspace: true, + lastVisitedAtByWorktreeId: {}, + ...overrides + } as Partial) + + await act(async () => { + testRoot.render() + }) + await flushEffects() +} + +function getWorktreeRows(): string[] { + return [...testContainer.querySelectorAll('[data-command-item^="worktree:"]')].map( + (node) => node.textContent ?? '' + ) +} + +describe('WorktreeJumpPalette', () => { + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + setCommandQuery = null + useAppStore.setState(initialAppState, true) + testContainer = document.createElement('div') + document.body.appendChild(testContainer) + testRoot = createRoot(testContainer) + }) + + afterEach(async () => { + await act(async () => { + testRoot.unmount() + }) + document.body.replaceChildren() + useAppStore.setState(initialAppState, true) + }) + + it('keeps every inactive main workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + // Why kept: the exemption keys on isMainWorktree, not the branch name, so a + // branchless folder workspace is the project's entry point too. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('keeps the explicit default-branch filter authoritative', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch] }, + showSleepingWorkspaces: false, + hideDefaultBranchWorkspace: true + }) + + expect(testContainer.textContent).not.toContain('Default branch workspace') + }) + + it('keeps an active non-default workspace visible when sleeping workspaces are hidden', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: false, + browserTabsByWorktree: { + feature: [ + { + id: 'browser-tab-1', + worktreeId: 'feature', + url: 'https://example.com', + title: 'example.com', + loading: false, + faviconUrl: null, + canGoBack: false, + canGoForward: false, + loadError: null, + createdAt: 0 + } + ] + } + }) + + expect(testContainer.textContent).toContain('Feature workspace') + expect(testContainer.textContent).toContain('Default branch workspace') + // Why kept: same isMainWorktree exemption — folder workspaces are covered. + expect(testContainer.textContent).toContain('Folder workspace') + }) + + it('sweeps the sleeping main workspace once the exemption is opted out', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false, + alwaysShowDefaultBranchWorkspace: false + }) + + expect(getWorktreeRows()).toEqual([]) + expect(testContainer.textContent).not.toContain('Default branch workspace') + expect(testContainer.textContent).not.toContain('Feature workspace') + }) + + it('keeps the show-sleeping baseline and empty-query ordering intact', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + const folderMain = makeWorktree('folder-main', 'Folder workspace', { + isMainWorktree: true, + branch: '' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature, folderMain] }, + showSleepingWorkspaces: true, + lastVisitedAtByWorktreeId: { + feature: 300, + 'default-branch': 200, + 'folder-main': 100 + } + }) + + expect(getWorktreeRows()).toEqual([ + expect.stringContaining('Feature workspace'), + expect.stringContaining('Default branch workspace'), + expect.stringContaining('Folder workspace') + ]) + }) + + it('keeps typed-query results on the full non-archived scope', async () => { + const defaultBranch = makeWorktree('default-branch', 'Default branch workspace', { + isMainWorktree: true, + branch: 'refs/heads/main' + }) + const feature = makeWorktree('feature', 'Feature workspace', { + branch: 'refs/heads/feature' + }) + + await renderPalette({ + worktreesByRepo: { 'repo-1': [defaultBranch, feature] }, + showSleepingWorkspaces: false + }) + + expect(testContainer.textContent).not.toContain('Feature workspace') + + expect(setCommandQuery).not.toBeNull() + + await act(async () => { + setCommandQuery?.('Feature') + }) + await flushEffects() + + expect(testContainer.textContent).toContain('Feature workspace') + }) +}) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index c85a2d700e7..19e352f7669 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -31,7 +31,8 @@ import { isAutomationGeneratedWorkspace, isCliCreatedWorkspace, isDetachedHeadWorkspace, - isDefaultBranchWorkspace + isDefaultBranchWorkspace, + isSleepingSweepExemptWorkspace } from '@/components/sidebar/visible-worktrees' import { getLiveAgentStatusByWorktreeId, isInactiveWorkspace } from '@/lib/worktree-activity-state' import { orderEmptyQueryWorktrees } from '@/lib/order-empty-query-worktrees' @@ -404,6 +405,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const lastVisitedAtByWorktreeId = useAppStore((s) => s.lastVisitedAtByWorktreeId) const workspacePortScan = useAppStore((s) => s.workspacePortScan?.result ?? null) const openNewBrowserTabInActiveWorkspace = useAppStore( @@ -501,6 +503,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { } if ( !showSleepingWorkspaces && + // Why the exemption here too: Cmd+J re-implements the sidebar's + // filter pass, so the shared predicate is what keeps them in step. + !isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace) && isInactiveWorkspace( worktree.id, tabsByWorktree, @@ -515,6 +520,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { }), [ allWorktrees, + alwaysShowDefaultBranchWorkspace, browserTabsByWorktree, hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, diff --git a/src/renderer/src/components/activity/ActivityPrototypePage.tsx b/src/renderer/src/components/activity/ActivityPrototypePage.tsx index 4ee1e7c8be6..2c3f6b28bb1 100644 --- a/src/renderer/src/components/activity/ActivityPrototypePage.tsx +++ b/src/renderer/src/components/activity/ActivityPrototypePage.tsx @@ -73,6 +73,7 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { getActivityThreadTaskTitle, getActivityThreadWorkspaceTitle, @@ -168,24 +169,12 @@ const absoluteDateFormatter = new Intl.DateTimeFormat(undefined, { minute: '2-digit' }) -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatAbsoluteDate(timestamp: number): string { return absoluteDateFormatter.format(new Date(timestamp)) } function formatRelativeTime(timestamp: number): string { - const diffMs = timestamp - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTime(timestamp - Date.now()) } function findActivityTerminalPane( diff --git a/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx new file mode 100644 index 00000000000..9921ad0b614 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationDeleteDialogs.tsx @@ -0,0 +1,186 @@ +import React from 'react' +import { Check, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import type { + Automation, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { getExternalProviderLabel } from './external-automation-display' +import { translate } from '@/i18n/i18n' + +export function AutomationDeleteDialog({ + deleteTarget, + dontAskDeleteAgain, + confirmButtonRef, + onOpenChange, + onDontAskAgainToggle, + onCancel, + onConfirm +}: { + deleteTarget: Automation | null + dontAskDeleteAgain: boolean + confirmButtonRef: React.RefObject + onOpenChange: (open: boolean) => void + onDontAskAgainToggle: () => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + { + event.preventDefault() + confirmButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.automations.AutomationsPage.080dcb5fbb', + 'Delete Automation' + )} + + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} + {deleteTarget?.name}{' '} + {translate( + 'auto.components.automations.AutomationsPage.b264564427', + 'and its run history. Workspaces created by previous runs are not deleted.' + )} + + + {deleteTarget ? ( +
+
{deleteTarget.name}
+
+ {deleteTarget.workspaceMode === 'new_per_run' + ? translate( + 'auto.components.automations.AutomationsPage.cd8397cc32', + 'New workspace each run' + ) + : translate( + 'auto.components.automations.AutomationsPage.36f71740a7', + 'Selected workspace' + )} +
+
+ ) : null} + + + + + +
+
+ ) +} + +export function ExternalAutomationDeleteDialog({ + externalDeleteTarget, + confirmButtonRef, + onOpenChange, + onCancel, + onConfirm +}: { + externalDeleteTarget: { + manager: ExternalAutomationManager + job: ExternalAutomationJob + } | null + confirmButtonRef: React.RefObject + onOpenChange: (open: boolean) => void + onCancel: () => void + onConfirm: () => void +}): React.JSX.Element { + return ( + + { + event.preventDefault() + confirmButtonRef.current?.focus() + }} + > + + + {translate( + 'auto.components.automations.AutomationsPage.9adfab2596', + 'Delete External Automation' + )} + + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} + + {externalDeleteTarget?.job.name} + {' '} + {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} + {externalDeleteTarget + ? getExternalProviderLabel(externalDeleteTarget.manager) + : translate( + 'auto.components.automations.AutomationsPage.8500baacb4', + 'external source' + )}{' '} + {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')}{' '} + {externalDeleteTarget?.manager.targetLabel}. + + + {externalDeleteTarget ? ( +
+
+ {externalDeleteTarget.job.name} +
+
+ { + getExternalAutomationScheduleDisplay( + externalDeleteTarget.manager, + externalDeleteTarget.job + ).label + } +
+
+ ) : null} + + + + +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationListExternalRows.tsx b/src/renderer/src/components/automations/AutomationListExternalRows.tsx new file mode 100644 index 00000000000..b5269b65593 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListExternalRows.tsx @@ -0,0 +1,223 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import { cn } from '@/lib/utils' +import type { + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' +import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' +import { + getExternalAutomationActionDisabledMessage, + getExternalAutomationSourceAvailability +} from './external-automation-source-availability' +import { translate } from '@/i18n/i18n' + +export function AutomationListExternalRows({ + entries, + selectedExternalKey, + relativeNow, + sshConnectionStates, + externalActionKey, + onSelect, + onRequestAction, + onEdit +}: { + entries: readonly ExternalAutomationListEntry[] + selectedExternalKey: string | null | undefined + relativeNow: number + sshConnectionStates: ReadonlyMap> + externalActionKey: string | null + onSelect: (entryKey: string) => void + onRequestAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + onEdit: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void +}): React.JSX.Element { + return ( + <> + {entries.map((entry) => { + const providerLabel = getExternalProviderLabel(entry.manager) + const targetKindLabel = getExternalTargetKindLabel(entry.manager) + if (entry.kind === 'source') { + const sshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const sourceAvailability = getExternalAutomationSourceAvailability({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus + }) + return ( + + ) + } + const nextRunLabel = entry.job.enabled + ? formatExternalDate(entry.job.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const entrySshStatus = + entry.manager.target.type === 'ssh' + ? sshConnectionStates.get(entry.manager.target.connectionId)?.status + : undefined + const disabledMessage = getExternalAutomationActionDisabledMessage({ + manager: entry.manager, + providerLabel, + targetKindLabel, + sshStatus: entrySshStatus, + actionInProgress: externalActionKey !== null + }) + const actionDisabled = disabledMessage !== null + const scheduleDisplay = getExternalAutomationScheduleDisplay(entry.manager, entry.job) + return ( + + + + + + onRequestAction(entry.manager, entry.job, 'run')} + > + + + {disabledMessage ?? + translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now')} + + + {entry.manager.provider === 'hermes' ? ( + onEdit(entry.manager, entry.job)} + > + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + ) : null} + + onRequestAction(entry.manager, entry.job, entry.job.enabled ? 'pause' : 'resume') + } + > + {entry.job.enabled ? : } + {entry.job.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onRequestAction(entry.manager, entry.job, 'delete')} + > + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListLocalRows.tsx b/src/renderer/src/components/automations/AutomationListLocalRows.tsx new file mode 100644 index 00000000000..1a5a4994aac --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListLocalRows.tsx @@ -0,0 +1,245 @@ +import React from 'react' +import { Clock, Pause, Pencil, Play, Trash2 } from 'lucide-react' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger +} from '@/components/ui/context-menu' +import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { cn } from '@/lib/utils' +import type { Automation, AutomationRun } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import { formatAutomationSchedule } from '../../../../shared/automation-schedules' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationHostTarget } from './automation-host-client' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatAutomationCost, + formatAutomationTokens, + summarizeAutomationRunUsage +} from './automation-usage-model' +import { getAutomationTargetAvailability } from './automation-target-availability' +import { getAgentLabel } from './automation-draft-model' +import { translate } from '@/i18n/i18n' + +export function AutomationListLocalRows({ + automations, + selectedId, + isSelectedLocal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + onSelect, + onRunNow, + onEdit, + onToggle, + onDelete +}: { + automations: readonly Automation[] + selectedId: string | null | undefined + isSelectedLocal: boolean + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + onSelect: (automationId: string) => void + onRunNow: (automation: Automation) => void + onEdit: (automation: Automation) => void + onToggle: (automation: Automation) => void + onDelete: (automation: Automation) => void +}): React.JSX.Element { + // Why: one pass over runs instead of a full scan per rendered automation — + // this list re-renders on the relativeNow timer. + const runsByAutomationId = React.useMemo(() => { + const grouped = new Map() + for (const run of runs) { + const existing = grouped.get(run.automationId) + if (existing) { + existing.push(run) + } else { + grouped.set(run.automationId, [run]) + } + } + return grouped + }, [runs]) + return ( + <> + {automations.map((automation) => { + const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) + const automationWorktree = automation.workspaceId + ? worktreeMap.get(automation.workspaceId) + : null + const automationRunAvailability = getAutomationTargetAvailability({ + automation, + repo: automationRepo, + workspace: automationWorktree, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) + }) + const baseRefLabel = + automation.baseBranch ?? + automationRepo?.worktreeBaseRef ?? + translate( + 'auto.components.automations.AutomationsPage.projectDefaultBaseRef', + 'project default' + ) + const workspaceLabel = + automation.workspaceMode === 'new_per_run' + ? translate( + 'auto.components.automations.AutomationsPage.createFromBaseRef', + 'Create from {{baseRef}}', + { baseRef: baseRefLabel } + ) + : (automationWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + const usageSummary = summarizeAutomationRunUsage( + runsByAutomationId.get(automation.id) ?? [] + ) + const usageText = + usageSummary.knownRuns > 0 + ? translate( + 'auto.components.automations.AutomationsPage.runUsageSummary', + '{{cost}} est. · {{tokens}} tokens', + { + cost: formatAutomationCost(usageSummary.estimatedCostUsd), + tokens: formatAutomationTokens(usageSummary.totalTokens) + } + ) + : usageSummary.unavailableRuns > 0 + ? translate( + 'auto.components.automations.AutomationsPage.usageUnavailable', + 'Usage unavailable' + ) + : translate( + 'auto.components.automations.AutomationsPage.noRunUsageYet', + 'No run usage yet' + ) + const nextRunLabel = automation.enabled + ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) + : translate('auto.components.automations.AutomationsPage.paused', 'Paused') + const scheduleLabel = formatAutomationSchedule(automation.rrule) + return ( + + + + + + { + if (!automationRunAvailability.canRunNow) { + event.preventDefault() + return + } + onRunNow(automation) + }} + > + + + {automationRunAvailability.canRunNow + ? translate('auto.components.automations.AutomationsPage.2faecab10b', 'Run Now') + : automationRunAvailability.message} + + + onEdit(automation)}> + + {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} + + onToggle(automation)}> + {automation.enabled ? ( + + ) : ( + + )} + {automation.enabled + ? translate('auto.components.automations.AutomationsPage.b457436d6a', 'Pause') + : translate('auto.components.automations.AutomationsPage.376631ef2b', 'Resume')} + + + onDelete(automation)}> + + {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')} + + + + ) + })} + + ) +} diff --git a/src/renderer/src/components/automations/AutomationListSearchField.tsx b/src/renderer/src/components/automations/AutomationListSearchField.tsx new file mode 100644 index 00000000000..3a2bceb7c48 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationListSearchField.tsx @@ -0,0 +1,106 @@ +import React, { useRef } from 'react' +import { Search, X } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { translate } from '@/i18n/i18n' +import { cn } from '@/lib/utils' + +type AutomationListSearchFieldProps = { + query: string + isTooLarge: boolean + onQueryChange: (query: string) => void + onClear: () => void + className?: string +} + +export function AutomationListSearchField({ + query, + isTooLarge, + onQueryChange, + onClear, + className +}: AutomationListSearchFieldProps): React.JSX.Element { + const inputRef = useRef(null) + const hasText = query !== '' + const tooLargeMessage = isTooLarge + ? translate( + 'auto.components.automations.AutomationListSearchField.tooLong', + 'Search text is too long — list is unfiltered' + ) + : null + + return ( +
+ + onQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Escape' || event.nativeEvent.isComposing) { + return + } + if (!hasText) { + return + } + event.preventDefault() + onClear() + }} + /> + {hasText ? ( +
+ {isTooLarge ? ( + + {translate( + 'auto.components.automations.AutomationListSearchField.tooLongShort', + 'Too long' + )} + + ) : null} + +
+ ) : null} + {isTooLarge ? ( +
+ {tooLargeMessage} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationRunHistory.tsx b/src/renderer/src/components/automations/AutomationRunHistory.tsx index be8a76dcd5e..777eaff3fa2 100644 --- a/src/renderer/src/components/automations/AutomationRunHistory.tsx +++ b/src/renderer/src/components/automations/AutomationRunHistory.tsx @@ -19,7 +19,7 @@ import { translate } from '@/i18n/i18n' type AutomationRunHistoryProps = { runs: AutomationRun[] automationId: string - worktreeMap: Map + worktreeMap: ReadonlyMap onOpenRun: (run: AutomationRun) => void } diff --git a/src/renderer/src/components/automations/AutomationsDetailPane.tsx b/src/renderer/src/components/automations/AutomationsDetailPane.tsx new file mode 100644 index 00000000000..2d290f26ac6 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsDetailPane.tsx @@ -0,0 +1,339 @@ +import React from 'react' +import { Eye, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { cn } from '@/lib/utils' +import type { + Automation, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun, + AutomationRun +} from '../../../../shared/automations-types' +import type { Worktree } from '../../../../shared/types' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { AutomationDetail } from './AutomationDetail' +import { HermesCronOutputView } from './HermesCronOutputView' +import { AutomationRunPageFrame } from './AutomationRunPageFrame' +import { AutomationRunHistory } from './AutomationRunHistory' +import { ExternalAutomationManagers } from './ExternalAutomationManagers' +import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalRunContent, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' +import { + formatAutomationDateTimeWithRelative, + getAutomationRunStatusLabel, + getAutomationRunStatusVariant +} from './automation-page-parts' +import { getAutomationRunContent } from './automation-run-content' +import type { AutomationTargetAvailability } from './automation-target-availability' +import type { AutomationRunViewState } from './automation-run-view-state' +import type { AutomationRunWorkspaceDisplay } from './automation-run-workspace-display' +import type { ExternalAutomationSourceAvailability } from './external-automation-source-availability' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { translate } from '@/i18n/i18n' + +type AutomationsDetailPaneProps = { + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + selectedExternalRunPage: SelectedExternalRunPage | null + selectedAutomationRunPage: AutomationRun | null + selectedRuns: AutomationRun[] + activePaneTab: AutomationPaneTab + relativeNow: number + externalActionKey: string | null + selectedRepoDisplayName: string + selectedRepoDefaultBaseRef: string | null + selectedWorkspaceName: string + hostLabelById: ReadonlyMap + selectedRunNowAvailability: AutomationTargetAvailability | null + selectedExternalSourceAvailability: ExternalAutomationSourceAvailability | null + selectedExternalSshSource: { + manager: ExternalAutomationManager + } | null + selectedExternalSshConnected: boolean + selectedAutomationRunPageWorkspaceDisplay: AutomationRunWorkspaceDisplay | null + selectedAutomationRunPageViewState: AutomationRunViewState | null + canRerunSelectedAutomationRunPage: boolean + isSelectedAutomationRunPageRerunPending: boolean + worktreeMap: ReadonlyMap + fetchExternalAutomationRuns: FetchExternalAutomationRuns + onActivePaneTabChange: (tab: AutomationPaneTab) => void + onClearExternalRunPage: () => void + onClearAutomationRunPage: () => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openExternalRunPage: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + run: ExternalAutomationRun + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + connectExternalAutomationSource: (manager: ExternalAutomationManager) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + rerunAutomationRun: (automation: Automation, run: AutomationRun) => void + openRunWorkspace: (run: AutomationRun) => void + openAutomationRunPage: (run: AutomationRun) => void +} + +export function AutomationsDetailPane({ + selected, + selectedExternal, + selectedExternalRunPage, + selectedAutomationRunPage, + selectedRuns, + activePaneTab, + relativeNow, + externalActionKey, + selectedRepoDisplayName, + selectedRepoDefaultBaseRef, + selectedWorkspaceName, + hostLabelById, + selectedRunNowAvailability, + selectedExternalSourceAvailability, + selectedExternalSshSource, + selectedExternalSshConnected, + selectedAutomationRunPageWorkspaceDisplay, + selectedAutomationRunPageViewState, + canRerunSelectedAutomationRunPage, + isSelectedAutomationRunPageRerunPending, + worktreeMap, + fetchExternalAutomationRuns, + onActivePaneTabChange, + onClearExternalRunPage, + onClearAutomationRunPage, + requestExternalAction, + openExternalRunPage, + openEditExternalDialog, + connectExternalAutomationSource, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + rerunAutomationRun, + openRunWorkspace, + openAutomationRunPage +}: AutomationsDetailPaneProps): React.JSX.Element { + return ( +
+ {selectedExternal ? ( +
+ {selectedExternalRunPage ? ( + + + + ) : selectedExternal.kind === 'job' ? ( + + ) : ( +
+
+
+
+ {selectedExternal.manager.targetLabel} +
+
+ {selectedExternalSourceAvailability?.summary} +
+
+ {selectedExternalSshSource ? ( + + ) : null} +
+
+ {selectedExternalSourceAvailability?.detail} +
+
+ )} +
+ ) : ( + onActivePaneTabChange(value as AutomationPaneTab)} + className="min-h-0 flex-1 gap-0" + > +
+ + + {translate('auto.components.automations.AutomationsPage.bb1b2cd31e', 'Overview')} + + + {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} + {selectedRuns.length} + + +
+ + + void runNow(automation)} + onEdit={(automation) => void openEditDialog(automation)} + onToggle={(automation) => void toggleAutomation(automation)} + onDelete={requestDeleteAutomation} + /> + + + + {selectedAutomationRunPage ? ( + + {canRerunSelectedAutomationRunPage && selected ? ( + + ) : null} + {selectedAutomationRunPageViewState ? ( + + ) : null} + + } + onBack={onClearAutomationRunPage} + > + + + ) : selected ? ( + + ) : ( +
+ {translate( + 'auto.components.automations.AutomationsPage.c3a28c9793', + 'Select an automation to view runs.' + )} +
+ )} +
+
+ )} +
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx new file mode 100644 index 00000000000..31d9cc9d6d9 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -0,0 +1,206 @@ +import React from 'react' +import { Plus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import type { + Automation, + AutomationRun, + ExternalAutomationAction, + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import type { SshConnectionState } from '../../../../shared/ssh-types' +import type { ProjectHostSetup, Repo, Worktree } from '../../../../shared/types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' +import type { AutomationHostTarget } from './automation-host-client' +import { clampAutomationListSearchQueryInput } from './automation-list-search' +import type { AutomationPaneTab } from './automation-page-state' +import { AutomationListSearchField } from './AutomationListSearchField' +import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { AutomationListLocalRows } from './AutomationListLocalRows' +import { AutomationListExternalRows } from './AutomationListExternalRows' +import { translate } from '@/i18n/i18n' + +type AutomationsListPanelProps = { + hasListItems: boolean + hasFilteredListItems: boolean + isListSearchActive: boolean + listSearchQuery: string + isListSearchQueryTooLarge: boolean + onListSearchQueryChange: (query: string) => void + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + selected: Automation | null + selectedExternal: ExternalAutomationListEntry | null + runs: readonly AutomationRun[] + relativeNow: number + repoMap: ReadonlyMap + worktreeMap: ReadonlyMap + projectHostSetups: readonly ProjectHostSetup[] + sshConnectionStates: ReadonlyMap> + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > + automationHostTarget: AutomationHostTarget | null + automationSourceHostAvailabilityById: ReadonlyMap + externalActionKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void + setActivePaneTab: (tab: AutomationPaneTab) => void + runNow: (automation: Automation) => void + openEditDialog: (automation: Automation) => void + toggleAutomation: (automation: Automation) => void + requestDeleteAutomation: (automation: Automation) => void + requestExternalAction: ( + manager: ExternalAutomationManager, + job: ExternalAutomationJob, + action: ExternalAutomationAction + ) => void + openEditExternalDialog: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void + openCreateDialog: (template?: AutomationTemplate) => void +} + +export function AutomationsListPanel({ + hasListItems, + hasFilteredListItems, + isListSearchActive, + listSearchQuery, + isListSearchQueryTooLarge, + onListSearchQueryChange, + filteredAutomations, + filteredExternalAutomationEntries, + selected, + selectedExternal, + runs, + relativeNow, + repoMap, + worktreeMap, + projectHostSetups, + sshConnectionStates, + runtimeStatusByEnvironmentId, + automationHostTarget, + automationSourceHostAvailabilityById, + externalActionKey, + selectAutomationId, + selectExternalKey, + setActivePaneTab, + runNow, + openEditDialog, + toggleAutomation, + requestDeleteAutomation, + requestExternalAction, + openEditExternalDialog, + openCreateDialog +}: AutomationsListPanelProps): React.JSX.Element { + return ( +
+ {hasListItems ? ( +
+ + onListSearchQueryChange(clampAutomationListSearchQueryInput(query)) + } + onClear={() => onListSearchQueryChange('')} + /> +
+ ) : null} +
+ {hasFilteredListItems ? ( +
+ + {translate('auto.components.automations.AutomationsPage.761a35834d', 'Automation')} + + + {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} + +
+ ) : null} + { + selectExternalKey(null) + selectAutomationId(automationId) + }} + onRunNow={runNow} + onEdit={openEditDialog} + onToggle={toggleAutomation} + onDelete={requestDeleteAutomation} + /> + { + selectExternalKey(entryKey) + setActivePaneTab('overview') + }} + onRequestAction={requestExternalAction} + onEdit={openEditExternalDialog} + /> + {hasListItems && isListSearchActive && !hasFilteredListItems ? ( +
+ {translate( + 'auto.components.automations.AutomationsPage.noSearchMatches', + 'No automations match your search.' + )} +
+ ) : null} + {!hasListItems ? ( +
+
+ {translate( + 'auto.components.automations.AutomationsPage.d207ab4c25', + 'Start from a template' + )} +
+ {getAutomationTemplates().map((template) => ( + + ))} + +
+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index d55ee012dae..97f15d844a6 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -1,46 +1,16 @@ /* eslint-disable max-lines -- Why: this page owns the automations list/detail - * orchestration while the form and detail presentation live in sibling files. */ + * orchestration while the form, list, and detail presentation live in sibling files. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - CalendarClock, - Check, - Clock, - Eye, - Pause, - Pencil, - Play, - Plus, - RefreshCw, - Trash2, - X -} from 'lucide-react' +import { CalendarClock, Plus, RefreshCw, X } from 'lucide-react' import { toast } from 'sonner' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' -import type { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger -} from '@/components/ui/context-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useAppStore } from '@/store' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getLocalPreflightContext, localPreflightContextKey } from '@/lib/local-preflight-context' import { cn } from '@/lib/utils' -import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' import { getAgentCatalog } from '@/lib/agent-catalog' import { useRepoMap, useWorktreeMap } from '@/store/selectors' import { activateAndRevealWorktree } from '@/lib/worktree-activation' @@ -50,7 +20,6 @@ import type { ExternalAutomationJob, ExternalAutomationManager, ExternalAutomationRun, - AutomationPrecheck, AutomationRun, AutomationUpdateInput } from '../../../../shared/automations-types' @@ -61,34 +30,20 @@ import { parseExecutionHostId } from '../../../../shared/execution-host' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' -import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { PreflightStatus } from '../../../../preload/api-types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { OrcaHooks, Repo, Worktree } from '../../../../shared/types' +import type { OrcaHooks, Repo } from '../../../../shared/types' import { getWorktreePathBasenameFromId } from '../../../../shared/worktree-id' import { - buildAutomationCronSchedule, buildAutomationRrule, - formatAutomationSchedule, isValidAutomationCronSchedule, isValidAutomationSchedule, tryParseAutomationRrule } from '../../../../shared/automation-schedules' -import { - formatAutomationDateTimeWithRelative, - getAutomationRunStatusLabel, - getAutomationRunStatusVariant -} from './automation-page-parts' -import { - formatAutomationCost, - formatAutomationTokens, - summarizeAutomationRunUsage -} from './automation-usage-model' import { canRerunAutomationRun, - getAutomationRerunPendingRemainingMs, - getAutomationRunViewState + getAutomationRunViewState, + waitForAutomationRerunPendingVisibility } from './automation-run-view-state' import { automationRunMatchesPaneKey, @@ -98,22 +53,17 @@ import { resolveAutomationRunOpenTarget } from './automation-run-open-target' import { getAutomationRunWorkspaceDisplay } from './automation-run-workspace-display' -import CommentMarkdown from '@/components/sidebar/CommentMarkdown' -import { AutomationDetail } from './AutomationDetail' -import { HermesCronOutputView } from './HermesCronOutputView' import { AutomationEditorDialog, type AutomationCreateTarget, type AutomationDraft } from './AutomationEditorDialog' -import { AutomationRunPageFrame } from './AutomationRunPageFrame' -import { AutomationRunHistory } from './AutomationRunHistory' import { getAutomationSetupDecisionDraftValue, getVisibleAutomationSetupDecision, resolveAutomationSetupDecisionForSave } from './automation-setup-decision' -import { getAutomationTemplates, type AutomationTemplate } from './automation-templates' +import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' import { buildAutomationRunContextForRepo } from './automation-run-context' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -125,14 +75,14 @@ import { } from '../task-source-provider-availability' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import { - getExternalAutomationActionDisabledMessage, getExternalAutomationSourceAvailability, isSshConnectionBusy } from './external-automation-source-availability' import { createAutomationForTarget, deleteAutomationForTarget, - type AutomationHostTarget, + getAutomationHostTargetFromKey, + getAutomationHostTargetKey, getAutomationListTarget, getAutomationOwnerTarget, getAutomationTargetFromHostId, @@ -141,223 +91,38 @@ import { runAutomationNowForTarget, updateAutomationForTarget } from './automation-host-client' -import { getExternalAutomationScheduleDisplay } from './external-automation-schedule-display' -import { ExternalAutomationManagers } from './ExternalAutomationManagers' import type { FetchExternalAutomationRuns } from './ExternalAutomationRunTable' +import { + AUTOMATION_DEFAULT_TIME, + buildDraftPrecheck, + buildHermesCronSchedule, + formatTimeInput, + getDefaultWorktree, + parseDraftTime +} from './automation-draft-model' +import { + getRepoBackedAutomationSourceContext, + getRuntimeSourceHostAvailability, + type RepoBackedAutomationSourceContext +} from './automation-source-context' +import type { AutomationPaneTab, SelectedExternalRunPage } from './automation-page-state' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey, + getExternalProviderLabel, + getExternalTargetKindLabel, + isMissingExternalRunsApiError +} from './external-automation-display' +import { buildExternalAutomationListEntries } from './external-automation-list-entries' +import { useAutomationListSearch } from './use-automation-list-search' +import { AutomationDeleteDialog, ExternalAutomationDeleteDialog } from './AutomationDeleteDialogs' +import { AutomationsListPanel } from './AutomationsListPanel' +import { AutomationsDetailPane } from './AutomationsDetailPane' import { useContextualTour } from '@/components/contextual-tours/use-contextual-tour' import { translate } from '@/i18n/i18n' const AGENTS = getAgentCatalog().map((agent) => agent.id) -const DEFAULT_TIME = '09:00' const AUTOMATIONS_CHANGED_EVENT = 'orca:automations-changed' -type AutomationPaneTab = 'overview' | 'runs' -type RepoBackedAutomationSourceContext = TaskSourceContext & { provider: 'github' | 'gitlab' } - -type ExternalAutomationListEntry = - | { - kind: 'job' - key: string - manager: ExternalAutomationManager - job: ExternalAutomationJob - } - | { - kind: 'source' - key: string - manager: ExternalAutomationManager - } - -type SelectedExternalRunPage = { - manager: ExternalAutomationManager - job: ExternalAutomationJob - run: ExternalAutomationRun -} - -function getAutomationHostTargetKey(target: AutomationHostTarget): string { - return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' -} - -function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { - if (!key) { - return null - } - if (key.startsWith('environment:')) { - return { kind: 'environment', environmentId: key.slice('environment:'.length) } - } - return { kind: 'local' } -} - -function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { - return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null -} - -function getRepoBackedAutomationSourceContext( - automation: Automation -): RepoBackedAutomationSourceContext | null { - const context = automation.sourceContext - return context?.provider === 'github' || context?.provider === 'gitlab' - ? (context as RepoBackedAutomationSourceContext) - : null -} - -function getRuntimeSourceHostAvailability( - context: TaskSourceContext, - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > -): TaskSourceHostAvailability | null { - const parsed = parseExecutionHostId(context.hostId) - if (parsed?.kind !== 'runtime') { - return null - } - const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) - if (!entry) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!entry.status) { - return { hostId: context.hostId, health: 'disconnected' } - } - if (entry.status.graphStatus !== 'ready') { - return { hostId: context.hostId, health: 'connecting' } - } - const capabilities = entry.status.capabilities - if (!capabilities) { - return { hostId: context.hostId, reason: 'checking-task-source-capability' } - } - if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { - return { hostId: context.hostId, reason: 'missing-task-source-capability' } - } - return null -} - -function formatTimeInput(hour: number, minute: number): string { - return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` -} - -function parseDraftTime(time: string): { hour: number; minute: number } { - const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) - return { - hour: Number.isFinite(rawHour) ? rawHour : 9, - minute: Number.isFinite(rawMinute) ? rawMinute : 0 - } -} - -function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { - const command = draft.precheckCommand.trim() - if (!command) { - return null - } - const rawTimeout = Number(draft.precheckTimeoutSeconds) - return { - command, - timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 - } -} - -function buildHermesCronSchedule(draft: AutomationDraft): string { - if (draft.preset === 'custom') { - return draft.customSchedule.trim() - } - const { hour, minute } = parseDraftTime(draft.time) - return buildAutomationCronSchedule({ - preset: draft.preset, - hour, - minute, - dayOfWeek: Number(draft.dayOfWeek) - }) -} - -function getAgentLabel(agentId: string): string { - return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId -} - -function getExternalAutomationKey( - manager: ExternalAutomationManager, - job: ExternalAutomationJob -): string { - return `${manager.id}:${job.id}` -} - -function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { - return `${manager.id}:source` -} - -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getExternalProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - -function getExternalRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getExternalRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - -function getExternalRunContent(run: ExternalAutomationRun): string { - return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' -} - -function getAutomationRunContent(run: AutomationRun): string { - const savedOutput = run.outputSnapshot?.content.trim() - if (savedOutput) { - return run.outputSnapshot?.content ?? savedOutput - } - if (run.precheckResult) { - const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] - .filter(Boolean) - .join('\n\n') - if (output) { - return output - } - } - return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' -} - -function isMissingExternalRunsApiError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error) - return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) -} - -async function waitForAutomationRerunPendingVisibility(pendingStartedAt: number): Promise { - const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) - if (remainingMs <= 0) { - return - } - await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) -} - export default function AutomationsPage(): React.JSX.Element { const repos = useAppStore((s) => s.repos) const projectHostSetups = useAppStore((s) => s.projectHostSetups) @@ -415,6 +180,7 @@ export default function AutomationsPage(): React.JSX.Element { ) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) + const [listSearchQuery, setListSearchQuery] = useState('') const [createOpen, setCreateOpen] = useState(false) const [createTarget, setCreateTarget] = useState('orca') const [editingAutomationId, setEditingAutomationId] = useState(null) @@ -463,6 +229,9 @@ export default function AutomationsPage(): React.JSX.Element { const [dontAskDeleteAgain, setDontAskDeleteAgain] = useState(false) const editRequestRef = useRef(0) const deleteConfirmButtonRef = useRef(null) + // Why: both dialogs stay mounted, so a shared ref would let one dialog's + // unmount clear the focus target the other still needs. + const externalDeleteConfirmButtonRef = useRef(null) const completionInFlightRef = useRef>(new Set()) const rerunRunIdsInFlightRef = useRef>(new Set()) const workspaceNameCacheRef = useRef>(new Map()) @@ -488,40 +257,35 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', scheduleWarning: null }) - const externalAutomationEntries = useMemo( - () => - externalManagers.flatMap((manager): ExternalAutomationListEntry[] => { - if (manager.jobs.length === 0) { - if ( - manager.provider === 'hermes' && - (manager.status === 'unavailable' || manager.error) - ) { - return [ - { - kind: 'source' as const, - key: getExternalAutomationSourceKey(manager), - manager - } - ] - } - return [] - } - return manager.jobs.map((job) => ({ - kind: 'job' as const, - key: getExternalAutomationKey(manager, job), - manager, - job - })) - }), + const externalAutomationEntries = useMemo( + () => buildExternalAutomationListEntries(externalManagers), [externalManagers] ) + const { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } = useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey + }) + const selectedExternal = externalAutomationEntries.find((entry) => entry.key === selectedExternalKey) ?? (automations.length === 0 ? (externalAutomationEntries[0] ?? null) : null) @@ -769,7 +533,10 @@ export default function AutomationsPage(): React.JSX.Element { : null const canRerunSelectedAutomationRunPage = selectedAutomationRunPage !== null && - canRerunAutomationRun({ automation: selected, run: selectedAutomationRunPage }) + canRerunAutomationRun({ + automation: selected, + run: selectedAutomationRunPage + }) const isSelectedAutomationRunPageRerunPending = selectedAutomationRunPage !== null && rerunRunIdsInFlight.has(selectedAutomationRunPage.id) const preflightStatusCurrent = preflightStatusContextKey === expectedPreflightContextKey @@ -1298,7 +1065,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: '', missedRunGraceMinutes: '720', @@ -1356,7 +1123,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: latest.precheck?.command ?? '', precheckTimeoutSeconds: String(latest.precheck?.timeoutSeconds ?? 60), preset: schedule?.preset ?? (hasCustomSchedule ? 'custom' : 'weekdays'), - time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : DEFAULT_TIME, + time: schedule ? formatTimeInput(schedule.hour, schedule.minute) : AUTOMATION_DEFAULT_TIME, dayOfWeek: String(schedule?.dayOfWeek ?? 1), customSchedule: hasCustomSchedule ? latest.rrule : '', missedRunGraceMinutes: String(latest.missedRunGraceMinutes), @@ -1404,7 +1171,7 @@ export default function AutomationsPage(): React.JSX.Element { precheckCommand: '', precheckTimeoutSeconds: '60', preset: hasCustomSchedule ? 'custom' : 'weekdays', - time: DEFAULT_TIME, + time: AUTOMATION_DEFAULT_TIME, dayOfWeek: '1', customSchedule: hasCustomSchedule ? rawSchedule : '', missedRunGraceMinutes: '720', @@ -2009,7 +1776,9 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - const state = await window.api.ssh.connect({ targetId: manager.target.connectionId }) + const state = await window.api.ssh.connect({ + targetId: manager.target.connectionId + }) if (!state || state.status !== 'connected') { toast.error( state?.error ?? @@ -2107,6 +1876,12 @@ export default function AutomationsPage(): React.JSX.Element { return } + // Why: fields that clear their own value on Escape consume this press; + // blurring here would drop focus and let the next Escape close the page. + if (target.dataset.escapeClearsValue === 'true') { + return + } + // Why: match Tasks page behavior: Esc first exits field focus, then exits // the page once focus is back on page chrome. if ( @@ -2232,8 +2007,10 @@ export default function AutomationsPage(): React.JSX.Element { onSave={() => void saveAutomation()} /> - { if (open) { return @@ -2241,766 +2018,116 @@ export default function AutomationsPage(): React.JSX.Element { setDeleteTarget(null) setDontAskDeleteAgain(false) }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.080dcb5fbb', - 'Delete Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - {deleteTarget?.name}{' '} - {translate( - 'auto.components.automations.AutomationsPage.b264564427', - 'and its run history. Workspaces created by previous runs are not deleted.' - )} - - - {deleteTarget ? ( -
-
{deleteTarget.name}
-
- {deleteTarget.workspaceMode === 'new_per_run' - ? translate( - 'auto.components.automations.AutomationsPage.cd8397cc32', - 'New workspace each run' - ) - : translate( - 'auto.components.automations.AutomationsPage.36f71740a7', - 'Selected workspace' - )} -
-
- ) : null} - - - - - -
-
+ onDontAskAgainToggle={() => setDontAskDeleteAgain((prev) => !prev)} + onCancel={() => { + setDeleteTarget(null) + setDontAskDeleteAgain(false) + }} + onConfirm={() => void confirmDeleteAutomation()} + /> - { if (!open) { setExternalDeleteTarget(null) } }} - > - { - event.preventDefault() - deleteConfirmButtonRef.current?.focus() - }} - > - - - {translate( - 'auto.components.automations.AutomationsPage.9adfab2596', - 'Delete External Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.15e0bfb13b', 'Delete')}{' '} - - {externalDeleteTarget?.job.name} - {' '} - {translate('auto.components.automations.AutomationsPage.02a33e3204', 'from')}{' '} - {externalDeleteTarget - ? getExternalProviderLabel(externalDeleteTarget.manager) - : translate( - 'auto.components.automations.AutomationsPage.8500baacb4', - 'external source' - )}{' '} - {translate('auto.components.automations.AutomationsPage.1b586f0e2b', 'on')} - {externalDeleteTarget?.manager.targetLabel}. - - - {externalDeleteTarget ? ( -
-
- {externalDeleteTarget.job.name} -
-
- { - getExternalAutomationScheduleDisplay( - externalDeleteTarget.manager, - externalDeleteTarget.job - ).label - } -
-
- ) : null} - - - - -
-
+ onCancel={() => setExternalDeleteTarget(null)} + onConfirm={() => void confirmDeleteExternalAutomation()} + />
-
-
- {automations.length + externalAutomationEntries.length > 0 ? ( -
- - {translate( - 'auto.components.automations.AutomationsPage.761a35834d', - 'Automation' - )} - - - {translate('auto.components.automations.AutomationsPage.587a4b205c', 'Next')} - -
- ) : null} - {automations.map((automation) => { - const automationRepo = repoMap.get(getAutomationRunRepoId(automation)) - const automationWorktree = automation.workspaceId - ? worktreeMap.get(automation.workspaceId) - : null - const automationRunAvailability = getAutomationTargetAvailability({ - automation, - repo: automationRepo, - workspace: automationWorktree, - projectHostSetups, - sshConnectionStates, - runtimeStatusByEnvironmentId, - automationHostTarget, - sourceHostAvailability: automationSourceHostAvailabilityById.get(automation.id) - }) - const workspaceLabel = - automation.workspaceMode === 'new_per_run' - ? `Create from ${automation.baseBranch ?? automationRepo?.worktreeBaseRef ?? 'project default'}` - : (automationWorktree?.displayName ?? 'Missing workspace') - const usageSummary = summarizeAutomationRunUsage( - runs.filter((run) => run.automationId === automation.id) - ) - const usageText = - usageSummary.knownRuns > 0 - ? `${formatAutomationCost( - usageSummary.estimatedCostUsd - )} est. · ${formatAutomationTokens(usageSummary.totalTokens)} tokens` - : usageSummary.unavailableRuns > 0 - ? 'Usage unavailable' - : 'No run usage yet' - const nextRunLabel = automation.enabled - ? formatAutomationDateTimeWithRelative(automation.nextRunAt, relativeNow) - : 'Paused' - const scheduleLabel = formatAutomationSchedule(automation.rrule) - return ( - - - - - - { - if (!automationRunAvailability.canRunNow) { - event.preventDefault() - return - } - void runNow(automation) - }} - > - - - {automationRunAvailability.canRunNow - ? translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - ) - : automationRunAvailability.message} - - - void openEditDialog(automation)}> - - {translate('auto.components.automations.AutomationsPage.f4612e3f78', 'Edit')} - - void toggleAutomation(automation)}> - {automation.enabled ? ( - - ) : ( - - )} - {automation.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestDeleteAutomation(automation)} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {externalAutomationEntries.map((entry) => { - const providerLabel = getExternalProviderLabel(entry.manager) - const targetKindLabel = getExternalTargetKindLabel(entry.manager) - if (entry.kind === 'source') { - const sshStatus = - entry.manager.target.type === 'ssh' - ? sshConnectionStates.get(entry.manager.target.connectionId)?.status - : undefined - const sourceAvailability = getExternalAutomationSourceAvailability({ - manager: entry.manager, - providerLabel, - targetKindLabel, - sshStatus - }) - return ( - + void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + requestExternalAction={requestExternalAction} + openEditExternalDialog={openEditExternalDialog} + openCreateDialog={openCreateDialog} + /> + + - - - - - requestExternalAction(entry.manager, entry.job, 'run')} - > - - - {disabledMessage ?? - translate( - 'auto.components.automations.AutomationsPage.2faecab10b', - 'Run Now' - )} - - - {entry.manager.provider === 'hermes' ? ( - openEditExternalDialog(entry.manager, entry.job)} - > - - {translate( - 'auto.components.automations.AutomationsPage.f4612e3f78', - 'Edit' - )} - - ) : null} - - requestExternalAction( - entry.manager, - entry.job, - entry.job.enabled ? 'pause' : 'resume' - ) - } - > - {entry.job.enabled ? ( - - ) : ( - - )} - {entry.job.enabled - ? translate( - 'auto.components.automations.AutomationsPage.b457436d6a', - 'Pause' - ) - : translate( - 'auto.components.automations.AutomationsPage.376631ef2b', - 'Resume' - )} - - - requestExternalAction(entry.manager, entry.job, 'delete')} - > - - {translate( - 'auto.components.automations.AutomationsPage.15e0bfb13b', - 'Delete' - )} - - - - ) - })} - {automations.length === 0 && externalAutomationEntries.length === 0 ? ( -
-
- {translate( - 'auto.components.automations.AutomationsPage.d207ab4c25', - 'Start from a template' - )} -
- {getAutomationTemplates().map((template) => ( - - ))} - -
- ) : null} -
-
- -
- {selectedExternal ? ( -
- {selectedExternalRunPage ? ( - setSelectedExternalRunPage(null)} - > - - - ) : selectedExternal.kind === 'job' ? ( - - ) : ( -
-
-
-
- {selectedExternal.manager.targetLabel} -
-
- {selectedExternalSourceAvailability?.summary} -
-
- {selectedExternalSshSource ? ( - - ) : null} -
-
- {selectedExternalSourceAvailability?.detail} -
-
- )} -
- ) : ( - setActivePaneTab(value as AutomationPaneTab)} - className="min-h-0 flex-1 gap-0" - > -
- - - {translate( - 'auto.components.automations.AutomationsPage.bb1b2cd31e', - 'Overview' - )} - - - {translate('auto.components.automations.AutomationsPage.0e110a3469', 'Runs')}{' '} - {selectedRuns.length} - - -
- - - void runNow(automation)} - onEdit={(automation) => void openEditDialog(automation)} - onToggle={(automation) => void toggleAutomation(automation)} - onDelete={requestDeleteAutomation} - /> - - - - {selectedAutomationRunPage ? ( - - {canRerunSelectedAutomationRunPage && selected ? ( - - ) : null} - {selectedAutomationRunPageViewState ? ( - - ) : null} - - } - onBack={() => setSelectedAutomationRunPageId(null)} - > - - - ) : selected ? ( - - ) : ( -
- {translate( - 'auto.components.automations.AutomationsPage.c3a28c9793', - 'Select an automation to view runs.' - )} -
- )} -
-
- )} -
+ : (selectedWorktree?.displayName ?? + translate( + 'auto.components.automations.AutomationsPage.missingWorkspace', + 'Missing workspace' + )) + } + hostLabelById={hostLabelById} + selectedRunNowAvailability={selectedRunNowAvailability} + selectedExternalSourceAvailability={selectedExternalSourceAvailability} + selectedExternalSshSource={ + selectedExternalSshSource ? { manager: selectedExternalSshSource.manager } : null + } + selectedExternalSshConnected={selectedExternalSshConnected} + selectedAutomationRunPageWorkspaceDisplay={selectedAutomationRunPageWorkspaceDisplay} + selectedAutomationRunPageViewState={selectedAutomationRunPageViewState} + canRerunSelectedAutomationRunPage={canRerunSelectedAutomationRunPage} + isSelectedAutomationRunPageRerunPending={isSelectedAutomationRunPageRerunPending} + worktreeMap={worktreeMap} + fetchExternalAutomationRuns={fetchExternalAutomationRuns} + onActivePaneTabChange={setActivePaneTab} + onClearExternalRunPage={() => setSelectedExternalRunPage(null)} + onClearAutomationRunPage={() => setSelectedAutomationRunPageId(null)} + requestExternalAction={requestExternalAction} + openExternalRunPage={openExternalRunPage} + openEditExternalDialog={openEditExternalDialog} + connectExternalAutomationSource={(manager) => + void connectExternalAutomationSource(manager) + } + runNow={(automation) => void runNow(automation)} + openEditDialog={(automation) => void openEditDialog(automation)} + toggleAutomation={(automation) => void toggleAutomation(automation)} + requestDeleteAutomation={requestDeleteAutomation} + rerunAutomationRun={(automation, run) => void rerunAutomationRun(automation, run)} + openRunWorkspace={openRunWorkspace} + openAutomationRunPage={openAutomationRunPage} + />
) diff --git a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx index bd6e1b67549..f64684f8c29 100644 --- a/src/renderer/src/components/automations/ExternalAutomationManagers.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationManagers.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalProviderLabel, + getExternalTargetKindLabel +} from './external-automation-display' import { ExternalAutomationRunTable, type FetchExternalAutomationRuns @@ -37,17 +41,6 @@ type ExternalAutomationManagersProps = { onEdit?: (manager: ExternalAutomationManager, job: ExternalAutomationJob) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - function actionKey( manager: ExternalAutomationManager, job: ExternalAutomationJob, @@ -56,14 +49,6 @@ function actionKey( return `${manager.id}:${job.id}:${action}` } -function getProviderLabel(manager: ExternalAutomationManager): string { - return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' -} - -function getTargetKindLabel(manager: ExternalAutomationManager): string { - return manager.target.type === 'ssh' ? 'SSH host' : 'Local' -} - function ExternalActionButton({ label, disabled, @@ -140,7 +125,7 @@ export function ExternalAutomationManagers({
{manager.targetLabel}
- {getProviderLabel(manager)} / {getTargetKindLabel(manager)} ·{' '} + {getExternalProviderLabel(manager)} / {getExternalTargetKindLabel(manager)} ·{' '} {manager.status === 'available' ? manager.canManage ? translate( @@ -236,8 +221,8 @@ export function ExternalAutomationManagers({ 'auto.components.automations.ExternalAutomationManagers.20fd7a3a15', 'next' )}{' '} - {formatExternalDate(job.nextRunAt, now)} · {getProviderLabel(manager)} /{' '} - {manager.targetLabel} + {formatExternalDate(job.nextRunAt, now)} ·{' '} + {getExternalProviderLabel(manager)} / {manager.targetLabel}
{manager.provider === 'hermes' ? (
diff --git a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx index f6e5f693371..2c322737cc8 100644 --- a/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx +++ b/src/renderer/src/components/automations/ExternalAutomationRunTable.tsx @@ -10,7 +10,11 @@ import type { ExternalAutomationManager, ExternalAutomationRun } from '../../../../shared/automations-types' -import { formatAutomationDateTimeWithRelative } from './automation-page-parts' +import { + formatExternalDate, + getExternalRunStatusLabel, + getExternalRunStatusVariant +} from './external-automation-display' import { createExternalAutomationRunTableState, resolveExternalAutomationFetchedRuns, @@ -41,41 +45,6 @@ type ExternalAutomationRunTableProps = { onOpenRun?: (run: ExternalAutomationRun) => void } -function formatExternalDate(value: string | null, now: number): string { - if (!value) { - return 'Never' - } - const parsed = Date.parse(value) - if (!Number.isFinite(parsed)) { - return value - } - return formatAutomationDateTimeWithRelative(parsed, now) -} - -function getRunStatusLabel(run: ExternalAutomationRun): string { - switch (run.status) { - case 'completed': - return 'Completed' - case 'failed': - return 'Failed' - case 'unknown': - return 'Unknown' - } -} - -function getRunStatusVariant( - run: ExternalAutomationRun -): React.ComponentProps['variant'] { - switch (run.status) { - case 'completed': - return 'secondary' - case 'failed': - return 'destructive' - case 'unknown': - return 'outline' - } -} - function getRunSummary(run: ExternalAutomationRun): string { return run.error ?? run.outputPreview ?? 'No output preview' } @@ -263,7 +232,9 @@ export function ExternalAutomationRunTable({ {getRunSummary(run)} - {getRunStatusLabel(run)} + + {getExternalRunStatusLabel(run)} + ))}
diff --git a/src/renderer/src/components/automations/automation-draft-model.ts b/src/renderer/src/components/automations/automation-draft-model.ts new file mode 100644 index 00000000000..747bc615782 --- /dev/null +++ b/src/renderer/src/components/automations/automation-draft-model.ts @@ -0,0 +1,52 @@ +import { getAgentCatalog } from '@/lib/agent-catalog' +import type { AutomationPrecheck } from '../../../../shared/automations-types' +import { buildAutomationCronSchedule } from '../../../../shared/automation-schedules' +import type { Worktree } from '../../../../shared/types' +import type { AutomationDraft } from './AutomationEditorDialog' + +export const AUTOMATION_DEFAULT_TIME = '09:00' + +export function getDefaultWorktree(worktrees: readonly Worktree[]): Worktree | null { + return worktrees.find((worktree) => worktree.isMainWorktree) ?? worktrees[0] ?? null +} + +export function formatTimeInput(hour: number, minute: number): string { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}` +} + +export function parseDraftTime(time: string): { hour: number; minute: number } { + const [rawHour, rawMinute] = time.split(':').map((part) => Number(part)) + return { + hour: Number.isFinite(rawHour) ? rawHour : 9, + minute: Number.isFinite(rawMinute) ? rawMinute : 0 + } +} + +export function buildDraftPrecheck(draft: AutomationDraft): AutomationPrecheck | null { + const command = draft.precheckCommand.trim() + if (!command) { + return null + } + const rawTimeout = Number(draft.precheckTimeoutSeconds) + return { + command, + timeoutSeconds: Number.isFinite(rawTimeout) ? rawTimeout : 60 + } +} + +export function buildHermesCronSchedule(draft: AutomationDraft): string { + if (draft.preset === 'custom') { + return draft.customSchedule.trim() + } + const { hour, minute } = parseDraftTime(draft.time) + return buildAutomationCronSchedule({ + preset: draft.preset, + hour, + minute, + dayOfWeek: Number(draft.dayOfWeek) + }) +} + +export function getAgentLabel(agentId: string): string { + return getAgentCatalog().find((agent) => agent.id === agentId)?.label ?? agentId +} diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index b3569b4b7b2..36e1ff0560a 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -26,6 +26,20 @@ export type AutomationHostTarget = | { kind: 'local' } | { kind: 'environment'; environmentId: string } +export function getAutomationHostTargetKey(target: AutomationHostTarget): string { + return target.kind === 'environment' ? `environment:${target.environmentId}` : 'local' +} + +export function getAutomationHostTargetFromKey(key: string | null): AutomationHostTarget | null { + if (!key) { + return null + } + if (key.startsWith('environment:')) { + return { kind: 'environment', environmentId: key.slice('environment:'.length) } + } + return { kind: 'local' } +} + export function getAutomationTargetFromHostId( hostId: string | null | undefined ): AutomationHostTarget { diff --git a/src/renderer/src/components/automations/automation-list-search.test.ts b/src/renderer/src/components/automations/automation-list-search.test.ts new file mode 100644 index 00000000000..47f59faaba6 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES, + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT, + automationListSearchFieldsMatch, + automationListSearchIndexMatches, + buildAutomationListSearchFingerprint, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + clampAutomationListSearchQueryInput, + filterByActiveAutomationListSearchQuery, + filterByAutomationListSearch, + filterByAutomationListSearchIndex, + getActiveAutomationListSearchQuery, + isAutomationListSearchQueryTooLarge, + normalizeAutomationListSearchField, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField +} from './automation-list-search' + +describe('automation-list-search', () => { + it('normalizes query casing and whitespace', () => { + expect(getActiveAutomationListSearchQuery(' Auto PR ')).toBe('auto pr') + expect(resolveAutomationListSearchQuery(' Auto PR ')).toEqual({ + status: 'active', + query: 'auto pr' + }) + }) + + it('rejects oversized queries without searching', () => { + const oversized = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(oversized)).toBe(true) + expect(getActiveAutomationListSearchQuery(oversized)).toBeNull() + expect(resolveAutomationListSearchQuery(oversized)).toEqual({ status: 'too_large' }) + expect( + automationListSearchFieldsMatch( + { name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + oversized + ) + ).toBe(false) + + const items = [ + { id: '1', name: 'Auto PR', project: 'orca', prompt: 'nudge' }, + { id: '2', name: 'Nightly', project: 'mobile', prompt: 'ship' } + ] + // Why: oversized paste must leave the list unfiltered, not blank it. + expect(filterByAutomationListSearch(items, oversized, (item) => item)).toBe(items) + }) + + it('rejects queries over the byte limit but under the code-unit limit', () => { + // 3 UTF-8 bytes per character, so half the cap in code units is over it in bytes. + const multiByte = '한'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES / 2) + expect(multiByte.length).toBeLessThanOrEqual(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES) + expect(isAutomationListSearchQueryTooLarge(multiByte)).toBe(true) + expect(resolveAutomationListSearchQuery(multiByte)).toEqual({ status: 'too_large' }) + }) + + it('treats whitespace-only queries as inactive (no search work)', () => { + expect(getActiveAutomationListSearchQuery(' \t ')).toBeNull() + expect(resolveAutomationListSearchQuery(' ')).toEqual({ status: 'inactive' }) + const items = [{ name: 'A', project: 'p1', prompt: 'one' }] + expect(filterByAutomationListSearch(items, ' ', (item) => item)).toBe(items) + }) + + it('clamps stored query input so multi-MB pastes are discarded', () => { + const hugePaste = 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES * 100) + const clamped = clampAutomationListSearchQueryInput(hugePaste) + expect(clamped.length).toBe(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + expect(isAutomationListSearchQueryTooLarge(clamped)).toBe(true) + expect(clampAutomationListSearchQueryInput('auto pr')).toBe('auto pr') + }) + + it('caps indexed field length so huge prompts stay bounded', () => { + const prompt = `${'x'.repeat(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS)}unique-tail-token` + const index = buildAutomationListSearchIndex({ + name: 'Nightly', + project: 'mobile', + prompt + }) + expect(index.prompt.length).toBe(AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS) + expect(automationListSearchIndexMatches(index, 'unique-tail-token')).toBe(false) + expect(automationListSearchIndexMatches(index, 'xxxx')).toBe(true) + }) + + it('null-safely normalizes missing fields', () => { + expect(normalizeAutomationListSearchField(null, 10)).toBe('') + expect(normalizeAutomationListSearchField(undefined, 10)).toBe('') + expect( + buildAutomationListSearchIndex({ + name: 'Job', + project: 'host', + prompt: null as unknown as string + }).prompt + ).toBe('') + }) + + it('does not split surrogate pairs when truncating', () => { + const emoji = '😀' + const value = `${'a'.repeat(7)}${emoji}` + expect(truncateAutomationListSearchField(value, 8)).toBe('a'.repeat(7)) + expect(truncateAutomationListSearchField(value, 9)).toBe(value) + }) + + it('indexes unknown project fallback for missing repos', () => { + expect(buildAutomationProjectSearchText({})).toBe(AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT) + expect(buildAutomationProjectSearchText({ displayName: ' ', path: null })).toBe( + AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT + ) + expect(buildAutomationProjectSearchText({ displayName: 'orca', path: '/tmp/orca' })).toBe( + 'orca /tmp/orca' + ) + const index = buildAutomationListSearchIndex({ + name: 'Orphan', + project: buildAutomationProjectSearchText({}), + prompt: 'hi' + }) + expect(automationListSearchIndexMatches(index, 'unknown')).toBe(true) + }) + + it('matches name, project, or prompt', () => { + const fields = { + name: 'Auto PR assignment', + project: 'orca / main', + prompt: 'Assign reviewers for open PRs' + } + expect(automationListSearchFieldsMatch(fields, 'assignment')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'ORCA')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'reviewers')).toBe(true) + expect(automationListSearchFieldsMatch(fields, 'missing')).toBe(false) + }) + + it('filters by active query without re-resolving bounds', () => { + const items = [ + { id: '1', name: 'Auto Issue assignment', project: 'orca', prompt: 'triage issues' }, + { id: '2', name: 'Nightly deploy', project: 'mobile', prompt: 'ship apk' }, + { id: '3', name: 'PR nudge', project: 'orca', prompt: 'remind reviewers' } + ] + const indexes = items.map((item) => + buildAutomationListSearchIndex({ + name: item.name, + project: item.project, + prompt: item.prompt + }) + ) + expect( + filterByActiveAutomationListSearchQuery(items, indexes, 'apk').map((item) => item.id) + ).toEqual(['2']) + expect( + filterByAutomationListSearchIndex(items, indexes, 'orca').map((item) => item.id) + ).toEqual(['1', '3']) + expect(filterByAutomationListSearchIndex(items, indexes, ' ')).toBe(items) + expect( + filterByAutomationListSearchIndex( + items, + indexes, + 'a'.repeat(AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES + 1) + ) + ).toBe(items) + // Why: a desynchronized index must leave the list unfiltered, not blank it. + expect( + filterByActiveAutomationListSearchQuery(items, indexes.slice(0, 1), 'apk').map( + (item) => item.id + ) + ).toEqual(['1', '2', '3']) + }) + + it('builds a stable fingerprint from search sources only', () => { + const sources = [ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ] + expect(buildAutomationListSearchFingerprint(sources)).toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'one' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + expect(buildAutomationListSearchFingerprint(sources)).not.toBe( + buildAutomationListSearchFingerprint([ + { name: 'A', project: 'p1', prompt: 'changed' }, + { name: 'B', project: 'p2', prompt: 'two' } + ]) + ) + }) +}) diff --git a/src/renderer/src/components/automations/automation-list-search.ts b/src/renderer/src/components/automations/automation-list-search.ts new file mode 100644 index 00000000000..b27da8fe221 --- /dev/null +++ b/src/renderer/src/components/automations/automation-list-search.ts @@ -0,0 +1,244 @@ +import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' + +/** Pasted queries above this are rejected so filtering never runs on unbounded input. */ +export const AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES = 2 * 1024 + +// Why: prompts can be multi-MB agent instructions. Index only a fixed prefix so +// lowercasing/includes stay O(bound) per automation rather than O(prompt size). +export const AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS = 512 +export const AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS = 1_024 +export const AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS = 8 * 1024 + +/** Indexed when a local automation has no resolved project so "unknown" still matches. */ +export const AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT = 'unknown project' + +export type AutomationListSearchFields = { + name: string + project: string + prompt: string +} + +/** Lowercased, length-capped fields ready for substring match. */ +export type AutomationListSearchIndex = { + name: string + project: string + prompt: string +} + +export type AutomationListSearchQueryResolution = + | { status: 'inactive' } + | { status: 'too_large' } + | { status: 'active'; query: string } + +export function isAutomationListSearchQueryTooLarge( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): boolean { + return isClipboardTextByteLengthOverLimit(rawQuery, maxBytes) +} + +/** + * Caps the controlled input value so a multi-MB paste cannot pin renderer + * memory. Keeping maxBytes+1 code units is enough for the over-limit check + * (`length > maxBytes`) while discarding the rest of the paste. + */ +export function clampAutomationListSearchQueryInput( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string { + const maxStoredCodeUnits = maxBytes + 1 + if (rawQuery.length <= maxStoredCodeUnits) { + return rawQuery + } + return rawQuery.slice(0, maxStoredCodeUnits) +} + +export function resolveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): AutomationListSearchQueryResolution { + // Why: length pre-check short-circuits multi-MB pastes before UTF-8 scan. + if (isClipboardTextByteLengthOverLimit(rawQuery, maxBytes)) { + return { status: 'too_large' } + } + const query = rawQuery.trim().toLowerCase() + if (!query) { + return { status: 'inactive' } + } + return { status: 'active', query } +} + +/** Active lowercase query, or null when search must not run. */ +export function getActiveAutomationListSearchQuery( + rawQuery: string, + maxBytes = AUTOMATION_LIST_SEARCH_QUERY_MAX_BYTES +): string | null { + const resolved = resolveAutomationListSearchQuery(rawQuery, maxBytes) + return resolved.status === 'active' ? resolved.query : null +} + +/** Avoid splitting a surrogate pair at the cap boundary. */ +export function truncateAutomationListSearchField(value: string, maxCodeUnits: number): string { + if (value.length <= maxCodeUnits) { + return value + } + let end = maxCodeUnits + const last = value.charCodeAt(end - 1) + // High surrogate at the cut would leave an orphan low surrogate. + if (last >= 0xd800 && last <= 0xdbff) { + end -= 1 + } + return value.slice(0, end) +} + +export function normalizeAutomationListSearchField( + value: string | null | undefined, + maxCodeUnits: number +): string { + if (value == null || value === '') { + return '' + } + return truncateAutomationListSearchField(value, maxCodeUnits).toLowerCase() +} + +export function buildAutomationListSearchIndex( + fields: AutomationListSearchFields +): AutomationListSearchIndex { + return { + name: normalizeAutomationListSearchField( + fields.name, + AUTOMATION_LIST_SEARCH_NAME_MAX_CODE_UNITS + ), + project: normalizeAutomationListSearchField( + fields.project, + AUTOMATION_LIST_SEARCH_PROJECT_MAX_CODE_UNITS + ), + prompt: normalizeAutomationListSearchField( + fields.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + } +} + +export function buildAutomationProjectSearchText(parts: { + displayName?: string | null + path?: string | null +}): string { + const joined = [parts.displayName, parts.path] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(' ') + return joined || AUTOMATION_LIST_SEARCH_UNKNOWN_PROJECT +} + +export function automationListSearchIndexMatches( + index: AutomationListSearchIndex, + activeQuery: string +): boolean { + // Why: check short fields first so huge-prompt includes rarely run. + return ( + index.name.includes(activeQuery) || + index.project.includes(activeQuery) || + index.prompt.includes(activeQuery) + ) +} + +export function automationListSearchFieldsMatch( + fields: AutomationListSearchFields, + rawQuery: string +): boolean { + const resolved = resolveAutomationListSearchQuery(rawQuery) + if (resolved.status === 'too_large') { + return false + } + if (resolved.status === 'inactive') { + return true + } + return automationListSearchIndexMatches(buildAutomationListSearchIndex(fields), resolved.query) +} + +/** + * Filters with an already-resolved active query. Callers must pass null/skip + * when search is inactive or too large so this never runs "for free". + */ +export function filterByActiveAutomationListSearchQuery( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + activeQuery: string +): T[] { + if (indexes.length !== items.length) { + return [...items] + } + const matches: T[] = [] + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + const index = indexes[i] + if (item !== undefined && index && automationListSearchIndexMatches(index, activeQuery)) { + matches.push(item) + } + } + return matches +} + +/** + * Filters items by a prebuilt index. Empty and oversized queries leave the + * original array reference untouched so the list stays unfiltered and search + * work is skipped entirely. + */ +export function filterByAutomationListSearchIndex( + items: readonly T[], + indexes: readonly AutomationListSearchIndex[], + rawQuery: string +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + return filterByActiveAutomationListSearchQuery(items, indexes, activeQuery) +} + +/** Builds indexes then filters. Prefer prebuilt indexes when filtering often. */ +export function filterByAutomationListSearch( + items: readonly T[], + rawQuery: string, + getFields: (item: T) => AutomationListSearchFields +): readonly T[] { + const activeQuery = getActiveAutomationListSearchQuery(rawQuery) + if (activeQuery === null) { + return items + } + const matches: T[] = [] + for (const item of items) { + if ( + automationListSearchIndexMatches(buildAutomationListSearchIndex(getFields(item)), activeQuery) + ) { + matches.push(item) + } + } + return matches +} + +/** + * Content fingerprint for search-relevant fields only. Used so list refresh + * ticks that replace arrays with equivalent search content do not rebuild + * indexes or re-run filtering. + */ +export function buildAutomationListSearchFingerprint( + sources: readonly AutomationListSearchFields[] +): string { + if (sources.length === 0) { + return '' + } + let fingerprint = '' + for (let i = 0; i < sources.length; i += 1) { + if (i > 0) { + fingerprint += '\u0000' + } + const source = sources[i] + if (!source) { + continue + } + fingerprint += `${source.name}\u0001${source.project}\u0001${source.prompt}` + } + return fingerprint +} diff --git a/src/renderer/src/components/automations/automation-page-state.ts b/src/renderer/src/components/automations/automation-page-state.ts new file mode 100644 index 00000000000..b4eb2a31cc0 --- /dev/null +++ b/src/renderer/src/components/automations/automation-page-state.ts @@ -0,0 +1,15 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' + +/** Detail-pane tab shared by the page, its list panel, and the detail pane. */ +export type AutomationPaneTab = 'overview' | 'runs' + +/** External run opened as a full page inside the detail pane. */ +export type SelectedExternalRunPage = { + manager: ExternalAutomationManager + job: ExternalAutomationJob + run: ExternalAutomationRun +} diff --git a/src/renderer/src/components/automations/automation-run-content.ts b/src/renderer/src/components/automations/automation-run-content.ts new file mode 100644 index 00000000000..5ea2eddabb4 --- /dev/null +++ b/src/renderer/src/components/automations/automation-run-content.ts @@ -0,0 +1,17 @@ +import type { AutomationRun } from '../../../../shared/automations-types' + +export function getAutomationRunContent(run: AutomationRun): string { + const savedOutput = run.outputSnapshot?.content.trim() + if (savedOutput) { + return run.outputSnapshot?.content ?? savedOutput + } + if (run.precheckResult) { + const output = [run.precheckResult.stderr.trim(), run.precheckResult.stdout.trim()] + .filter(Boolean) + .join('\n\n') + if (output) { + return output + } + } + return run.error ?? run.usage?.unavailableMessage ?? 'No output content available.' +} diff --git a/src/renderer/src/components/automations/automation-run-output-snapshot.ts b/src/renderer/src/components/automations/automation-run-output-snapshot.ts index a7b9151fbfc..91c9e88c592 100644 --- a/src/renderer/src/components/automations/automation-run-output-snapshot.ts +++ b/src/renderer/src/components/automations/automation-run-output-snapshot.ts @@ -1,17 +1,11 @@ -/* eslint-disable no-control-regex -- terminal snapshots normalize ANSI/control output. */ import type { AutomationRunOutputSnapshot } from '../../../../shared/automations-types' +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../../../shared/ansi-escape-sequences' const MAX_OUTPUT_SNAPSHOT_CHARS = 256 * 1024 -// Why: Codex/Claude TUIs emit OSC title/progress frames in hidden automation -// PTYs; saved run output should keep command text, not terminal metadata. -const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g -const STRING_SEQUENCE_PATTERN = - /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g -const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g -const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g -const CONTROL_PATTERN = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g - export type AutomationRunOutputSnapshotBuffer = { append: (chunk: string) => void snapshot: () => AutomationRunOutputSnapshot | null @@ -43,14 +37,10 @@ export function selectAutomationRunOutputSnapshot( } function stripTerminalControls(value: string): string { - return value - .replace(OSC_SEQUENCE_PATTERN, '') - .replace(STRING_SEQUENCE_PATTERN, '') - .replace(CSI_SEQUENCE_PATTERN, '') - .replace(ESCAPE_SEQUENCE_PATTERN, '') + return stripAnsiEscapeSequences(value) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') - .replace(CONTROL_PATTERN, '') + .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') } export function createAutomationRunOutputSnapshotBuffer(): AutomationRunOutputSnapshotBuffer { diff --git a/src/renderer/src/components/automations/automation-run-view-state.ts b/src/renderer/src/components/automations/automation-run-view-state.ts index dd300871ac7..56f55dec088 100644 --- a/src/renderer/src/components/automations/automation-run-view-state.ts +++ b/src/renderer/src/components/automations/automation-run-view-state.ts @@ -21,6 +21,16 @@ export function getAutomationRerunPendingRemainingMs({ return Math.max(0, pendingStartedAt + AUTOMATION_RERUN_PENDING_MIN_VISIBLE_MS - now) } +export async function waitForAutomationRerunPendingVisibility( + pendingStartedAt: number +): Promise { + const remainingMs = getAutomationRerunPendingRemainingMs({ pendingStartedAt }) + if (remainingMs <= 0) { + return + } + await new Promise((resolve) => window.setTimeout(resolve, remainingMs)) +} + export function canRerunAutomationRun({ automation, run diff --git a/src/renderer/src/components/automations/automation-source-context.ts b/src/renderer/src/components/automations/automation-source-context.ts new file mode 100644 index 00000000000..e8a05ebabc5 --- /dev/null +++ b/src/renderer/src/components/automations/automation-source-context.ts @@ -0,0 +1,56 @@ +import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { parseExecutionHostId } from '../../../../shared/execution-host' +import type { Automation } from '../../../../shared/automations-types' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { TaskSourceContext } from '../../../../shared/task-source-context' +import type { TaskSourceHostAvailability } from '../task-source-context-summary' + +export type RepoBackedAutomationSourceContext = TaskSourceContext & { + provider: 'github' | 'gitlab' +} + +export function getRepoBackedAutomationSourceContext( + automation: Automation +): RepoBackedAutomationSourceContext | null { + const context = automation.sourceContext + return context?.provider === 'github' || context?.provider === 'gitlab' + ? (context as RepoBackedAutomationSourceContext) + : null +} + +export function getRuntimeSourceHostAvailability( + context: TaskSourceContext, + runtimeStatusByEnvironmentId: ReadonlyMap< + string, + { status: RuntimeStatus | null; checkedAt: number } + > +): TaskSourceHostAvailability | null { + const parsed = parseExecutionHostId(context.hostId) + if (parsed?.kind !== 'runtime') { + return null + } + const entry = runtimeStatusByEnvironmentId.get(parsed.environmentId) + if (!entry) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!entry.status) { + return { hostId: context.hostId, health: 'disconnected' } + } + if (entry.status.graphStatus !== 'ready') { + return { hostId: context.hostId, health: 'connecting' } + } + const capabilities = entry.status.capabilities + if (!capabilities) { + return { + hostId: context.hostId, + reason: 'checking-task-source-capability' + } + } + if (!capabilities.includes(TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY)) { + return { hostId: context.hostId, reason: 'missing-task-source-capability' } + } + return null +} diff --git a/src/renderer/src/components/automations/external-automation-display.ts b/src/renderer/src/components/automations/external-automation-display.ts new file mode 100644 index 00000000000..ee8e9d19d68 --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-display.ts @@ -0,0 +1,71 @@ +import React from 'react' +import type { Badge } from '@/components/ui/badge' +import type { + ExternalAutomationJob, + ExternalAutomationManager, + ExternalAutomationRun +} from '../../../../shared/automations-types' +import { formatAutomationDateTimeWithRelative } from './automation-page-parts' + +export function getExternalAutomationKey( + manager: ExternalAutomationManager, + job: ExternalAutomationJob +): string { + return `${manager.id}:${job.id}` +} + +export function getExternalAutomationSourceKey(manager: ExternalAutomationManager): string { + return `${manager.id}:source` +} + +export function formatExternalDate(value: string | null, now: number): string { + if (!value) { + return 'Never' + } + const parsed = Date.parse(value) + if (!Number.isFinite(parsed)) { + return value + } + return formatAutomationDateTimeWithRelative(parsed, now) +} + +export function getExternalProviderLabel(manager: ExternalAutomationManager): string { + return manager.provider === 'hermes' ? 'Hermes' : 'OpenClaw' +} + +export function getExternalTargetKindLabel(manager: ExternalAutomationManager): string { + return manager.target.type === 'ssh' ? 'SSH host' : 'Local' +} + +export function getExternalRunStatusLabel(run: ExternalAutomationRun): string { + switch (run.status) { + case 'completed': + return 'Completed' + case 'failed': + return 'Failed' + case 'unknown': + return 'Unknown' + } +} + +export function getExternalRunStatusVariant( + run: ExternalAutomationRun +): React.ComponentProps['variant'] { + switch (run.status) { + case 'completed': + return 'secondary' + case 'failed': + return 'destructive' + case 'unknown': + return 'outline' + } +} + +export function getExternalRunContent(run: ExternalAutomationRun): string { + return run.outputContent ?? run.error ?? run.outputPreview ?? 'No output content available.' +} + +export function isMissingExternalRunsApiError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + return /listExternalRuns|automations:listExternalRuns|No handler registered/i.test(message) +} diff --git a/src/renderer/src/components/automations/external-automation-list-entries.ts b/src/renderer/src/components/automations/external-automation-list-entries.ts new file mode 100644 index 00000000000..01b3a84720a --- /dev/null +++ b/src/renderer/src/components/automations/external-automation-list-entries.ts @@ -0,0 +1,46 @@ +import type { + ExternalAutomationJob, + ExternalAutomationManager +} from '../../../../shared/automations-types' +import { + getExternalAutomationKey, + getExternalAutomationSourceKey +} from './external-automation-display' + +export type ExternalAutomationListEntry = + | { + kind: 'job' + key: string + manager: ExternalAutomationManager + job: ExternalAutomationJob + } + | { + kind: 'source' + key: string + manager: ExternalAutomationManager + } + +export function buildExternalAutomationListEntries( + managers: readonly ExternalAutomationManager[] +): ExternalAutomationListEntry[] { + return managers.flatMap((manager): ExternalAutomationListEntry[] => { + if (manager.jobs.length === 0) { + if (manager.provider === 'hermes' && (manager.status === 'unavailable' || manager.error)) { + return [ + { + kind: 'source' as const, + key: getExternalAutomationSourceKey(manager), + manager + } + ] + } + return [] + } + return manager.jobs.map((job) => ({ + kind: 'job' as const, + key: getExternalAutomationKey(manager, job), + manager, + job + })) + }) +} diff --git a/src/renderer/src/components/automations/external-automation-source-availability.ts b/src/renderer/src/components/automations/external-automation-source-availability.ts index 92f0749f892..acb51056e73 100644 --- a/src/renderer/src/components/automations/external-automation-source-availability.ts +++ b/src/renderer/src/components/automations/external-automation-source-availability.ts @@ -3,6 +3,7 @@ import type { ExternalAutomationProvider } from '../../../../shared/automations-types' import type { SshConnectionStatus } from '../../../../shared/ssh-types' +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' export type ExternalAutomationSourceAvailability = { statusLabel: string @@ -75,7 +76,7 @@ export function getExternalAutomationSourceAvailability({ } export function isSshConnectionBusy(status: SshConnectionStatus | undefined): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return isConnectingSshStatus(status) } export function getExternalAutomationActionDisabledMessage(args: { diff --git a/src/renderer/src/components/automations/use-automation-list-search.ts b/src/renderer/src/components/automations/use-automation-list-search.ts new file mode 100644 index 00000000000..ce16d98c06a --- /dev/null +++ b/src/renderer/src/components/automations/use-automation-list-search.ts @@ -0,0 +1,265 @@ +import { useDeferredValue, useEffect, useMemo } from 'react' +import type { Automation } from '../../../../shared/automations-types' +import { getAutomationRunRepoId } from '../../../../shared/automation-run-identity' +import type { Repo } from '../../../../shared/types' +import { + automationListSearchIndexMatches, + buildAutomationListSearchIndex, + buildAutomationProjectSearchText, + resolveAutomationListSearchQuery, + truncateAutomationListSearchField, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS, + type AutomationListSearchFields, + type AutomationListSearchIndex +} from './automation-list-search' +import type { ExternalAutomationListEntry } from './external-automation-list-entries' +import { getExternalProviderLabel } from './external-automation-display' + +export function useAutomationListSearch({ + listSearchQuery, + automations, + externalAutomationEntries, + repoMap, + selectedId, + selectedExternalKey, + selectAutomationId, + selectExternalKey +}: { + listSearchQuery: string + automations: readonly Automation[] + externalAutomationEntries: readonly ExternalAutomationListEntry[] + repoMap: ReadonlyMap + selectedId: string | null + selectedExternalKey: string | null + selectAutomationId: (automationId: string | null) => void + selectExternalKey: (externalKey: string | null) => void +}): { + isListSearchQueryTooLarge: boolean + isListSearchActive: boolean + filteredAutomations: readonly Automation[] + filteredExternalAutomationEntries: readonly ExternalAutomationListEntry[] + hasListItems: boolean + hasFilteredListItems: boolean +} { + // Why: keep the input snappy; matching is deferred so caret never waits on + // index scans. Only the normalized active query can fire a search. + const deferredListSearchQuery = useDeferredValue(listSearchQuery) + const liveListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(listSearchQuery), + [listSearchQuery] + ) + const deferredListSearchResolution = useMemo( + () => resolveAutomationListSearchQuery(deferredListSearchQuery), + [deferredListSearchQuery] + ) + // Why: field feedback tracks the live value so a huge paste is labeled + // immediately; list filtering stays on the deferred resolution. + const isListSearchQueryTooLarge = liveListSearchResolution.status === 'too_large' + // Why: null means search must not run (empty, whitespace, or too large). + const activeListSearchQuery = + deferredListSearchResolution.status === 'active' ? deferredListSearchResolution.query : null + const isListSearchActive = activeListSearchQuery !== null + + // Why: fingerprint includes id + search fields so refresh ticks that only + // change nextRunAt / usage do not rebuild indexes or re-run matching. Prompts + // are truncated to the indexed prefix so each tick stays O(bound) per row. + const automationSearchFingerprint = useMemo( + () => + automations + .map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + const project = buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }) + const prompt = truncateAutomationListSearchField( + automation.prompt, + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${automation.id}\u0001${automation.name}\u0001${project}\u0001${prompt}` + }) + .join('\u0000'), + [automations, repoMap] + ) + const automationSearchRows = useMemo((): { + id: string + index: AutomationListSearchIndex + }[] => { + return automations.map((automation) => { + const repo = repoMap.get(getAutomationRunRepoId(automation)) + return { + id: automation.id, + index: buildAutomationListSearchIndex({ + name: automation.name, + project: buildAutomationProjectSearchText({ + displayName: repo?.displayName, + path: repo?.path + }), + prompt: automation.prompt + }) + } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [automationSearchFingerprint]) + + const externalAutomationSearchFingerprint = useMemo( + () => + externalAutomationEntries + .map((entry) => { + if (entry.kind === 'source') { + return `${entry.key}\u0001${entry.manager.targetLabel}\u0001${getExternalProviderLabel(entry.manager)}\u0001` + } + const prompt = truncateAutomationListSearchField( + entry.job.prompt ?? entry.job.promptPreview ?? '', + AUTOMATION_LIST_SEARCH_PROMPT_MAX_CODE_UNITS + ) + return `${entry.key}\u0001${entry.job.name}\u0001${getExternalProviderLabel(entry.manager)}\u0001${entry.manager.targetLabel}\u0001${entry.job.workdir ?? ''}\u0001${prompt}` + }) + .join('\u0000'), + [externalAutomationEntries] + ) + const externalAutomationSearchRows = useMemo((): { + key: string + index: AutomationListSearchIndex + }[] => { + return externalAutomationEntries.map((entry) => { + const fields: AutomationListSearchFields = + entry.kind === 'source' + ? { + name: entry.manager.targetLabel, + project: `${getExternalProviderLabel(entry.manager)} ${entry.manager.targetLabel}`, + prompt: '' + } + : { + name: entry.job.name, + project: [ + getExternalProviderLabel(entry.manager), + entry.manager.targetLabel, + entry.job.workdir + ] + .filter(Boolean) + .join(' '), + prompt: entry.job.prompt ?? entry.job.promptPreview ?? '' + } + return { key: entry.key, index: buildAutomationListSearchIndex(fields) } + }) + // eslint-disable-next-line react-hooks/exhaustive-deps -- fingerprint is the rebuild gate + }, [externalAutomationSearchFingerprint]) + + // Why: matching runs only when the normalized query or search content changes — + // never on relativeNow / nextRunAt / usage refresh alone. + const filteredAutomationIds = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const ids: string[] = [] + for (const row of automationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + ids.push(row.id) + } + } + return ids + }, [activeListSearchQuery, automationSearchRows]) + + const filteredExternalAutomationKeys = useMemo((): readonly string[] | null => { + if (activeListSearchQuery === null) { + return null + } + const keys: string[] = [] + for (const row of externalAutomationSearchRows) { + if (automationListSearchIndexMatches(row.index, activeListSearchQuery)) { + keys.push(row.key) + } + } + return keys + }, [activeListSearchQuery, externalAutomationSearchRows]) + + const filteredAutomations = useMemo((): readonly Automation[] => { + if (filteredAutomationIds === null) { + return automations + } + if (filteredAutomationIds.length === 0) { + return [] + } + const byId = new Map(automations.map((automation) => [automation.id, automation])) + const next: Automation[] = [] + for (const id of filteredAutomationIds) { + const automation = byId.get(id) + if (automation) { + next.push(automation) + } + } + return next + }, [automations, filteredAutomationIds]) + + const filteredExternalAutomationEntries = useMemo((): readonly ExternalAutomationListEntry[] => { + if (filteredExternalAutomationKeys === null) { + return externalAutomationEntries + } + if (filteredExternalAutomationKeys.length === 0) { + return [] + } + const byKey = new Map(externalAutomationEntries.map((entry) => [entry.key, entry])) + const next: ExternalAutomationListEntry[] = [] + for (const key of filteredExternalAutomationKeys) { + const entry = byKey.get(key) + if (entry) { + next.push(entry) + } + } + return next + }, [externalAutomationEntries, filteredExternalAutomationKeys]) + + const hasListItems = automations.length + externalAutomationEntries.length > 0 + const hasFilteredListItems = + filteredAutomations.length + filteredExternalAutomationEntries.length > 0 + + // Why: when search hides the current row, move selection to the first visible + // match so list highlight and detail stay aligned. No matches → keep detail. + useEffect(() => { + if (activeListSearchQuery === null) { + return + } + const localVisible = + selectedExternalKey === null && + selectedId != null && + filteredAutomations.some((automation) => automation.id === selectedId) + const externalVisible = + selectedExternalKey != null && + filteredExternalAutomationEntries.some((entry) => entry.key === selectedExternalKey) + if (localVisible || externalVisible) { + return + } + const firstLocal = filteredAutomations[0] + if (firstLocal) { + if (selectedExternalKey !== null) { + selectExternalKey(null) + } + if (selectedId !== firstLocal.id) { + selectAutomationId(firstLocal.id) + } + return + } + const firstExternal = filteredExternalAutomationEntries[0] + if (firstExternal && selectedExternalKey !== firstExternal.key) { + selectExternalKey(firstExternal.key) + } + }, [ + activeListSearchQuery, + filteredAutomations, + filteredExternalAutomationEntries, + selectAutomationId, + selectExternalKey, + selectedExternalKey, + selectedId + ]) + + return { + isListSearchQueryTooLarge, + isListSearchActive, + filteredAutomations, + filteredExternalAutomationEntries, + hasListItems, + hasFilteredListItems + } +} diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts index dd15257ac87..14f4d8c9976 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.test.ts @@ -19,8 +19,11 @@ describe('browser automation visibility leases', () => { const { acquireBrowserAutomationVisibility, isBrowserAutomationVisible, + onBrowserAutomationVisibilityChange, releaseBrowserAutomationVisibility } = await import('./browser-automation-visibility') + const listener = vi.fn() + const unsubscribe = onBrowserAutomationVisibilityChange(listener) const first = acquireBrowserAutomationVisibility('page-1') const second = acquireBrowserAutomationVisibility('page-1') @@ -32,6 +35,11 @@ describe('browser automation visibility leases', () => { expect(releaseBrowserAutomationVisibility(second)).toBe(true) expect(isBrowserAutomationVisible('page-1')).toBe(false) + expect(listener).toHaveBeenCalledTimes(4) + + unsubscribe() + acquireBrowserAutomationVisibility('page-2') + expect(listener).toHaveBeenCalledTimes(4) }) it('installs a main-process bridge that keeps the page visible while waiting for paint', async () => { diff --git a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts index 6b092f2c950..f810d22f024 100644 --- a/src/renderer/src/components/browser-pane/browser-automation-visibility.ts +++ b/src/renderer/src/components/browser-pane/browser-automation-visibility.ts @@ -34,6 +34,10 @@ function subscribe(listener: () => void): () => void { } } +export function onBrowserAutomationVisibilityChange(listener: () => void): () => void { + return subscribe(listener) +} + function getSnapshot(): number { return version } diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts index d428f20b409..cfd9c3f2e0c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.test.ts @@ -74,21 +74,26 @@ describe('browser page download activity', () => { it('deactivates an interrupted download (no finished event ever fires) and reactivates on resume', async () => { const { hasActiveBrowserPageDownload, installBrowserPageDownloadActivityTracking } = await import('./browser-page-download-activity') - installBrowserPageDownloadActivityTracking() + const onEvictionVetoChange = vi.fn() + installBrowserPageDownloadActivityTracking(onEvictionVetoChange) requestedCallbacks[0]({ downloadId: 'dl-1', browserPageId: 'page-1' }) progressCallbacks[0]({ downloadId: 'dl-1', state: 'interrupted' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(2) progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) - // A null state is transport noise, not a transition. + // Duplicate progress and null transport noise are not transitions. + progressCallbacks[0]({ downloadId: 'dl-1', state: 'progressing' }) progressCallbacks[0]({ downloadId: 'dl-1', state: null }) expect(hasActiveBrowserPageDownload('page-1')).toBe(true) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(3) finishedCallbacks[0]({ downloadId: 'dl-1' }) expect(hasActiveBrowserPageDownload('page-1')).toBe(false) + expect(onEvictionVetoChange).toHaveBeenCalledTimes(4) }) it('ignores duplicate start events, unknown progress and unknown finish events', async () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts index 0b3e1af2895..4c2b270ed8c 100644 --- a/src/renderer/src/components/browser-pane/browser-page-download-activity.ts +++ b/src/renderer/src/components/browser-pane/browser-page-download-activity.ts @@ -12,7 +12,11 @@ export function hasActiveBrowserPageDownload(browserPageId: string): boolean { return (activeDownloadCountByPageId.get(browserPageId) ?? 0) > 0 } -function setDownloadActive(download: TrackedDownload, active: boolean): void { +function setDownloadActive( + download: TrackedDownload, + active: boolean, + onEvictionVetoChange: () => void +): void { if (download.active === active) { return } @@ -23,50 +27,61 @@ function setDownloadActive(download: TrackedDownload, active: boolean): void { } else { activeDownloadCountByPageId.set(download.browserPageId, count) } + onEvictionVetoChange() } -function trackDownloadStarted(downloadId: string, browserPageId: string): void { +function trackDownloadStarted( + downloadId: string, + browserPageId: string, + onEvictionVetoChange: () => void +): void { if (trackedDownloadsById.has(downloadId)) { return } const download: TrackedDownload = { browserPageId, active: false } trackedDownloadsById.set(downloadId, download) - setDownloadActive(download, true) + setDownloadActive(download, true, onEvictionVetoChange) } // Why: an interrupted-resumable download never fires Chromium's 'done' (no // finished event), and Orca has no resume path — without this transition the // veto would outlive the download for the whole session. A resumed download // re-actives on its next 'progressing' tick, restoring the veto. -function trackDownloadProgress(downloadId: string, state: 'progressing' | 'interrupted' | null): void { +function trackDownloadProgress( + downloadId: string, + state: 'progressing' | 'interrupted' | null, + onEvictionVetoChange: () => void +): void { const download = trackedDownloadsById.get(downloadId) if (!download || state === null) { return } - setDownloadActive(download, state === 'progressing') + setDownloadActive(download, state === 'progressing', onEvictionVetoChange) } -function trackDownloadFinished(downloadId: string): void { +function trackDownloadFinished(downloadId: string, onEvictionVetoChange: () => void): void { const download = trackedDownloadsById.get(downloadId) if (!download) { return } - setDownloadActive(download, false) + setDownloadActive(download, false, onEvictionVetoChange) trackedDownloadsById.delete(downloadId) } /** App-lifetime tracking; install once from the surface host (Terminal). The * cleanup clears tracked state — a host unmount tears down every guest, so * stale entries must not veto eviction after a remount. */ -export function installBrowserPageDownloadActivityTracking(): () => void { +export function installBrowserPageDownloadActivityTracking( + onEvictionVetoChange: () => void = () => {} +): () => void { const removeRequested = window.api.browser.onDownloadRequested((event) => { - trackDownloadStarted(event.downloadId, event.browserPageId) + trackDownloadStarted(event.downloadId, event.browserPageId, onEvictionVetoChange) }) const removeProgress = window.api.browser.onDownloadProgress((event) => { - trackDownloadProgress(event.downloadId, event.state) + trackDownloadProgress(event.downloadId, event.state, onEvictionVetoChange) }) const removeFinished = window.api.browser.onDownloadFinished((event) => { - trackDownloadFinished(event.downloadId) + trackDownloadFinished(event.downloadId, onEvictionVetoChange) }) return () => { removeRequested() diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts index 8ea69a07c8c..d6def5c80e5 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.test.ts @@ -121,6 +121,27 @@ describe('syncBrowserPageChromeInset', () => { const viewport = ensureBrowserPageViewport('page-1', 'workspace-1')! expect(viewport.chromeInset.style.height).toBe('48px') }) + + it('restores the inset when guest recovery rebuilds the shell', () => { + mountSlotViewport('workspace-1') + ensureBrowserPageViewport('page-1', 'workspace-1') + syncBrowserPageChromeInset('page-1', 48) + // Guest replacement tears the shell down; the recovery re-render rebuilds it without re-measuring the chrome. + removeBrowserPageViewport('page-1') + + const rebuilt = ensureBrowserPageViewport('page-1', 'workspace-1')! + + expect(rebuilt.chromeInset.style.height).toBe('48px') + }) + + it('applies an inset measured before the shell existed', () => { + syncBrowserPageChromeInset('page-2', 40) + mountSlotViewport('workspace-1') + + const viewport = ensureBrowserPageViewport('page-2', 'workspace-1')! + + expect(viewport.chromeInset.style.height).toBe('40px') + }) }) describe('applyBrowserPageViewportLayout', () => { diff --git a/src/renderer/src/components/browser-pane/browser-page-viewport.ts b/src/renderer/src/components/browser-pane/browser-page-viewport.ts index d34fbf744f4..e4a2441c755 100644 --- a/src/renderer/src/components/browser-pane/browser-page-viewport.ts +++ b/src/renderer/src/components/browser-pane/browser-page-viewport.ts @@ -14,6 +14,11 @@ type BrowserPageViewport = { const browserPageViewports = new Map() +// Why: React measures the chrome only on mount, but shells are rebuilt without a +// re-measure (guest recovery/replacement, profile switch, slot remount). Remembering +// the inset keeps geometry a property of attaching a guest, not of the first mount. +const browserPageChromeInsetHeights = new Map() + const slotRootListeners = new Map void>>() function notifySlotRootListeners(workspaceTabId: string): void { @@ -88,6 +93,10 @@ export function ensureBrowserPageViewport( const chromeInset = document.createElement('div') chromeInset.dataset.browserPageChromeInset = '' chromeInset.className = 'shrink-0' + const rememberedInsetHeight = browserPageChromeInsetHeights.get(browserPageId) + if (rememberedInsetHeight !== undefined) { + chromeInset.style.height = `${rememberedInsetHeight}px` + } const container = document.createElement('div') container.dataset.browserPageContainer = '' @@ -141,11 +150,13 @@ export function applyBrowserPageViewportLayout( } export function syncBrowserPageChromeInset(browserPageId: string, heightPx: number): void { + const insetHeight = Math.max(0, heightPx) + browserPageChromeInsetHeights.set(browserPageId, insetHeight) const viewport = browserPageViewports.get(browserPageId) if (!viewport) { return } - viewport.chromeInset.style.height = `${Math.max(0, heightPx)}px` + viewport.chromeInset.style.height = `${insetHeight}px` } export function parkBrowserPageViewport(browserPageId: string): void { diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx new file mode 100644 index 00000000000..b9923d0fc11 --- /dev/null +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.localization.test.tsx @@ -0,0 +1,56 @@ +// @vitest-environment happy-dom + +import type { ReactElement, RefObject } from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setRendererUiLanguage } from '@/i18n/i18n' +import { + ContextualTourOverlaySurface, + handleContextualTourOverlayKeyDown, + type ActiveTourRenderState +} from './ContextualTourOverlaySurface' + +afterEach(async () => { + await setRendererUiLanguage('en') +}) + +function renderSurface(isLastStep: boolean): ReactElement { + const panelRef: RefObject = { current: null } + const renderState: ActiveTourRenderState = { + rect: new DOMRect(0, 0, 20, 20), + targetElement: document.createElement('button'), + progress: { current: isLastStep ? 2 : 1, total: 2 }, + title: 'Tour title', + body: 'Tour body', + isLastStep, + isFirstStep: !isLastStep, + panelHost: null + } + return ( + + ) +} + +describe('ContextualTourOverlaySurface localization', () => { + it('renders default tour actions in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + + const firstStep = renderToStaticMarkup(renderSurface(false)) + const finalStep = renderToStaticMarkup(renderSurface(true)) + + expect(firstStep).toContain('다음') + expect(firstStep).not.toContain('>Next<') + expect(finalStep).toContain('완료') + expect(finalStep).not.toContain('>Done<') + }) +}) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx index a692a6cdaf1..5b6284ae648 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx +++ b/src/renderer/src/components/contextual-tours/ContextualTourOverlaySurface.tsx @@ -107,7 +107,12 @@ export function ContextualTourOverlaySurface({ const stepKey = `${activeTourId}-${renderState.progress.current}` const defaultPrimaryAction = { kind: renderState.isLastStep ? 'complete' : 'next', - label: renderState.isLastStep ? 'Done' : 'Next' + label: renderState.isLastStep + ? translate('auto.components.contextual.tours.ContextualTourOverlaySurface.complete', 'Done') + : translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.38b3155418', + 'Next' + ) } satisfies ContextualTourStepAction const primaryAction = renderState.primaryAction ?? (renderState.hidePrimaryAction ? null : defaultPrimaryAction) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts index d83fafd3fc1..4b2ce9631ca 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts @@ -1,12 +1,104 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' import { getContextualTour } from '../../../../shared/contextual-tours' +import { setRendererUiLanguage } from '@/i18n/i18n' import { getContextualTourDisplayProgress, getContextualTourMeasurementAction, + measureContextualTourOverlayRenderState, isContextualTourLastDisplayStep } from './contextual-tour-overlay-measurement' +afterEach(async () => { + document.body.replaceChildren() + await setRendererUiLanguage('en') +}) + describe('contextual tour overlay measurement', () => { + it('renders the automation tour copy in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 0, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + expect(result.renderState.body).toBe( + '자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요.' + ) + }) + + it('renders the automation results step in Korean when the UI locale is Korean', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('div') + target.setAttribute('data-contextual-tour-target', 'automations-runs') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const result = measureContextualTourOverlayRenderState({ + tour: getContextualTour('automations'), + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('결과 확인') + expect(result.renderState.body).toBe( + '실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다.' + ) + }) + + it('keeps localized copy on its own step when a step is inserted before it', async () => { + await setRendererUiLanguage('ko') + const target = document.createElement('button') + target.setAttribute('data-contextual-tour-target', 'automations-create') + target.getBoundingClientRect = () => new DOMRect(0, 0, 20, 20) + document.body.appendChild(target) + + const automations = getContextualTour('automations') + const result = measureContextualTourOverlayRenderState({ + tour: { + ...automations, + steps: [ + { + title: 'Inserted step', + body: 'Added ahead of the localized steps.', + targetSelector: '[data-contextual-tour-target="automations-create"]' + }, + ...automations.steps + ] + }, + activeStepIndex: 1, + sidebarOpen: true, + keybindings: undefined, + previousTelemetryTotalSteps: 0 + }) + + expect(result.kind).toBe('render') + if (result.kind !== 'render') { + throw new Error(`Expected render result, received ${result.kind}`) + } + expect(result.renderState.title).toBe('자동화란 무엇인가요?') + }) + it('shows all defined browser steps in progress even when step 3 is hidden', () => { const tour = getContextualTour('browser') diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts index a807ade9fe4..1fe945a42cb 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.ts @@ -28,6 +28,36 @@ export type ContextualTourOverlayMeasurementResult = telemetryTotalSteps: number } +// Why: keyed by the step's stable id, not its position — inserting a step must +// not shift localized copy onto a neighbour. Thunks keep translate() out of +// module scope so the lookup resolves in the language active at render time. +const LOCALIZED_STEP_COPY: Record string; body: () => string }> = { + 'automations-intro': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title', + 'What is an automation?' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body', + 'Automations run agent work on a schedule. Add an automation by clicking this button.' + ) + }, + 'automations-results': { + title: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title', + 'Find the results' + ), + body: () => + translate( + 'auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body', + 'Runs show when automations ran, what happened, and where to inspect their output.' + ) + } +} + export function getContextualTourDisplayProgress(args: { tour: ContextualTour visibleStepIndexes: readonly number[] @@ -89,6 +119,13 @@ export function measureContextualTourOverlayRenderState(args: { ) const activeStep = args.tour.steps[args.activeStepIndex] const target = activeStep ? getMeasurableContextualTourTarget(activeStep.targetSelector) : null + const localizedCopy = activeStep?.id ? LOCALIZED_STEP_COPY[activeStep.id] : undefined + const localizedTitle = localizedCopy ? localizedCopy.title() : activeStep?.title + const localizedBody = localizedCopy + ? localizedCopy.body() + : activeStep + ? getContextualTourStepCopy(activeStep) + : undefined const progress = getContextualTourDisplayProgress({ tour: args.tour, visibleStepIndexes, @@ -135,8 +172,11 @@ export function measureContextualTourOverlayRenderState(args: { rect: target.rect, targetElement: target.element, progress, - title: activeStep.title, - body: formatContextualTourStepCopy(getContextualTourStepCopy(activeStep), args.keybindings), + title: localizedTitle ?? activeStep.title, + body: formatContextualTourStepCopy( + localizedBody ?? getContextualTourStepCopy(activeStep), + args.keybindings + ), control: activeStep.control, primaryAction, secondaryAction, diff --git a/src/renderer/src/components/editor/check-run-details-tab.test.ts b/src/renderer/src/components/editor/check-run-details-tab.test.ts index c6beb882802..5646f523263 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.test.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.test.ts @@ -38,6 +38,18 @@ describe('check-run-details-tab', () => { ).toBe('url:https://github.com/acme/widgets/actions/runs/1') }) + it('keys GitLab jobs by job id so same-named stages do not share one tab', () => { + const base = { status: 'completed', conclusion: 'failure', url: null } as const + + expect(getCheckRunTabIdentity({ ...base, name: 'test: unit', gitlabJobId: 42 })).toBe( + 'gitlab-job:42' + ) + // Why: GitLab reuses `stage: name` across pipeline runs, so the name alone collides. + expect( + buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 42 }) + ).not.toBe(buildCheckRunDetailsTabId('wt-1', { ...base, name: 'test: unit', gitlabJobId: 43 })) + }) + it('uses the check name for the tab label', () => { expect( getCheckRunDetailsTabLabel({ diff --git a/src/renderer/src/components/editor/check-run-details-tab.ts b/src/renderer/src/components/editor/check-run-details-tab.ts index 0acd550f7b2..bbc8fc1180b 100644 --- a/src/renderer/src/components/editor/check-run-details-tab.ts +++ b/src/renderer/src/components/editor/check-run-details-tab.ts @@ -1,3 +1,4 @@ +import type { GitLabProjectRef } from '../../../../shared/gitlab-types' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/types' export type OpenCheckRunDetailsState = { @@ -6,6 +7,20 @@ export type OpenCheckRunDetailsState = { details: PRCheckRunDetails | null loading: boolean error: string | null + /** Why: fork/cross-project MR jobs live outside the repo's own project, so reloads need the pipeline's project. */ + gitlabProjectRef?: GitLabProjectRef | null +} + +export type CheckRunDetailsTabPatch = Pick< + OpenCheckRunDetailsState, + 'details' | 'loading' | 'error' | 'gitlabProjectRef' +> + +export function isSameGitLabProjectRef( + a: GitLabProjectRef | null, + b: GitLabProjectRef | null +): boolean { + return a === b || (a?.host === b?.host && a?.path === b?.path) } export function getCheckRunTabIdentity(check: PRCheckDetail): string { @@ -15,6 +30,11 @@ export function getCheckRunTabIdentity(check: PRCheckDetail): string { if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}` } + // Why: GitLab jobs reuse `stage: name` across pipeline runs and can lack a web_url, + // so the numeric job id is the only stable per-job tab identity. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}` + } if (check.url) { return `url:${check.url}` } diff --git a/src/renderer/src/components/editor/editor-autosave-controller.test.ts b/src/renderer/src/components/editor/editor-autosave-controller.test.ts index 5020bf5e8f8..78e83706e52 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.test.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.test.ts @@ -511,6 +511,53 @@ describe('attachEditorAutosaveController', () => { } }) + it('drops an autosave already queued when owner migration starts', async () => { + let releaseFirstWrite!: () => void + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve + }) + const writeFile = vi.fn().mockReturnValueOnce(firstWrite).mockResolvedValue(undefined) + const eventTarget = new EventTarget() + vi.stubGlobal('window', { + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + dispatchEvent: eventTarget.dispatchEvent.bind(eventTarget), + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + api: { fs: { writeFile } } + } satisfies WindowStub) + + const store = createEditorStore() + store.getState().openFile({ + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }) + const cleanup = attachEditorAutosaveController(store) + try { + store.getState().setEditorDraft('/repo/file.ts', 'first autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + expect(writeFile).toHaveBeenCalledTimes(1) + + store.getState().setEditorDraft('/repo/file.ts', 'queued autosave') + store.getState().markFileDirty('/repo/file.ts', true) + await vi.advanceTimersByTimeAsync(1000) + store.getState().setRestoredEditorOwnerMigrationPending('/repo/file.ts', true) + releaseFirstWrite() + for (let index = 0; index < 6; index += 1) { + await Promise.resolve() + } + + expect(writeFile).toHaveBeenCalledTimes(1) + expect(store.getState().editorDrafts['/repo/file.ts']).toBe('queued autosave') + } finally { + cleanup() + } + }) + it('leaves dirty editor drafts ready for the combined hot-exit checkpoint', async () => { const writeFile = vi.fn().mockResolvedValue(undefined) const setSync = vi.fn() diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 3326d93492d..271786a74c1 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -92,6 +92,13 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { return } + if (liveFile.pendingOwnerMigration === true) { + if (trigger === 'autosave') { + return + } + throw new Error('This file is still restoring its workspace owner. Try saving again.') + } + // Why: only autosave is blocked while suspended; explicit user saves proceed (the banner warned). if (trigger === 'autosave' && isAutosaveSuspendedForFile(liveFile)) { return @@ -341,6 +348,10 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { detail.resolve() return } + if (file.pendingOwnerMigration === true) { + detail.reject('This file is still restoring its workspace owner. Try saving again.') + return + } flushPendingEditorChange(file.id) diff --git a/src/renderer/src/components/editor/editor-autosave.ts b/src/renderer/src/components/editor/editor-autosave.ts index 01ac40151e5..efeda658724 100644 --- a/src/renderer/src/components/editor/editor-autosave.ts +++ b/src/renderer/src/components/editor/editor-autosave.ts @@ -89,13 +89,17 @@ export function canAutoSaveOpenFile(file: OpenFile): boolean { export function isAutosaveSuspendedForFile( file: Pick< OpenFile, - 'externalMutation' | 'pendingDiskBaselineVerification' | 'pendingLiveDiskVerification' + | 'externalMutation' + | 'pendingDiskBaselineVerification' + | 'pendingLiveDiskVerification' + | 'pendingOwnerMigration' > ): boolean { return ( file.externalMutation === 'changed' || file.pendingDiskBaselineVerification === true || - file.pendingLiveDiskVerification === true + file.pendingLiveDiskVerification === true || + file.pendingOwnerMigration === true ) } diff --git a/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts new file mode 100644 index 00000000000..86fbbfa63f1 --- /dev/null +++ b/src/renderer/src/components/editor/migrate-restored-editor-file-owner.ts @@ -0,0 +1,87 @@ +import { useAppStore } from '@/store' +import { + findWorkspaceFileRoute, + type RuntimeWorkspaceFileRoute +} from '@/lib/runtime-workspace-file-route' +import { + assertEditorFileOperationCurrent, + captureEditorFileOperationProvenance, + type EditorFileOperationProvenance +} from '@/lib/editor-file-operation-owner' +import { requestEditorSaveQuiesce } from './editor-autosave' +import type { RestoredEditorOwnerResult } from '@/store/slices/editor' + +export type RestoredEditorOwnerMigrationResult = RestoredEditorOwnerResult + +export async function migrateRestoredEditorFileOwner( + fileId: string, + route: RuntimeWorkspaceFileRoute, + runtimeEnvironmentId: string | null +): Promise { + const state = useAppStore.getState() + const source = state.openFiles.find((file) => file.id === fileId) + const initialRoute = source + ? findWorkspaceFileRoute(state, route.executionHostId, source.filePath) + : null + if (!source || !routesMatch(initialRoute, route)) { + return { ok: false, reason: 'stale' } + } + let targetOperationProvenance: EditorFileOperationProvenance + try { + targetOperationProvenance = captureEditorFileOperationProvenance( + state, + route.worktreeId, + runtimeEnvironmentId, + true + ) + } catch { + return { ok: false, reason: 'owner-changed' } + } + if (!state.setRestoredEditorOwnerMigrationPending(fileId, true)) { + return { ok: false, reason: 'stale' } + } + + try { + await requestEditorSaveQuiesce({ fileId }) + } catch (error) { + useAppStore.getState().setRestoredEditorOwnerMigrationPending(fileId, false) + throw error + } + const currentState = useAppStore.getState() + const currentSource = currentState.openFiles.find((file) => file.id === fileId) + const currentRoute = currentSource + ? findWorkspaceFileRoute(currentState, route.executionHostId, currentSource.filePath) + : null + try { + if ( + currentSource?.filePath !== source.filePath || + !routesMatch(currentRoute, route) || + assertEditorFileOperationCurrent(currentState, route.worktreeId, targetOperationProvenance) + .runtimeEnvironmentId !== runtimeEnvironmentId + ) { + throw new Error('stale route') + } + } catch { + currentState.setRestoredEditorOwnerMigrationPending(fileId, false) + return { ok: false, reason: 'owner-changed' } + } + return currentState.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: route.worktreeId, + targetRelativePath: route.relativePath, + targetExecutionHostId: route.executionHostId, + targetRuntimeEnvironmentId: runtimeEnvironmentId, + targetOperationProvenance + }) +} + +function routesMatch( + current: RuntimeWorkspaceFileRoute | null, + expected: RuntimeWorkspaceFileRoute +): boolean { + return ( + current?.worktreeId === expected.worktreeId && + current.relativePath === expected.relativePath && + current.executionHostId === expected.executionHostId + ) +} diff --git a/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts new file mode 100644 index 00000000000..1d5ccf262b3 --- /dev/null +++ b/src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts @@ -0,0 +1,287 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as RuntimeFileClient from '@/runtime/runtime-file-client' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { attachEditorAutosaveController } from './editor-autosave-controller' +import { requestEditorFileSave } from './editor-autosave' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' + +const mocks = vi.hoisted(() => ({ writeRuntimeFile: vi.fn() })) + +vi.mock('@/runtime/runtime-file-client', async (importOriginal) => ({ + ...(await importOriginal()), + writeRuntimeFile: mocks.writeRuntimeFile +})) + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/file.md' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +function seed(filePath = FILE_PATH): string { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: true, editorAutoSaveDelayMs: 250 }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map() + } as unknown as Partial) + return useAppStore.getState().openFile( + { + filePath, + relativePath: filePath, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) +} + +describe('restored editor owner save lifecycle', () => { + let detach: (() => void) | null = null + + beforeEach(() => { + vi.useFakeTimers() + mocks.writeRuntimeFile.mockReset() + }) + + afterEach(() => { + detach?.() + detach = null + vi.useRealTimers() + }) + + it('drains the old save before reparent, then routes explicit save and autosave to the destination', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise).mockResolvedValue(undefined) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { + worktreeId: TARGET, + relativePath: 'file.md', + executionHostId: 'local' + }, + null + ) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + expect(useAppStore.getState().openFiles[0]?.worktreeId).toBe(SOURCE) + await expect(requestEditorFileSave({ fileId: oldId })).rejects.toThrow( + 'still restoring its workspace owner' + ) + + firstWrite.resolve() + await sourceSave + const migrated = await migration + expect(migrated.ok).toBe(true) + if (!migrated.ok) { + return + } + expect(mocks.writeRuntimeFile.mock.calls[0]?.[0]).toMatchObject({ worktreeId: SOURCE }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'explicit destination save') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await requestEditorFileSave({ fileId: migrated.fileId }) + expect(mocks.writeRuntimeFile.mock.calls[1]?.[0]).toMatchObject({ worktreeId: TARGET }) + + useAppStore.getState().setEditorDraft(migrated.fileId, 'autosave destination') + useAppStore.getState().markFileDirty(migrated.fileId, true) + await vi.advanceTimersByTimeAsync(250) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(3)) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[0]).toMatchObject({ worktreeId: TARGET }) + expect(mocks.writeRuntimeFile.mock.calls[2]?.[2]).toBe('autosave destination') + }) + + it('rejects a concurrent migration without clearing the active migration gate', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const route = { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' as const } + const firstMigration = migrateRestoredEditorFileOwner(oldId, route, null) + await Promise.resolve() + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + await expect(migrateRestoredEditorFileOwner(oldId, route, null)).resolves.toEqual({ + ok: false, + reason: 'stale' + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBe(true) + + firstWrite.resolve() + await sourceSave + await expect(firstMigration).resolves.toMatchObject({ ok: true }) + }) + + it('fails closed when the sibling workspace disappears during save quiescence', async () => { + const oldId = seed() + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + worktreesByRepo: { ...state.worktreesByRepo, 'repo-b': [] } + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when a folder root changes during save quiescence', async () => { + const oldId = seed('/notes/todo.md') + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'notes-group', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'notes-group', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: 'folder:notes', relativePath: 'todo.md', executionHostId: 'local' }, + null + ) + await Promise.resolve() + useAppStore.setState((state) => ({ + folderWorkspaces: state.folderWorkspaces.map((workspace) => ({ + ...workspace, + folderPath: '/renamed-notes' + })) + })) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) + + it('fails closed when direct-SSH authority reconnects during save quiescence', async () => { + const oldId = seed() + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => + repo.id === 'repo-b' + ? { ...repo, connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' } + : repo + ), + worktreesByRepo: { + ...state.worktreesByRepo, + 'repo-b': state.worktreesByRepo['repo-b'].map((worktree) => ({ + ...worktree, + hostId: 'ssh:ssh-1' + })) + } + })) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + const firstWrite = deferred() + mocks.writeRuntimeFile.mockReturnValueOnce(firstWrite.promise) + detach = attachEditorAutosaveController(useAppStore) + useAppStore.getState().setEditorDraft(oldId, 'source save') + useAppStore.getState().markFileDirty(oldId, true) + + const sourceSave = requestEditorFileSave({ fileId: oldId }) + await vi.waitFor(() => expect(mocks.writeRuntimeFile).toHaveBeenCalledTimes(1)) + const migration = migrateRestoredEditorFileOwner( + oldId, + { worktreeId: TARGET, relativePath: 'file.md', executionHostId: 'ssh:ssh-1' }, + null + ) + await Promise.resolve() + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'disconnected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 1 + }) + useAppStore.getState().setSshConnectionState('ssh-1', { + targetId: 'ssh-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + connectionGeneration: 2 + }) + + firstWrite.resolve() + await sourceSave + await expect(migration).resolves.toEqual({ ok: false, reason: 'owner-changed' }) + expect(useAppStore.getState().openFiles[0]).toMatchObject({ + id: oldId, + worktreeId: SOURCE + }) + expect(useAppStore.getState().openFiles[0]?.pendingOwnerMigration).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx index 3920caa89a8..3a5ed862d8e 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx +++ b/src/renderer/src/components/editor/useEditorPanelContentState.test.tsx @@ -41,6 +41,10 @@ vi.mock('@/lib/connection-context', () => ({ isWorktreeConnectionResolved: mocks.isWorktreeConnectionResolved })) +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: vi.fn(() => null) +})) + vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index d7c62b6e6ed..f9cda5dc753 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -9,6 +9,12 @@ import { useAppStore } from '@/store' import { getDiskBaselineSignature } from './diff-content-signature' import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { findWorkspaceFileRoute } from '@/lib/runtime-workspace-file-route' +import { + LOCAL_EXECUTION_HOST_ID, + toRuntimeExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' import { getRuntimeGitBranchDiff, getRuntimeGitCommitDiff, @@ -31,6 +37,7 @@ import { } from './useEditorPanelExternalContentEvents' import { useEditorPanelFileLoadRetry } from './useEditorPanelFileLoadRetry' import { useLocalLogTail } from './useLocalLogTail' +import { migrateRestoredEditorFileOwner } from './migrate-restored-editor-file-owner' const inFlightFileReads = new Map>() const inFlightDiffReads = new Map>() @@ -143,6 +150,8 @@ export function useEditorPanelContentState({ const isLiveTailLogTab = restoredOpenFile?.readOnly === true && restoredOpenFile.liveTail === true let readConnectionId = connectionId + let readWorktreeId = worktreeId + let readRelativePath = restoredOpenFile?.relativePath ?? relativePath if ( resolvedConnectionId === undefined && !readSettings?.activeRuntimeEnvironmentId?.trim() && @@ -160,18 +169,52 @@ export function useEditorPanelContentState({ const externalSshOwnerId = restoredOpenFile.externalSshTargetId?.trim() || (isLiveTailLogTab ? undefined : connectionId) - if (!externalSshOwnerId && readSettings?.activeRuntimeEnvironmentId?.trim()) { - // Why: runtime file RPCs are worktree-scoped, so a client-local absolute - // path has no route into a remote runtime workspace. - throw new Error('External local files are not available for remote workspaces.') - } - if (!externalSshOwnerId) { - // Why: client-local external tabs need their main-process path grant - // refreshed because that authorization is only held in memory. + const runtimeEnvironmentId = isLiveTailLogTab + ? undefined + : readSettings?.activeRuntimeEnvironmentId?.trim() + if (isLiveTailLogTab) { await window.api.fs.authorizeExternalPath({ targetPath: filePath }) - // Why: that grant covers the client path, so this read must stay off the - // worktree's SSH host. readConnectionId = undefined + } else { + const currentState = useAppStore.getState() + const executionHostId = externalSshOwnerId + ? toSshExecutionHostId(externalSshOwnerId) + : runtimeEnvironmentId + ? toRuntimeExecutionHostId(runtimeEnvironmentId) + : LOCAL_EXECUTION_HOST_ID + const route = findWorkspaceFileRoute(currentState, executionHostId, filePath) + if (route && route.worktreeId !== worktreeId) { + const migration = await migrateRestoredEditorFileOwner( + id, + route, + runtimeEnvironmentId ?? null + ) + fileReadGenerationRef.current[id] = ++fileReadGenerationCounterRef.current + if (!migration.ok) { + throw new Error( + migration.reason === 'collision' + ? 'The sibling file is already open; close one tab before restoring it.' + : 'The sibling file owner changed while the tab was restoring.' + ) + } + setFileContents((prev) => { + const next = { ...prev } + delete next[id] + return next + }) + return + } + if (runtimeEnvironmentId && !route) { + throw new Error('External local files are not available for remote workspaces.') + } + if (!externalSshOwnerId) { + // Why: client-local external tabs need their main-process path grant + // refreshed because that authorization is only held in memory. + await window.api.fs.authorizeExternalPath({ targetPath: filePath }) + // Why: that grant covers the client path, so this read must stay off the + // worktree's SSH host. + readConnectionId = undefined + } } } const readScope = getRuntimeFileReadScope(readSettings, readConnectionId) @@ -186,8 +229,8 @@ export function useEditorPanelContentState({ pending = readRuntimeFileContent({ settings: readSettings, filePath, - relativePath: restoredOpenFile?.relativePath ?? relativePath, - worktreeId, + relativePath: readRelativePath, + worktreeId: readWorktreeId, connectionId: readConnectionId, expectedExternalSshTargetId: restoredOpenFile?.externalSshTargetId, includeLocalLogMetadata: isLiveTailLogTab diff --git a/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx new file mode 100644 index 00000000000..4576b137432 --- /dev/null +++ b/src/renderer/src/components/editor/useEditorPanelRemoteSiblingContentState.test.tsx @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OpenFile } from '@/store/slices/editor' +import type { FileContent } from './editor-panel-content-types' + +const mocks = vi.hoisted(() => ({ + readRuntimeFileContent: vi.fn(), + findWorkspaceFileRoute: vi.fn(), + migrateRestoredEditorFileOwner: vi.fn(), + getState: vi.fn() +})) + +vi.mock('@/runtime/runtime-file-client', () => ({ + getRuntimeFileReadScope: vi.fn( + ( + settings: { activeRuntimeEnvironmentId?: string | null } | null | undefined, + connectionId?: string + ) => connectionId ?? settings?.activeRuntimeEnvironmentId ?? null + ), + readRuntimeFileContent: mocks.readRuntimeFileContent, + subscribeRuntimeFileChanges: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => ({ + getRuntimeGitBranchDiff: vi.fn(), + getRuntimeGitCommitDiff: vi.fn(), + getRuntimeGitDiff: vi.fn(), + getRuntimeGitScope: vi.fn(() => null) +})) + +vi.mock('@/lib/connection-context', () => ({ + getConnectionId: vi.fn(), + getConnectionIdForFile: vi.fn(), + isWorktreeConnectionResolved: vi.fn(() => true) +})) + +vi.mock('@/lib/runtime-workspace-file-route', () => ({ + findWorkspaceFileRoute: mocks.findWorkspaceFileRoute +})) + +vi.mock('./migrate-restored-editor-file-owner', () => ({ + migrateRestoredEditorFileOwner: mocks.migrateRestoredEditorFileOwner +})) + +vi.mock('@/lib/worktree-runtime-owner', () => ({ + getExecutionHostIdForWorktree: vi.fn(() => 'local') +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: mocks.getState } })) + +vi.mock('./useEditorPanelExternalContentEvents', () => ({ + useEditorPanelExternalContentEvents: vi.fn(), + usePruneClosedEditorContent: vi.fn() +})) + +vi.mock('./useEditorPanelFileLoadRetry', () => ({ useEditorPanelFileLoadRetry: vi.fn() })) +vi.mock('./useLocalLogTail', () => ({ useLocalLogTail: vi.fn() })) + +import { useEditorPanelContentState } from './useEditorPanelContentState' + +const authorizeExternalPath = vi.fn() +let latestFileContents: Record = {} + +function createOpenFile(overrides: Partial): OpenFile { + return { + id: '/repo/file.ts', + filePath: '/repo/file.ts', + relativePath: 'file.ts', + worktreeId: 'wt-1', + language: 'typescript', + isDirty: false, + mode: 'edit', + ...overrides + } +} + +function HookProbe({ activeFile }: { activeFile: OpenFile }): null { + latestFileContents = useEditorPanelContentState({ + activeFile, + isChangesMode: false, + openFiles: [activeFile], + gitStatusEntries: undefined, + editorViewMode: {} + }).fileContents + return null +} + +describe('remote sibling editor content routing', () => { + let container: HTMLDivElement | null = null + let root: Root | null = null + + beforeEach(() => { + latestFileContents = {} + authorizeExternalPath.mockReset() + authorizeExternalPath.mockResolvedValue(undefined) + ;(window as unknown as { api: unknown }).api = { fs: { authorizeExternalPath } } + mocks.readRuntimeFileContent.mockReset() + mocks.findWorkspaceFileRoute.mockReset() + mocks.findWorkspaceFileRoute.mockReturnValue(null) + mocks.migrateRestoredEditorFileOwner.mockReset() + mocks.migrateRestoredEditorFileOwner.mockResolvedValue({ ok: true, fileId: 'owned-file' }) + mocks.getState.mockReset() + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: 'runtime-1' }, + openFiles: [], + setLastKnownDiskSignature: vi.fn() + }) + container = document.body.appendChild(document.createElement('div')) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + }) + + it('keeps a client-local live-tail log on the client', async () => { + const logPath = '/Users/me/.codex/sessions/session.jsonl' + const activeFile = createOpenFile({ + id: logPath, + filePath: logPath, + relativePath: logPath, + worktreeId: 'repo-runtime::/work/demo-project', + readOnly: true, + liveTail: true + }) + mocks.readRuntimeFileContent.mockResolvedValue({ content: 'log line', isBinary: false }) + + await act(async () => root?.render()) + + await vi.waitFor(() => expect(latestFileContents[activeFile.id]?.content).toBe('log line')) + expect(mocks.findWorkspaceFileRoute).not.toHaveBeenCalled() + expect(authorizeExternalPath).toHaveBeenCalledWith({ targetPath: logPath }) + expect(mocks.readRuntimeFileContent).toHaveBeenCalledWith( + expect.objectContaining({ connectionId: undefined, includeLocalLogMetadata: true }) + ) + }) + + it('atomically reparents an unstamped sibling file before reading', async () => { + const activeFile = createOpenFile({ + id: '/work/repo-b/docs/readme.md', + filePath: '/work/repo-b/docs/readme.md', + relativePath: '/work/repo-b/docs/readme.md', + worktreeId: 'repo-a::/work/repo-a' + }) + const route = { + worktreeId: 'repo-b::/work/repo-b', + relativePath: 'docs/readme.md', + executionHostId: 'runtime:runtime-1' + } as const + mocks.findWorkspaceFileRoute.mockReturnValue(route) + + await act(async () => root?.render()) + + await vi.waitFor(() => + expect(mocks.migrateRestoredEditorFileOwner).toHaveBeenCalledWith( + activeFile.id, + route, + 'runtime-1' + ) + ) + expect(authorizeExternalPath).not.toHaveBeenCalled() + expect(mocks.readRuntimeFileContent).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts index da666d54523..248783de9e0 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.test.ts @@ -1,11 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { GhAuthDiagnostic } from '../../../../shared/github-auth-types' vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -import { buildRemediation } from './GhAuthErrorHelp' +import { buildRemediation, reloadOrcaRenderer } from './GhAuthErrorHelp' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) function diagnostic(overrides: Partial = {}): GhAuthDiagnostic { return { @@ -57,4 +62,20 @@ describe('GitHub Project auth remediation host routing', () => { } ]) }) + + it('does not force navigation when the checkpointed reload is refused', async () => { + const reload = vi.fn(() => Promise.reject(new Error('checkpoint failed'))) + const locationReload = vi.fn() + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.stubGlobal('window', { + api: { app: { reload } }, + location: { reload: locationReload } + }) + + reloadOrcaRenderer() + await vi.waitFor(() => expect(errors).toHaveBeenCalledTimes(1)) + + expect(reload).toHaveBeenCalledTimes(1) + expect(locationReload).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx index 30d1ea61b16..7bbeff291d7 100644 --- a/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx +++ b/src/renderer/src/components/github-project/GhAuthErrorHelp.tsx @@ -40,14 +40,12 @@ function refreshCommandForHost(host: string | null | undefined): string { // macOS/Linux vs PowerShell on Windows. const IS_WINDOWS = typeof navigator !== 'undefined' && /Win(dows|32|64)/i.test(navigator.userAgent) -function reloadOrcaRenderer(): void { - const reload = window.api.app.reload - if (typeof reload !== 'function') { - window.location.reload() - return - } - void reload().catch(() => { - window.location.reload() +export function reloadOrcaRenderer(): void { + void window.api.app.reload().catch((error) => { + console.error( + '[github-projects] Renderer reload refused:', + error instanceof Error ? error.name : typeof error + ) }) } diff --git a/src/renderer/src/components/github/work-item-state-presentation.tsx b/src/renderer/src/components/github/work-item-state-presentation.tsx index 7e14804fbaf..fadc502fb91 100644 --- a/src/renderer/src/components/github/work-item-state-presentation.tsx +++ b/src/renderer/src/components/github/work-item-state-presentation.tsx @@ -1,24 +1,10 @@ import React from 'react' import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import type { GitHubAssignableUser, GitHubWorkItem } from '../../../../shared/types' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - if (Math.abs(diffMinutes) < 60) { - return formatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return formatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return formatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function getStateLabel(item: GitHubWorkItem): string { diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx index 753e24872dc..845ed09b0d3 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.test.tsx @@ -1,10 +1,48 @@ -import { describe, expect, it } from 'vitest' +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { LinearTeam } from '../../../shared/types' import { clearLinearIssueAttributeFacet, countLinearIssueAttributeFilters, linearIssueAttributeFilterPillLabels } from './linear-issue-attribute-filter-sections' import type { LinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import LinearIssueAttributeFilterDropdowns from './linear-issue-attribute-filter-dropdowns' + +const metadataMocks = vi.hoisted(() => ({ + useTeamsStates: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'state-1', name: 'Todo' }] : [], + loading: false, + error: null + })), + useTeamsLabels: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'label-1', name: 'Bug' }] : [], + loading: false, + error: null + })), + useTeamsMembers: vi.fn((teamIds: readonly string[]) => ({ + data: teamIds.length > 0 ? [{ id: 'member-1', displayName: 'Ada Lovelace' }] : [], + loading: false, + error: null + })) +})) + +vi.mock('@/hooks/useIssueMetadata', () => metadataMocks) + +const roots: Root[] = [] + +afterEach(() => { + roots.splice(0).forEach((root) => { + act(() => root.unmount()) + }) + document.body.replaceChildren() + metadataMocks.useTeamsStates.mockClear() + metadataMocks.useTeamsLabels.mockClear() + metadataMocks.useTeamsMembers.mockClear() +}) const sample: LinearIssueAttributeFilter = { stateIds: ['s1', 's2'], @@ -38,3 +76,72 @@ describe('linear-issue-attribute-filter helpers', () => { expect(pills[3]?.value).toBe('Bug') }) }) + +describe('LinearIssueAttributeFilterDropdowns', () => { + it('keeps metadata lazy while closed with only static filter labels', () => { + const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + act(() => { + root.render( + undefined} + workspaceId="workspace-1" + isAllWorkspaces={false} + primaryTeam={team} + selectedTeamIds={[]} + availableTeams={[team]} + /> + ) + }) + + expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith([], undefined, null) + expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith([], undefined, null) + expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith([], undefined, null) + }) + + it('keeps readable metadata names available after the popover closes', () => { + const value: LinearIssueAttributeFilter = { + stateIds: ['state-1'], + priorities: [], + assignee: { kind: 'user', id: 'member-1' }, + labelIds: ['label-1'] + } + const team: LinearTeam = { id: 'team-1', name: 'Engineering', key: 'ENG' } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + roots.push(root) + + act(() => { + root.render( + undefined} + workspaceId="workspace-1" + isAllWorkspaces={false} + primaryTeam={team} + selectedTeamIds={[]} + availableTeams={[team]} + /> + ) + }) + + expect(container.textContent).toContain('Todo') + expect(container.textContent).toContain('Ada Lovelace') + expect(container.textContent).toContain('Bug') + expect(container.textContent).not.toContain('state-1') + expect(metadataMocks.useTeamsStates).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + expect(metadataMocks.useTeamsLabels).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + expect(metadataMocks.useTeamsMembers).toHaveBeenCalledWith(['team-1'], undefined, 'workspace-1') + }) +}) diff --git a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx index fa47d6def6b..9cf550e2fb3 100644 --- a/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx +++ b/src/renderer/src/components/linear-issue-attribute-filter-dropdowns.tsx @@ -76,9 +76,15 @@ export default function LinearIssueAttributeFilterDropdowns({ }: Props): React.JSX.Element { const [popoverOpen, setPopoverOpen] = useState(false) const [openSection, setOpenSection] = useState(null) + const activeCount = countLinearIssueAttributeFilters(value) + const metadataNeeded = + popoverOpen || + value.stateIds.length > 0 || + value.labelIds.length > 0 || + value.assignee?.kind === 'user' const activeTeamIds = useMemo(() => { - if (!popoverOpen || isAllWorkspaces) { + if (!metadataNeeded || isAllWorkspaces) { return [] as string[] } return resolveLinearIssueAttributeFilterTeamIds({ @@ -86,10 +92,10 @@ export default function LinearIssueAttributeFilterDropdowns({ availableTeams, primaryTeamId: primaryTeam?.id ?? null }) - }, [popoverOpen, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id]) + }, [metadataNeeded, isAllWorkspaces, selectedTeamIds, availableTeams, primaryTeam?.id]) const concreteWorkspaceId = - popoverOpen && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null + metadataNeeded && !isAllWorkspaces && workspaceId && workspaceId !== 'all' ? workspaceId : null // Why: multi-team / All teams must union filter options across every selected team (#8739). const states = useTeamsStates(activeTeamIds, settings, concreteWorkspaceId) @@ -179,7 +185,6 @@ export default function LinearIssueAttributeFilterDropdowns({ [members.data] ) - const activeCount = countLinearIssueAttributeFilters(value) const pills = linearIssueAttributeFilterPillLabels({ value, stateNamesById, diff --git a/src/renderer/src/components/linear-issue-workspace-text.ts b/src/renderer/src/components/linear-issue-workspace-text.ts index b4e88121bb8..e047495d40b 100644 --- a/src/renderer/src/components/linear-issue-workspace-text.ts +++ b/src/renderer/src/components/linear-issue-workspace-text.ts @@ -1,25 +1,10 @@ import { getLinearIssueWorkspaceName } from '../../../shared/workspace-name' import type { LinearIssue } from '../../../shared/types' import { getUsableLinearBranchName } from '../../../shared/new-workspace/workspace-source' - -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' export function formatLinearIssueRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMs = date.getTime() - Date.now() - const diffMinutes = Math.round(diffMs / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeTimeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeTimeFormatter.format(diffHours, 'hour') - } - const diffDays = Math.round(diffHours / 24) - return relativeTimeFormatter.format(diffDays, 'day') + return formatUiRelativeTimeFromDate(input) } export function buildLinearIssueBranchName(issue: LinearIssue): string { diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index 26aea01d014..15009c5e599 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -4,6 +4,8 @@ import '@testing-library/jest-dom/vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' +import { applyCommandMarkerBoundaries } from './native-chat-pending' import type { NativeChatInteractiveSend } from './use-native-chat-interactive-send' const INITIAL_PROMPT = JSON.stringify({ @@ -19,8 +21,9 @@ const INITIAL_PROMPT = JSON.stringify({ const storeState = { agentStatusByPaneKey: { 'tab-1:leaf-1': { - interactivePrompt: INITIAL_PROMPT, - toolName: 'AskUserQuestion' + interactivePrompt: INITIAL_PROMPT as string | undefined, + toolName: 'AskUserQuestion' as string | undefined, + state: undefined as string | undefined } } } @@ -42,11 +45,17 @@ function renderCard(canSend = true): ReturnType { return render(cardElement(canSend)) } -function cardElement(canSend = true): React.JSX.Element { +function cardElement( + canSend = true, + messages?: readonly NativeChatMessage[], + onShowingQuestionChange?: (showing: boolean) => void +): React.JSX.Element { return ( { beforeEach(() => { vi.clearAllMocks() storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT + storeState.agentStatusByPaneKey['tab-1:leaf-1'].state = undefined }) afterEach(() => { @@ -166,3 +209,82 @@ describe('NativeChatInteractiveCard answer lifecycle', () => { expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() }) }) + +// A headless host, a relay gap, or a replay can leave the pane with no live +// `interactivePrompt` while the transcript still holds the unresolved call (#11761). +// Which asks the transcript still counts as pending (orphaned calls, turn boundaries) +// is the shared parser's contract — covered in `src/shared/native-chat-ask.test.ts`. +describe('NativeChatInteractiveCard transcript fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = undefined + storeState.agentStatusByPaneKey['tab-1:leaf-1'].state = undefined + }) + + afterEach(() => { + cleanup() + }) + + it('renders a pending transcript ask and reports the composer replacement', () => { + const onShowingQuestionChange = vi.fn() + render(cardElement(true, [askCallMessage('Tabs or spaces?')], onShowingQuestionChange)) + + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + expect(onShowingQuestionChange).toHaveBeenCalledWith(true) + }) + + it('prefers live status over the transcript when both carry a prompt', () => { + storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT + render(cardElement(true, [askCallMessage('Stale transcript question?')])) + + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument() + }) + + it('still renders while the mirrored status says the agent is working', () => { + // Why no state gate: the mirrored status channel is exactly what fails in the + // reported topology, so keying the fallback on it would suppress the card. + storeState.agentStatusByPaneKey['tab-1:leaf-1'].state = 'working' + render(cardElement(true, [askCallMessage('Tabs or spaces?')])) + + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + }) + + it('stays dismissed after answering while the transcript call is still pending', () => { + mocks.sendAnswer.mockReturnValue({ settleAfterMs: 500, waitsForVerifiedDelivery: true }) + const messages = [askCallMessage('Tabs or spaces?')] + const rendered = render(cardElement(true, messages)) + + let settleDelivery: ((delivered: boolean) => void) | undefined + mocks.sendAnswer.mockImplementation((_prompt, _selections, onDeliverySettled) => { + settleDelivery = onDeliverySettled + return { settleAfterMs: 500, waitsForVerifiedDelivery: true } + }) + chooseSpacesAndSubmit() + act(() => settleDelivery?.(true)) + rendered.rerender(cardElement(true, messages)) + + expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument() + }) + + it('clears once the FIFO tool result lands', () => { + const rendered = render(cardElement(true, [askCallMessage('Tabs or spaces?')])) + expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument() + + rendered.rerender(cardElement(true, [askCallMessage('Tabs or spaces?'), askResultMessage()])) + expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument() + }) + + // The view passes the command-boundary-trimmed messages, so an ask abandoned via + // `/clear` cannot come back as a permanent card sitting over the composer. + it('drops an ask abandoned by /clear', () => { + const abandoned = { ...askCallMessage('Tabs or spaces?'), timestamp: 100 } + const trimmed = applyCommandMarkerBoundaries( + [abandoned as unknown as NativeChatMessage], + [{ id: 'clear-1', command: '/clear', sentAt: 200 }] + ) + render(cardElement(true, trimmed)) + + expect(screen.queryByText('Tabs or spaces?')).not.toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index 3c8973e6d7f..c02df5c13e8 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '../../store' +import { extractPendingAsk } from '../../../../shared/native-chat-ask' +import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { parseInteractivePrompt } from './native-chat-interactive-prompt' import { nativeChatCardDismissKey } from './native-chat-dismiss-key' import { NativeChatQuestionCard } from './NativeChatQuestionCard' @@ -19,17 +21,27 @@ import type { NativeChatInteractiveSend } from './use-native-chat-interactive-se * answered prompt by content key and hide the card until a genuinely different * prompt arrives. The dismissal resets once the prompt clears, so a later * (even identical) prompt shows again instead of staying hidden. + * + * The transcript is the second source (mobile parity again): a question that the + * live status never delivered — headless host, relay gap, replay, reconnect — + * still has its unresolved tool call in the messages we already parsed. Without + * it the composer stays mounted over a pane parked on a selector, and the next + * send commits the highlighted option instead of the typed message (#11761). */ export function NativeChatInteractiveCard({ paneKey, send, canSend, + messages, onShowingQuestionChange, answerInputRef }: { paneKey: string send: NativeChatInteractiveSend canSend: boolean + /** Transcript to fall back on when live status carries no prompt. Pass the + * command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */ + messages?: readonly NativeChatMessage[] /** Reports whether a question card is on screen so the view can replace the * composer with it (the card's free-text row is the answer input). */ onShowingQuestionChange?: (showing: boolean) => void @@ -45,10 +57,14 @@ export function NativeChatInteractiveCard({ const interactiveToolName = useAppStore((s) => s.agentStatusByPaneKey[paneKey]?.toolName ?? null) const { sendAnswer, sendRaw, cancelPending, cancel } = send - const card = useMemo( - () => parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined), - [interactivePrompt, interactiveToolName] - ) + const card = useMemo(() => { + const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined) + if (statusCard || !messages) { + return statusCard + } + const prompt = extractPendingAsk(messages) + return prompt ? { kind: 'question' as const, prompt } : null + }, [interactivePrompt, interactiveToolName, messages]) const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card]) const [dismissedKey, setDismissedKey] = useState(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 90bf3be227d..33a8ddecbcc 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -284,15 +284,13 @@ function NativeChatResolvedView({ ? sessionWithLaunchPrompt : { ...sessionWithLaunchPrompt, messages } }, [sessionWithLaunchPrompt, commandMarkers]) - const launchPromptVisible = - launchPromptMessage !== null && - sessionAfterCommandBoundaries.messages.some((message) => message.id === launchPromptMessage.id) const failedLaunchPromptMessageIds = useMemo(() => { - if (!paneLaunchPrompt?.failed || !launchPromptVisible || !launchPromptMessage) { + const id = paneLaunchPrompt?.failed ? launchPromptMessage?.id : null + if (!id || !sessionAfterCommandBoundaries.messages.some((message) => message.id === id)) { return undefined } - return new Set([launchPromptMessage.id]) - }, [paneLaunchPrompt?.failed, launchPromptMessage, launchPromptVisible]) + return new Set([id]) + }, [paneLaunchPrompt?.failed, launchPromptMessage?.id, sessionAfterCommandBoundaries.messages]) // The streaming preview bubble (if any) sits after the transcript but before // the optimistic user echoes — same order mobile uses. @@ -431,6 +429,7 @@ function NativeChatResolvedView({ paneKey={paneKey} send={interactiveSend} canSend={canSend} + messages={sessionAfterCommandBoundaries.messages} onShowingQuestionChange={setQuestionActive} answerInputRef={questionAnswerInputRef} /> diff --git a/src/renderer/src/components/pr-checks-fix-prompt.test.ts b/src/renderer/src/components/pr-checks-fix-prompt.test.ts index 2884138c0ef..b3f2eadf600 100644 --- a/src/renderer/src/components/pr-checks-fix-prompt.test.ts +++ b/src/renderer/src/components/pr-checks-fix-prompt.test.ts @@ -34,6 +34,27 @@ afterEach(() => { vi.restoreAllMocks() }) +describe('getCheckDetailsPromptKey', () => { + // Regression for #7732: a GitLab job with no web_url would otherwise key on its + // list index, so its freshly loaded log would never reach the fix prompt. + it('keys GitLab jobs by job id ahead of the index fallback', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 987654 + } + + expect(getCheckDetailsPromptKey(manualJob, 0)).toBe('gitlab-job:987654:deploy: production') + expect(getCheckDetailsPromptKey(manualJob, 3)).toBe(getCheckDetailsPromptKey(manualJob, 0)) + }) + + it('leaves GitHub check identities untouched', () => { + expect(getCheckDetailsPromptKey(failingCheck, 0)).toBe('check-run:11') + }) +}) + describe('buildFixBrokenChecksPrompt', () => { it('keeps names-only broken check data when no details are provided', () => { const prompt = buildPrompt({}) diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 0dc26aa38c2..028abac821c 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -58,6 +58,7 @@ import { import { ENTRY_REFRESH_GRACE_MS, shouldEntryRefresh } from './checks-entry-refresh' import type { GitLabDiscussionResolveResult, + GitLabProjectRef, GitLabWorkItemDetails, PRInfo, PRCheckDetail, @@ -159,6 +160,7 @@ import { import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useMountedRef } from '@/hooks/useMountedRef' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' +import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { gitLabPipelineJobsToPRChecks } from '../../../../shared/gitlab-pipeline-checks' import { getWorktreeGitIdentityDisplay } from '@/lib/worktree-git-identity-display' import { SourceControlAgentActionDialog } from './SourceControlAgentActionDialog' @@ -370,6 +372,10 @@ function isGitLabChecksPanelReview( return review?.provider === 'gitlab' } +function hasGitHubCheckHandle(check: PRCheckDetail): boolean { + return Boolean(check.checkRunId || check.workflowRunId || check.url) +} + function gitLabMRCommentsToPRComments( comments: GitLabWorkItemDetails['comments'] | undefined ): PRComment[] { @@ -564,6 +570,9 @@ export default function ChecksPanel(): React.JSX.Element { const mountedRef = useMountedRef() const confirm = useConfirmationDialog() const prevChecksRef = useRef('') + // Why: a fork MR's pipeline lives in the source project, so job traces must be + // fetched against the MR's own project rather than this repo's default remote. + const gitLabProjectRefRef = useRef(null) const conflictSummaryRefreshKeyRef = useRef(null) const panelVisibleSinceRef = useRef(null) const foregroundedUnrenderedReviewKeyRef = useRef(null) @@ -1925,6 +1934,7 @@ export default function ChecksPanel(): React.JSX.Element { if (!isCurrentAsyncResult(requestKey)) { return } + gitLabProjectRefRef.current = details?.item.projectRef ?? null const result = gitLabPipelineJobsToPRChecks(details?.pipelineJobs ?? []) setChecks(result) setComments(gitLabMRCommentsToPRComments(details?.comments)) @@ -2063,6 +2073,17 @@ export default function ChecksPanel(): React.JSX.Element { if (!repo) { return Promise.resolve(null) } + if (check.gitlabJobId) { + // Why: `settings` (not ownerSettings) is what fetched the job list, so the + // job id and its trace always resolve against the same host. + return loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings, + check, + projectRef: gitLabProjectRefRef.current + }) + } return fetchPRCheckDetails( repo.path, { @@ -2075,9 +2096,12 @@ export default function ChecksPanel(): React.JSX.Element { { repoId: repo.id } ) }, - [fetchPRCheckDetails, pr?.prRepo, repo] + [fetchPRCheckDetails, pr?.prRepo, repo, settings] ) + // Why: read at call time — the ref is filled by an async MR fetch, so a value prop would be stale. + const getGitLabProjectRef = useCallback(() => gitLabProjectRefRef.current, []) + useEffect(() => { if (activeGitLabReview) { return @@ -3417,33 +3441,45 @@ export default function ChecksPanel(): React.JSX.Element { setIsFixingChecksWithAI(true) try { const checkRunDetailsByCheckKey: Record = {} - if (activeReview.provider !== 'gitlab' && repo) { - await Promise.all( - broken.slice(0, 5).map(async (check, index) => { - if (!check.checkRunId && !check.workflowRunId && !check.url) { - return + await Promise.all( + broken.slice(0, 5).map(async (check, index) => { + const isGitLabJob = Boolean(check.gitlabJobId) + if ( + !isGitLabJob && + (activeReview.provider === 'gitlab' || !hasGitHubCheckHandle(check)) + ) { + return + } + try { + // Why: GitLab job logs are now loadable, so the fix prompt gets the same + // failure context the sidebar shows instead of check names alone. + const details = isGitLabJob + ? await loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings, + check, + projectRef: gitLabProjectRefRef.current + }) + : await fetchPRCheckDetails( + repo.path, + { + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: pr?.prRepo ?? null + }, + { repoId: repo.id } + ) + if (details) { + checkRunDetailsByCheckKey[getCheckDetailsPromptKey(check, index)] = details } - try { - const details = await fetchPRCheckDetails( - repo.path, - { - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo: pr?.prRepo ?? null - }, - { repoId: repo.id } - ) - if (details) { - checkRunDetailsByCheckKey[getCheckDetailsPromptKey(check, index)] = details - } - } catch (error) { - console.warn('[ChecksPanel] failed to load check details for AI fix prompt', error) - } - }) - ) - } + } catch (error) { + console.warn('[ChecksPanel] failed to load check details for AI fix prompt', error) + } + }) + ) if (!isCurrentAsyncResult(requestKey)) { return } @@ -3482,6 +3518,7 @@ export default function ChecksPanel(): React.JSX.Element { isFixingChecksWithAI, pr?.prRepo, repo, + settings, sourceControlAiActionsVisible, stateRequestKey ]) @@ -3663,6 +3700,9 @@ export default function ChecksPanel(): React.JSX.Element { } openModal('edit-meta', { worktreeId: activeWorktreeId, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on this workspace instead of the ambiguous lookup. + repoId: activeWorktree.repoId, currentDisplayName: activeWorktree.displayName, currentIssue: activeWorktree.linkedIssue, currentPR: activeWorktree.linkedPR ?? activeReview.number, @@ -4445,6 +4485,7 @@ export default function ChecksPanel(): React.JSX.Element { checksLoading={checksLoading} checkDetailsContextKey={stateRequestKey} onLoadCheckDetails={handleLoadCheckDetails} + getGitLabProjectRef={getGitLabProjectRef} /> )} Promise }> = {} ): void { @@ -91,7 +92,7 @@ function renderChecksList( root.render( { expect(container.textContent).toContain('manual action on GitHub') expect(container.textContent).not.toContain('No inline details are available') }) + + it('renders the log excerpt inline instead of deferring it to the full-details tab', async () => { + renderChecksList({ worktreeId: 'wt-child-1' }) + + await act(async () => { + await Promise.resolve() + }) + + expect(container.textContent).toContain('Error: assertion failed') + expect(container.textContent).not.toContain('Log tail available in full details') + }) + + // Regression for #7732: manual/created GitLab jobs carry no web_url and no GitHub + // handles, so the panel used to short-circuit before ever asking for their log. + it('loads details for a GitLab job that only carries a job id', async () => { + const gitLabCheck: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 987654 + } + const onLoadCheckDetails = vi.fn(async () => ({ + ...checkDetails, + name: gitLabCheck.name, + jobs: [{ ...checkDetails.jobs[0]!, id: 987654, logTail: 'ERROR: Job failed: exit code 1' }] + })) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [gitLabCheck], onLoadCheckDetails }) + + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledWith(gitLabCheck) + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + expect(container.textContent).not.toContain('No inline details are available') + expect(container.querySelector('.sticky.top-0')?.textContent).toContain('View full logs') + }) + + // GitLab trace fetches fail for reasons GitHub's never do (auth, 404, self-hosted + // host resolution) and the panel has no retry button, so a transient failure must + // not pin its error on the row for the rest of the session. + it('retries a failed detail load once the check state moves on', async () => { + const runningCheck: PRCheckDetail = { + name: 'test: unit', + status: 'in_progress', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockRejectedValueOnce(new Error('401 Unauthorized')) + .mockResolvedValueOnce({ + ...checkDetails, + name: runningCheck.name, + status: 'completed', + conclusion: 'failure', + jobs: [{ ...checkDetails.jobs[0]!, id: 5150, logTail: 'ERROR: Job failed: exit code 1' }] + }) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [runningCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(container.textContent).toContain('401 Unauthorized') + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + + const finishedCheck: PRCheckDetail = { ...runningCheck, status: 'completed' } + renderChecksList({ worktreeId: 'wt-child-1', checks: [finishedCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(2) + expect(container.textContent).not.toContain('401 Unauthorized') + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + }) + + // A running GitLab job has no trace yet, so the first load legitimately resolves to + // null — without re-arming, the row stays "no inline details" after the job finishes. + it('retries a detail load that resolved to null once the check state moves on', async () => { + const runningCheck: PRCheckDetail = { + name: 'test: unit', + status: 'in_progress', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + ...checkDetails, + name: runningCheck.name, + jobs: [{ ...checkDetails.jobs[0]!, id: 5150, logTail: 'ERROR: Job failed: exit code 1' }] + }) + + renderChecksList({ worktreeId: 'wt-child-1', checks: [runningCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('No inline details are available') + + const finishedCheck: PRCheckDetail = { ...runningCheck, status: 'completed' } + renderChecksList({ worktreeId: 'wt-child-1', checks: [finishedCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + expect(onLoadCheckDetails).toHaveBeenCalledTimes(2) + expect(container.textContent).not.toContain('No inline details are available') + expect(container.textContent).toContain('ERROR: Job failed: exit code 1') + }) + + it('keeps a failed detail load pinned while the check state is unchanged', async () => { + const onLoadCheckDetails = vi + .fn<(check: PRCheckDetail) => Promise>() + .mockRejectedValue(new Error('401 Unauthorized')) + const gitLabCheck: PRCheckDetail = { + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 5150 + } + + renderChecksList({ worktreeId: 'wt-child-1', checks: [gitLabCheck], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + // Re-render with an equal-but-new row array, as every 30s poll tick does. + renderChecksList({ worktreeId: 'wt-child-1', checks: [{ ...gitLabCheck }], onLoadCheckDetails }) + await act(async () => { + await Promise.resolve() + }) + + // Why: re-arming on row identity alone would refetch on every poll tick. + expect(onLoadCheckDetails).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain('401 Unauthorized') + }) }) 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 b6353e9380a..2b51b03f7b2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -55,6 +55,7 @@ import { } from '@/components/ui/context-menu' import { cn } from '@/lib/utils' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import { CheckJobLogTail } from './check-job-log-tail' import { filterPRCommentsByAudience, getPRCommentAudienceCounts, @@ -79,6 +80,7 @@ import { getPRCommentGroupSurfaceClasses, type PRCommentPresentationClasses } from './pr-comment-presentation' +import type { GitLabProjectRef } from '../../../../shared/gitlab-types' import type { PRInfo, PRCheckDetail, @@ -541,6 +543,8 @@ type CheckDetailsLoadState = { loading: boolean details: PRCheckRunDetails | null error: string | null + /** Check state when the load failed or returned nothing, so a later state change can retry it. */ + errorAt?: { status: PRCheckDetail['status']; conclusion: PRCheckDetail['conclusion'] } } function getCheckIdentityKey(check: PRCheckDetail, index: number): string { @@ -550,6 +554,11 @@ function getCheckIdentityKey(check: PRCheckDetail, index: number): string { if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}` } + // Why: manual/created GitLab jobs have no web_url, so they would otherwise key on + // the list index and lose their cached log whenever the pipeline re-sorts. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}` + } if (check.url) { return `url:${check.url}` } @@ -661,13 +670,16 @@ function CheckRunDetails({ state, checkDetailsContextKey, worktreeId, - detailsStickySurface = 'sidebar' + detailsStickySurface = 'sidebar', + getGitLabProjectRef }: { check: PRCheckDetail state: CheckDetailsLoadState | undefined checkDetailsContextKey: string worktreeId: string | null detailsStickySurface?: CheckDetailsStickySurface + /** Why: a getter, not a value — the source ref is filled by an async fetch and would read stale during render. */ + getGitLabProjectRef?: () => GitLabProjectRef | null }): React.JSX.Element { const openCheckRunDetails = useAppStore((s) => s.openCheckRunDetails) const details = state?.details @@ -706,7 +718,8 @@ function CheckRunDetails({ openCheckRunDetails(worktreeId, checkDetailsContextKey, check, { details: state?.details ?? null, loading: state?.loading ?? false, - error: state?.error ?? null + error: state?.error ?? null, + gitlabProjectRef: getGitLabProjectRef?.() ?? null }) } @@ -914,6 +927,7 @@ function CheckRunDetails({ ))}
)} + {job.logTail && } ))} @@ -928,15 +942,6 @@ function CheckRunDetails({ )} - {hasLogTail && ( -
- {translate( - 'auto.components.right.sidebar.checks.panel.content.2524d1fb83', - 'Log tail available in full details.' - )} -
- )} - {!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
{getCheckConclusion(detailsStatusCheck) === 'action_required' @@ -965,7 +970,8 @@ export function ChecksList({ checkDetailsContextKey, onLoadCheckDetails, worktreeId: worktreeIdOverride, - detailsStickySurface = 'sidebar' + detailsStickySurface = 'sidebar', + getGitLabProjectRef }: { checks: PRCheckDetail[] checksLoading: boolean @@ -974,6 +980,8 @@ export function ChecksList({ /** Why: folder-workspace PR checks render rows for attached worktrees, not the active one. */ worktreeId?: string detailsStickySurface?: CheckDetailsStickySurface + /** Why: a getter, not a value — the source ref is filled by an async fetch and would read stale during render. */ + getGitLabProjectRef?: () => GitLabProjectRef | null }): React.JSX.Element { const activeWorktree = useActiveWorktree() const resolvedWorktreeId = worktreeIdOverride ?? activeWorktree?.id ?? null @@ -1041,13 +1049,20 @@ export function ChecksList({ const next: Record = { ...current } for (const row of rows) { const cached = next[row.key] - if (!cached?.details) { + if (!cached || cached.loading) { continue } - if ( - cached.details.status !== row.check.status || - cached.details.conclusion !== row.check.conclusion - ) { + // Why: a failed load (GitLab auth blip, 404, offline) otherwise pins its error + // forever — there is no retry affordance — so re-arm it once the job moves on. + const stale = cached.details + ? cached.details.status !== row.check.status || + cached.details.conclusion !== row.check.conclusion + : Boolean( + cached.errorAt && + (cached.errorAt.status !== row.check.status || + cached.errorAt.conclusion !== row.check.conclusion) + ) + if (stale) { delete next[row.key] changed = true } @@ -1061,7 +1076,12 @@ export function ChecksList({ if (detailsByCheckKey[row.key]?.loading || detailsByCheckKey[row.key]?.details) { return } - if (!row.check.checkRunId && !row.check.workflowRunId && !row.check.url) { + if ( + !row.check.checkRunId && + !row.check.workflowRunId && + !row.check.url && + !row.check.gitlabJobId + ) { setDetailsByCheckKey((current) => ({ ...current, [row.key]: { @@ -1104,7 +1124,16 @@ export function ChecksList({ [row.key]: { loading: false, details, - error: details ? null : 'No inline details are available for this check.' + error: details + ? null + : translate( + 'auto.components.right.sidebar.checks.panel.content.e15a8b77ef', + 'No inline details are available for this check.' + ), + // Why: a detail-less result is only final for this status — re-arm the retry once the job moves on. + errorAt: details + ? undefined + : { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1117,7 +1146,14 @@ export function ChecksList({ [row.key]: { loading: false, details: null, - error: err instanceof Error ? err.message : 'Failed to load check details.' + error: + err instanceof Error + ? err.message + : translate( + 'auto.components.right.sidebar.checks.panel.content.checkDetailsLoadFailed', + 'Failed to load check details.' + ), + errorAt: { status: row.check.status, conclusion: row.check.conclusion } } })) }) @@ -1148,12 +1184,14 @@ export function ChecksList({ patchOpenCheckRunDetails(resolvedWorktreeId, checkDetailsContextKey, row.check, { details: detailsState.details ?? null, loading: detailsState.loading ?? false, - error: detailsState.error ?? null + error: detailsState.error ?? null, + gitlabProjectRef: getGitLabProjectRef?.() ?? null }) } }, [ checkDetailsContextKey, detailsByCheckKey, + getGitLabProjectRef, patchOpenCheckRunDetails, resolvedWorktreeId, rows @@ -1329,6 +1367,7 @@ export function ChecksList({ checkDetailsContextKey={checkDetailsContextKey} worktreeId={resolvedWorktreeId} detailsStickySurface={detailsStickySurface} + getGitLabProjectRef={getGitLabProjectRef} /> )}
diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index 7578112895a..d9db52dd9c6 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -81,6 +81,7 @@ import { type ProviderAccountRuntimeView } from './provider-account-visibility' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTime } from '@/i18n/relative-time-format' import { cn } from '@/lib/utils' import { isWebClientLocation } from '@/lib/web-client-location' import { @@ -104,16 +105,7 @@ function formatMiniMaxRelativeRefresh(updatedAt: number, now: number): string { 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') + return formatUiRelativeTime(-diffMs) } function MiniMaxCookieHelpPopover(): React.JSX.Element { diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx index aeb8f4151d8..b0c55e87204 100644 --- a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx +++ b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx @@ -4,7 +4,7 @@ import { SettingsSwitchRow } from './SettingsFormControls' import { getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' type BrowserLinkRoutingModifierSettingProps = { settings: Pick diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index d67aaf7ef70..5c8088cc9bb 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObjec import type { GlobalSettings } from '../../../../shared/types' import { useAppStore } from '../../store' import { matchesSettingsSearch } from './settings-search' -import { getBrowserPaneSearchEntries, getBrowserLinkRoutingDescription } from './browser-search' +import { getBrowserPaneSearchEntries } from './browser-search' +import { getBrowserLinkRoutingDescription } from './browser-link-routing-copy' import { getBrowserUsePaneSearchEntries } from './browser-use-search' import { getBrowserPaneCombinedSearchEntries } from './browser-pane-search' import { BrowserHomePageSetting } from './BrowserHomePageSetting' diff --git a/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx index 918eec3344a..473caf85f00 100644 --- a/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx +++ b/src/renderer/src/components/settings/RemoteServerUpdateDialog.tsx @@ -93,7 +93,9 @@ function ServerUpdateRow({ {help ? (

{help} diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx new file mode 100644 index 00000000000..da0292e631b --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { RepositoryIconEmojiPicker } from './RepositoryIconEmojiPicker' + +const toastMocks = vi.hoisted(() => ({ + error: vi.fn() +})) + +vi.mock('sonner', () => ({ + toast: { error: toastMocks.error } +})) + +// Mock store exposing only the theme value, to verify the minimal-selector subscription. +const storeMocks = vi.hoisted(() => ({ + state: { settings: { theme: 'light' } } as Partial +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Partial) => unknown) => selector(storeMocks.state) +})) + +vi.mock('@/components/terminal-pane/use-system-prefers-dark', () => ({ + useSystemPrefersDark: () => false +})) + +/** Signature of the onEmojiClick callback captured by the emoji-picker-react mock. */ +type MockEmojiClickHandler = (data: { emoji: string }) => void + +const emojiPickerMocks = vi.hoisted(() => ({ + onEmojiClick: null as MockEmojiClickHandler | null, + searchPlaceholder: null as string | null +})) + +vi.mock('emoji-picker-react', () => ({ + __esModule: true, + default: (props: { onEmojiClick: MockEmojiClickHandler; searchPlaceholder: string }) => { + emojiPickerMocks.onEmojiClick = props.onEmojiClick + emojiPickerMocks.searchPlaceholder = props.searchPlaceholder + return null + }, + EmojiStyle: { NATIVE: 'native' }, + Theme: { DARK: 'dark', LIGHT: 'light' } +})) + +let container: HTMLDivElement +let root: Root + +/** Renders RepositoryIconEmojiPicker with default props and returns the spy callback. */ +function renderPicker(overrides: Partial[0]> = {}) { + const onSetIcon = vi.fn() + act(() => { + root.render() + }) + return { onSetIcon } +} + +describe('RepositoryIconEmojiPicker', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + emojiPickerMocks.onEmojiClick = null + emojiPickerMocks.searchPlaceholder = null + toastMocks.error.mockReset() + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + document.body.replaceChildren() + }) + + it('renders the full native emoji picker in place of the static grid', () => { + renderPicker() + expect(emojiPickerMocks.onEmojiClick).not.toBeNull() + expect(emojiPickerMocks.searchPlaceholder).toBe('Search emoji') + expect(container.querySelector('.repo-icon-emoji-picker')).not.toBeNull() + }) + + it('saves a valid emoji click through the existing RepoIcon contract', () => { + const { onSetIcon } = renderPicker() + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: '🚀' }) + }) + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ type: 'emoji', emoji: '🚀' }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('rejects an emoji sequence over the sanitizeRepoIcon 16-char cap instead of silently dropping it', () => { + const { onSetIcon } = renderPicker() + // Two ZWJ family sequences back to back: valid codepoints, well over 16 UTF-16 units. + const overlong = '👨‍👩‍👧‍👦👨‍👩‍👧‍👦' + expect(overlong.length).toBeGreaterThan(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: overlong }) + }) + + expect(onSetIcon).not.toHaveBeenCalled() + expect(toastMocks.error).toHaveBeenCalledTimes(1) + }) + + // Guards the skinTonesDisabled removal: skin-toned picks must save like any other emoji, not be blocked. + it('saves a valid skin-tone emoji selection instead of blocking it for global inclusivity', () => { + const { onSetIcon } = renderPicker() + // Base emoji + skin-tone modifier: well within the 16 UTF-16 unit cap. + const thumbsUpMediumSkinTone = '👍🏽' + expect(thumbsUpMediumSkinTone.length).toBeLessThanOrEqual(16) + + act(() => { + emojiPickerMocks.onEmojiClick?.({ emoji: thumbsUpMediumSkinTone }) + }) + + expect(onSetIcon).toHaveBeenCalledExactlyOnceWith({ + type: 'emoji', + emoji: thumbsUpMediumSkinTone + }) + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('shows the currently selected emoji as trailing metadata', () => { + renderPicker({ selectedEmoji: '🎨' }) + expect(container.textContent).toContain('🎨') + }) +}) diff --git a/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx new file mode 100644 index 00000000000..9a97d07fbf6 --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryIconEmojiPicker.tsx @@ -0,0 +1,76 @@ +import { toast } from 'sonner' +import EmojiPicker, { EmojiStyle, Theme, type EmojiClickData } from 'emoji-picker-react' +import type { RepoIcon } from '../../../../shared/repo-icon' +import { sanitizeRepoIcon } from '../../../../shared/repo-icon' +import { useAppStore } from '../../store' +import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' +import { translate } from '@/i18n/i18n' + +type RepositoryIconEmojiPickerProps = { + selectedEmoji: string + onSetIcon: (repoIcon: RepoIcon | null) => void +} + +/** + * Full native emoji picker for the repo icon "Emoji" tab. Kept in its own + * module (lazy-loaded by RepositoryIconTabs) so emoji-picker-react's ~500KB + * chunk only loads once this tab actually renders. + */ +export function RepositoryIconEmojiPicker({ + selectedEmoji, + onSetIcon +}: RepositoryIconEmojiPickerProps): React.JSX.Element { + // Minimal selector: only the theme string is needed; default to system before settings load. + const settingsTheme = useAppStore((state) => state.settings?.theme ?? 'system') + const systemPrefersDark = useSystemPrefersDark() + // Theme.AUTO only follows the OS setting, which can disagree with Orca's manual theme. + const isDarkTheme = settingsTheme === 'dark' || (settingsTheme === 'system' && systemPrefersDark) + + /** Saves the picked emoji, rejecting anything over sanitizeRepoIcon's 16-char cap. */ + const handleEmojiClick = (emojiData: EmojiClickData): void => { + // ZWJ/skin-tone sequences can exceed the 16-char cap even without skinTonesDisabled. + const repoIcon = sanitizeRepoIcon({ type: 'emoji', emoji: emojiData.emoji }) + if (!repoIcon) { + toast.error( + translate( + 'auto.components.settings.RepositoryIconPicker.emojiTooLongForRepoIcon', + "This emoji can't be used as a repo icon." + ) + ) + return + } + onSetIcon(repoIcon) + } + + return ( + <> +

+ +
+ {selectedEmoji ? ( +

+ {translate( + 'auto.components.settings.RepositoryIconPicker.currentEmojiSelection', + 'Current: {{value0}}', + { value0: selectedEmoji } + )} +

+ ) : null} + + ) +} diff --git a/src/renderer/src/components/settings/RepositoryIconTabs.tsx b/src/renderer/src/components/settings/RepositoryIconTabs.tsx index d0507a89db4..1d695da6085 100644 --- a/src/renderer/src/components/settings/RepositoryIconTabs.tsx +++ b/src/renderer/src/components/settings/RepositoryIconTabs.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { Suspense, useState } from 'react' import { toast } from 'sonner' import { Github, Image, Link2 } from 'lucide-react' import type { RepoIcon } from '../../../../shared/repo-icon' @@ -10,8 +10,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip' import { getRepoLucideIconOptions } from '../repo/repo-icon' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { lazyWithRetry } from '@/lib/lazy-with-retry' -const EMOJI_OPTIONS = ['🚀', '✨', '💻', '🧠', '📦', '🔧', '🎨', '🌐', '📊', '🔒', '⚡', '✅'] +// Deferred so emoji-picker-react's ~500KB chunk only loads once the Emoji tab renders. +const RepositoryIconEmojiPicker = lazyWithRetry( + () => + import('./RepositoryIconEmojiPicker').then((module) => ({ + default: module.RepositoryIconEmojiPicker + })), + { reloadKey: 'repo-icon-emoji-picker' } +) type RepositoryIconTabsProps = { initialTab: 'avatar' | 'icon' | 'emoji' @@ -180,24 +188,10 @@ export function RepositoryIconTabs({ - - {EMOJI_OPTIONS.map((emoji) => ( - - ))} + + + + ) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx index f0bb4ba08df..0b32a439dbd 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.test.tsx @@ -7,15 +7,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ listNetworkInterfaces: vi.fn(), - listRuntimeAccessGrants: vi.fn() + listRuntimeAccessGrants: vi.fn(), + getRuntimePairingUrl: vi.fn() })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) vi.mock('sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('./RuntimeAccessGrantList', () => ({ RuntimeAccessGrantList: () => null })) vi.mock('./RuntimePairingGeneratorForm', () => ({ - RuntimePairingGeneratorForm: (props: { selectedAddress: string }) => ( -
{props.selectedAddress}
+ RuntimePairingGeneratorForm: (props: { selectedAddress: string; onGenerate: () => void }) => ( +
+
{props.selectedAddress}
+ +
) })) @@ -33,12 +39,20 @@ describe('RuntimePairingUrlGenerator', () => { runtimePairingLinkCache.runtimePairingDeviceId = null mocks.listNetworkInterfaces.mockReset() mocks.listRuntimeAccessGrants.mockReset().mockResolvedValue({ grants: [] }) + mocks.getRuntimePairingUrl.mockReset().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair#runtime', + webClientUrl: 'http://127.0.0.1:6768/web-index.html?pairing=runtime', + endpoint: 'ws://127.0.0.1:6768', + deviceId: 'runtime-1' + }) Object.defineProperty(window, 'api', { configurable: true, value: { mobile: { listNetworkInterfaces: mocks.listNetworkInterfaces, - listRuntimeAccessGrants: mocks.listRuntimeAccessGrants + listRuntimeAccessGrants: mocks.listRuntimeAccessGrants, + getRuntimePairingUrl: mocks.getRuntimePairingUrl } } }) @@ -67,4 +81,28 @@ describe('RuntimePairingUrlGenerator', () => { expect(screen.getByTestId('selected-address')).toHaveTextContent('100.76.32.125') ) }) + + // Why: the main process gates the one-way network widen on the declared reach, so dropping it (as the + // component used to) leaves main guessing from the address string — "This computer only" then widened, + // and a Custom loopback tunnel front-end would not. + it.each([ + ['local' as const, '127.0.0.1', 'this-computer'], + ['another' as const, '100.76.32.125', 'network'], + ['custom' as const, '127.0.0.1:8443', 'network'] + ])('sends the %s reach with the address', async (intent, address, reach) => { + runtimePairingLinkCache.intent = intent + runtimePairingLinkCache.selectedAddress = address + mocks.listNetworkInterfaces.mockResolvedValue({ + interfaces: [{ name: 'tailscale0', address: '100.76.32.125' }] + }) + + render() + await waitFor(() => expect(mocks.listNetworkInterfaces).toHaveBeenCalledOnce()) + + screen.getByRole('button', { name: 'Generate' }).click() + + await waitFor(() => + expect(mocks.getRuntimePairingUrl).toHaveBeenCalledWith({ address, rotate: true, reach }) + ) + }) }) diff --git a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx index 54c75c7ac92..c6d927de593 100644 --- a/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx +++ b/src/renderer/src/components/settings/RuntimePairingUrlGenerator.tsx @@ -11,6 +11,7 @@ import { cacheGeneratedRuntimePairingLink, clearGeneratedRuntimePairingLink, runtimePairingLinkCache, + runtimePairingReachForIntent, selectRuntimePairingIntent, type RuntimePairingIntent, type RuntimePairingUrlGeneratorProps @@ -187,7 +188,10 @@ export function RuntimePairingUrlGenerator({ try { const result = await window.api.mobile.getRuntimePairingUrl({ address, - rotate: true + rotate: true, + // Why: main gates the one-way network widen on this, so the declared choice must travel with the + // address — the address alone cannot tell "This computer only" from a loopback tunnel front-end. + reach: runtimePairingReachForIntent(intent) }) if (!result.available) { clearGeneratedUrls() diff --git a/src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts b/src/renderer/src/components/settings/browser-link-routing-copy.ts similarity index 58% rename from src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts rename to src/renderer/src/components/settings/browser-link-routing-copy.ts index 1151d3e70e1..5a4d5bac2d6 100644 --- a/src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts +++ b/src/renderer/src/components/settings/browser-link-routing-copy.ts @@ -1,5 +1,30 @@ import { translate } from '@/i18n/i18n' +export function getBrowserLinkRoutingShortcutLabel(platform: { isMac: boolean }): string { + return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' +} + +// Why: "always" stops being true once inverting is on, so only then does the nested +// row take over the chord sentence — with it off this reads exactly as it always has. +// Each variant is a complete catalog entry: stitched suffixes break in locales whose +// sentence joining differs from English. +export function getBrowserLinkRoutingDescription( + platform: { isMac: boolean }, + modifierInverts = false +): string { + if (modifierInverts) { + return translate( + 'auto.components.settings.BrowserLinkRoutingSetting.descriptionBase', + "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor." + ) + } + return translate( + 'auto.components.settings.BrowserLinkRoutingSetting.description', + "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. {{shortcut}} always uses your system browser.", + { shortcut: getBrowserLinkRoutingShortcutLabel(platform) } + ) +} + /** * Title and description both name the destination the modifier reaches, which is * the opposite of wherever Link Routing points. Kept out of the component so the diff --git a/src/renderer/src/components/settings/browser-link-routing-localization.test.ts b/src/renderer/src/components/settings/browser-link-routing-localization.test.ts new file mode 100644 index 00000000000..613f8e0de1c --- /dev/null +++ b/src/renderer/src/components/settings/browser-link-routing-localization.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { i18n } from '@/i18n/i18n' +import { getBrowserLinkRoutingDescription } from './browser-link-routing-copy' + +// The Link Routing description was a bare template literal, so it stayed English in every +// locale while its own title and the rest of the pane translated. It carries the platform +// shortcut label, so each locale must interpolate it rather than embed a hardcoded modifier. +const NON_ENGLISH_LOCALES = ['es', 'ja', 'ko', 'zh'] as const + +const SHORTCUT_BY_PLATFORM = { + mac: '⇧⌘-click', + other: 'Shift+Ctrl+click' +} as const + +describe('Link Routing description localization', () => { + afterEach(async () => { + await i18n.changeLanguage('en') + }) + + it('translates the description in every non-English locale on both platforms', async () => { + const english = { + mac: getBrowserLinkRoutingDescription({ isMac: true }), + other: getBrowserLinkRoutingDescription({ isMac: false }) + } + + for (const locale of NON_ENGLISH_LOCALES) { + await i18n.changeLanguage(locale) + + for (const [platform, shortcut] of Object.entries(SHORTCUT_BY_PLATFORM)) { + const description = getBrowserLinkRoutingDescription({ isMac: platform === 'mac' }) + + expect(description, `${locale}/${platform} fell back to English`).not.toBe( + english[platform as keyof typeof english] + ) + // The label is a key symbol, not prose: it stays literal in every locale. + expect(description, `${locale}/${platform} lost the shortcut label`).toContain(shortcut) + expect(description, `${locale}/${platform} leaked a placeholder`).not.toMatch(/\{\{.+?\}\}/) + } + } + }) + + it('keeps each platform label out of the other platform copy', async () => { + for (const locale of ['en', ...NON_ENGLISH_LOCALES]) { + await i18n.changeLanguage(locale) + + expect(getBrowserLinkRoutingDescription({ isMac: true })).not.toContain( + SHORTCUT_BY_PLATFORM.other + ) + expect(getBrowserLinkRoutingDescription({ isMac: false })).not.toContain( + SHORTCUT_BY_PLATFORM.mac + ) + } + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.test.ts b/src/renderer/src/components/settings/browser-search.test.ts index 2ac1b67239e..0df9173b28d 100644 --- a/src/renderer/src/components/settings/browser-search.test.ts +++ b/src/renderer/src/components/settings/browser-search.test.ts @@ -1,12 +1,25 @@ -import { describe, expect, it } from 'vitest' -import { getBrowserLinkRoutingDescription, getBrowserPaneSearchEntries } from './browser-search' +import { beforeEach, describe, expect, it } from 'vitest' + +import ko from '@/i18n/locales/ko.json' +import { i18n } from '@/i18n/i18n' +import { getBrowserPaneSearchEntries } from './browser-search' import { + getBrowserLinkRoutingDescription, + getBrowserLinkRoutingShortcutLabel, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' describe('browser settings search copy', () => { - it('uses macOS shortcut keywords for Link Routing search metadata', () => { + it('uses macOS shortcut symbols for Link Routing copy and search metadata', () => { + expect(getBrowserLinkRoutingShortcutLabel({ isMac: true })).toBe('⇧⌘-click') + + const description = getBrowserLinkRoutingDescription({ isMac: true }) + expect(description).toContain('⇧⌘-click') + expect(description).not.toContain('Cmd/Ctrl') + // The copy is translated: a leaked `{{...}}` means the interpolation name drifted from the catalog. + expect(description).not.toMatch(/\{\{.+?\}\}/) + const linkRoutingEntry = getBrowserPaneSearchEntries({ isMac: true }).find( (entry) => entry.title === 'Link Routing' ) @@ -20,7 +33,14 @@ describe('browser settings search copy', () => { expect(defaultZoomEntry?.keywords).toContain('zoom') }) - it('uses Ctrl shortcut keywords for Link Routing search metadata off macOS', () => { + it('uses Ctrl shortcut text for Link Routing copy and search metadata off macOS', () => { + expect(getBrowserLinkRoutingShortcutLabel({ isMac: false })).toBe('Shift+Ctrl+click') + + const description = getBrowserLinkRoutingDescription({ isMac: false }) + expect(description).toContain('Shift+Ctrl+click') + expect(description).not.toContain('Cmd/Ctrl') + expect(description).not.toMatch(/\{\{.+?\}\}/) + const linkRoutingEntry = getBrowserPaneSearchEntries({ isMac: false }).find( (entry) => entry.title === 'Link Routing' ) @@ -102,3 +122,61 @@ describe('browser link routing modifier copy', () => { expect(entry?.keywords).toContain(getLinkRoutingModifierTitle(true)) }) }) + +// The bug this file guards: the Link Routing description was a bare template +// literal, so it stayed English in every locale. Asserting only "no {{...}} leaked" +// cannot catch that — the English literal has no placeholder either. +describe('Link Routing description localization', () => { + const KEY = 'auto.components.settings.BrowserLinkRoutingSetting.description' + const BASE_KEY = 'auto.components.settings.BrowserLinkRoutingSetting.descriptionBase' + + beforeEach(async () => { + await i18n.changeLanguage('en') + }) + + it('renders the Korean copy with the shortcut interpolated', async () => { + const koCopy = ( + ko.auto.components.settings.BrowserLinkRoutingSetting as unknown as Record + )['description'] + expect(koCopy).toBeTruthy() + expect(koCopy).toContain('{{shortcut}}') + + i18n.addResourceBundle('ko', 'translation', ko, true, true) + await i18n.changeLanguage('ko') + + const description = getBrowserLinkRoutingDescription({ isMac: true }) + expect(description).toBe(koCopy.replace('{{shortcut}}', '⇧⌘-click')) + expect(description).not.toMatch(/\{\{.+?\}\}/) + // Fails when the copy is a hardcoded English literal. + expect(description).not.toContain("Orca's built-in browser") + + // The entry title is localized too, so match on the description instead. + const entry = getBrowserPaneSearchEntries({ isMac: true }).find( + (item) => item.description === description + ) + expect(entry).toBeDefined() + + await i18n.changeLanguage('en') + expect(getBrowserLinkRoutingDescription({ isMac: true })).toContain("Orca's built-in browser") + }) + + it('renders the Korean copy for the invert-on variant', async () => { + const koBase = ( + ko.auto.components.settings.BrowserLinkRoutingSetting as unknown as Record + )['descriptionBase'] + expect(koBase).toBeTruthy() + + i18n.addResourceBundle('ko', 'translation', ko, true, true) + await i18n.changeLanguage('ko') + + const description = getBrowserLinkRoutingDescription({ isMac: true }, true) + expect(description).toBe(koBase) + // Fails when the invert-on branch regresses to a hardcoded English literal. + expect(description).not.toContain("Orca's built-in browser") + }) + + it('uses the catalog key rather than an inline literal', () => { + expect(i18n.exists(KEY)).toBe(true) + expect(i18n.exists(BASE_KEY)).toBe(true) + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index a9b20444142..67f5506de42 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -2,9 +2,10 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' import { + getBrowserLinkRoutingDescription, getLinkRoutingModifierDescription, getLinkRoutingModifierTitle -} from './browser-link-routing-modifier-copy' +} from './browser-link-routing-copy' type BrowserShortcutPlatform = { isMac: boolean @@ -16,19 +17,6 @@ function getDefaultBrowserShortcutPlatform(): BrowserShortcutPlatform { } } -// Why: "always" stops being true once inverting is on, so only then does the nested -// row take over the chord sentence — with it off this reads exactly as it always has. -export function getBrowserLinkRoutingDescription( - platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform(), - modifierInverts = false -): string { - const base = - "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor." - return modifierInverts - ? base - : `${base} ${platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click'} always uses your system browser.` -} - export function getBrowserPaneSearchEntries( platform: BrowserShortcutPlatform = getDefaultBrowserShortcutPlatform() ): SettingsSearchEntry[] { diff --git a/src/renderer/src/components/settings/jira-integration-card.tsx b/src/renderer/src/components/settings/jira-integration-card.tsx index e1889c2f073..e9f510628c6 100644 --- a/src/renderer/src/components/settings/jira-integration-card.tsx +++ b/src/renderer/src/components/settings/jira-integration-card.tsx @@ -99,7 +99,14 @@ export function JiraIntegrationCard(): React.JSX.Element { } checking={checking} statusTone={connected ? 'connected' : 'attention'} - statusLabel={connected ? 'Connected' : 'Not connected'} + statusLabel={ + connected + ? translate('auto.components.settings.jira.integration.card.statusConnected', 'Connected') + : translate( + 'auto.components.settings.jira.integration.card.statusNotConnected', + 'Not connected' + ) + } actions={ !checking ? ( diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.test.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.test.tsx new file mode 100644 index 00000000000..0035a682479 --- /dev/null +++ b/src/renderer/src/components/sidebar/FilterToggleRow.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment happy-dom + +/** + * Guards the sub-option indent: Tailwind v4 emits `px-*` as `padding-inline`, + * which outranks a physical `pl-*` override and silently flattens the nesting. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FilterToggleRow } from './FilterToggleRow' + +let container: HTMLDivElement +let root: Root + +function renderRow(indented: boolean): HTMLElement { + act(() => { + root.render( + + ) + }) + const row = container.querySelector('[role="switch"]') + if (!(row instanceof HTMLElement)) { + throw new Error('FilterToggleRow did not render a switch') + } + return row +} + +function paddingClasses(row: HTMLElement): string[] { + return row.className.split(/\s+/).filter((cls) => /^p[xlr]?-/.test(cls)) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('FilterToggleRow', () => { + it('indents a sub-option past the row above it', () => { + const indented = paddingClasses(renderRow(true)) + const flat = paddingClasses(renderRow(false)) + expect(indented).toContain('pl-7') + expect(flat).toContain('pl-2') + }) + + it('never pairs the indent with a padding-inline utility that would outrank it', () => { + expect(paddingClasses(renderRow(true)).filter((cls) => cls.startsWith('px-'))).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/sidebar/FilterToggleRow.tsx b/src/renderer/src/components/sidebar/FilterToggleRow.tsx new file mode 100644 index 00000000000..7fbc0b77874 --- /dev/null +++ b/src/renderer/src/components/sidebar/FilterToggleRow.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu' +import { cn } from '@/lib/utils' + +/** + * Switch row shared by the two workspace-filter surfaces (the sidebar filter + * dropdown and the workspace options menu). Extracted so the two copies cannot + * drift as rows are added. + */ +export function FilterToggleRow({ + icon, + label, + ariaLabel, + checked, + onChange, + shortcutLabel, + indented = false +}: { + icon: React.ReactNode + label: string + /** Full sentence for assistive tech when `label` only reads in visual context. */ + ariaLabel?: string + checked: boolean + onChange: (next: boolean) => void + shortcutLabel?: string + /** Renders the row as a sub-option of the row above it. */ + indented?: boolean +}) { + return ( + + ) +} + +export default FilterToggleRow diff --git a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx index 5c944fa3a1e..3aead74fd81 100644 --- a/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx +++ b/src/renderer/src/components/sidebar/HostSectionHeaderMenu.tsx @@ -24,6 +24,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { useMountedRef } from '@/hooks/useMountedRef' import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' +import { sshConnectVerb } from '@/ssh/ssh-connect-verb' import { parseExecutionHostId } from '../../../../shared/execution-host' import { describeRuntimeCompatBlock } from '../../../../shared/protocol-compat' import { @@ -74,18 +75,18 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS const [renameOpen, setRenameOpen] = useState(false) const [removeOpen, setRemoveOpen] = useState(false) const mountedRef = useMountedRef() - const sshConnected = useAppStore((s) => { + const sshStatus = useAppStore((s) => { const parsed = parseExecutionHostId(row.hostId) if (parsed?.kind !== 'ssh') { - return false + return null } - return s.sshConnectionStates.get(parsed.targetId)?.status === 'connected' + return s.sshConnectionStates.get(parsed.targetId)?.status ?? null }) const model = buildHostHeaderMenuModel({ kind: row.kind, health: row.health, - sshConnected, + sshConnected: sshStatus === 'connected', compatibility: row.compatibility }) const removalTarget = resolveHostRemoval(row.hostId) @@ -242,7 +243,7 @@ export function HostSectionHeaderMenu({ row }: { row: HostHeaderRow }): React.JS {model.actions.includes('ssh-reconnect') && ( void runSshAction('connect')}> - {translate('auto.components.sidebar.HostSectionHeaderMenu.63f36455cc', 'Reconnect')} + {sshConnectVerb(sshStatus)} )} {model.actions.includes('ssh-disconnect') && ( diff --git a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx index 40b609aa1e4..d3aaf883e6b 100644 --- a/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx +++ b/src/renderer/src/components/sidebar/RemoveFolderDialog.tsx @@ -59,7 +59,7 @@ const RemoveFolderDialog = React.memo(function RemoveFolderDialog() { const handleConfirm = useCallback(() => { if (repoId) { - void removeProject(repoId) + void removeProject(repoId, { errorFeedback: 'toast' }) } closeModal() }, [closeModal, removeProject, repoId]) diff --git a/src/renderer/src/components/sidebar/SidebarFilter.tsx b/src/renderer/src/components/sidebar/SidebarFilter.tsx index 06c445564a7..7c4836e698d 100644 --- a/src/renderer/src/components/sidebar/SidebarFilter.tsx +++ b/src/renderer/src/components/sidebar/SidebarFilter.tsx @@ -23,15 +23,15 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, - DropdownMenuShortcut, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel' +import { FilterToggleRow } from './FilterToggleRow' import { useShortcutLabel } from '@/hooks/useShortcutLabel' import { searchRepos } from '@/lib/repo-search' -import { cn } from '@/lib/utils' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' +import { isSleepingSweepExemptionNarrowingList } from './visible-worktrees' import { translate } from '@/i18n/i18n' type SidebarFilterProps = { @@ -62,6 +62,10 @@ const SidebarFilter = React.memo(function SidebarFilter({ const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const repos = useAppStore((s) => s.repos) @@ -108,12 +112,19 @@ const SidebarFilter = React.memo(function SidebarFilter({ const selectedCount = selectedRepoIdSet.size const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES + // Why counted: turning the exemption off is the only way that row narrows the + // list — but only while its parent row is on, which is also when it renders. + const hasSleepingExemptionFilter = isSleepingSweepExemptionNarrowingList( + showSleepingWorkspaces, + alwaysShowDefaultBranchWorkspace + ) const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + hasSleepingExemptionFilter || hasRepoFilter const activeFilterCount = (hasSleepingFilter ? 1 : 0) + @@ -121,6 +132,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (hasSleepingExemptionFilter ? 1 : 0) + selectedCount const filteredRepos = useMemo(() => searchRepos(repos, query), [repos, query]) @@ -136,6 +148,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces(false) setHideCliCreatedWorkspaces(false) setHideDetachedHeadWorkspaces(false) + setAlwaysShowDefaultBranchWorkspace(true) setFilterRepoIds([]) }, [ setShowSleepingWorkspaces, @@ -143,6 +156,7 @@ const SidebarFilter = React.memo(function SidebarFilter({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setFilterRepoIds ]) @@ -212,6 +226,24 @@ const SidebarFilter = React.memo(function SidebarFilter({ onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} shortcutLabel={sleepingShortcut === 'Unassigned' ? undefined : sleepingShortcut} /> + {/* Why gated: the exemption only has an effect while sleeping workspaces + are being swept, so it stays hidden until its parent row is on. */} + {!showSleepingWorkspaces && ( + } + label={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarFilter.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> + )} } label={translate( @@ -372,50 +404,4 @@ const SidebarFilter = React.memo(function SidebarFilter({ ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange, - shortcutLabel -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void - shortcutLabel?: string -}) { - return ( - - ) -} - export default SidebarFilter diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx index f3dde9621ea..1ef570524cb 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.test.tsx @@ -4,6 +4,8 @@ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' +import { i18n } from '@/i18n/i18n' +import ko from '@/i18n/locales/ko.json' import SidebarToolbar from './SidebarToolbar' const mocks = vi.hoisted(() => ({ @@ -82,12 +84,15 @@ describe('SidebarToolbar moved workspace board hint', () => { } }) - afterEach(() => { + afterEach(async () => { roots.splice(0).forEach((root) => { act(() => root.unmount()) }) document.body.replaceChildren() vi.clearAllMocks() + // Why: the language-change case mutates the shared i18n instance; restore + // here so a failing assertion cannot leave later tests running in Korean. + await i18n.changeLanguage('en') }) it('does not show the moved hint to brand-new users after their first board click', async () => { @@ -131,6 +136,26 @@ describe('SidebarToolbar moved workspace board hint', () => { expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true') }) + // Why: the toolbar is a React.memo boundary whose props never change on a + // language switch, so without its own useTranslation() subscription it keeps + // the English copy it rendered at boot (the persisted locale is applied + // asynchronously, after the lazy catalog loads). + it('relabels itself when the UI language changes after mount', async () => { + const { container } = await renderToolbar() + expect(container.querySelector('button[aria-label="Workspace board"]')).not.toBeNull() + + await act(async () => { + await i18n.changeLanguage('ko') + }) + + // Why: read the expected copy from the catalog instead of hardcoding it, so + // editing the Korean wording cannot fail this test as a bogus stale-render + // report. + const localized = ko.auto.components.sidebar.SidebarToolbar['49f62c5665'] + expect(localized).not.toBe('Workspace board') + expect(container.querySelector(`button[aria-label="${localized}"]`)).not.toBeNull() + }) + it('renders the profile switcher before settings in the footer controls', async () => { const { container } = await renderToolbar() const html = container.innerHTML diff --git a/src/renderer/src/components/sidebar/SidebarToolbar.tsx b/src/renderer/src/components/sidebar/SidebarToolbar.tsx index ff2e030c7a5..dcf57248839 100644 --- a/src/renderer/src/components/sidebar/SidebarToolbar.tsx +++ b/src/renderer/src/components/sidebar/SidebarToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Kanban } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton' @@ -23,6 +24,12 @@ const SidebarToolbar = React.memo(function SidebarToolbar({ workspaceBoardDragPreviewOpen = false, onWorkspaceBoardToggle }: SidebarToolbarProps) { + // Why: this memo boundary needs its own language subscription, while + // translate() preserves Orca's pseudo-localization behavior. Without it the + // toolbar (and the ScrollToCurrentWorkspaceToolbarButton it renders) keeps + // whatever language was active at boot — English, since the persisted locale + // is applied asynchronously after the lazy catalog loads. + useTranslation() const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false) const movedHintEligibleRef = React.useRef(null) const persistedUIReady = useAppStore((state) => state.persistedUIReady) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx new file mode 100644 index 00000000000..a98d881b09f --- /dev/null +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +/** + * The "Except default branch" exemption only bites during the "Hide sleeping" + * sweep, so its row must stay out of the filter list until the parent row is on. + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + state: {} as Record +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => selector(mocks.state) +})) + +import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' + +const EXEMPTION_LABEL = 'Except default branch' + +function setState(overrides: Record = {}): void { + mocks.state = { + showSleepingWorkspaces: true, + setShowSleepingWorkspaces: vi.fn(), + hideDefaultBranchWorkspace: false, + setHideDefaultBranchWorkspace: vi.fn(), + hideAutomationGeneratedWorkspaces: false, + setHideAutomationGeneratedWorkspaces: vi.fn(), + hideCliCreatedWorkspaces: false, + setHideCliCreatedWorkspaces: vi.fn(), + hideDetachedHeadWorkspaces: false, + setHideDetachedHeadWorkspaces: vi.fn(), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: vi.fn(), + ...overrides + } +} + +let container: HTMLDivElement +let root: Root + +function render(): void { + act(() => { + root.render() + }) +} + +function rowLabels(): string[] { + return Array.from(container.querySelectorAll('[role="switch"]')).map( + (el) => el.textContent?.trim() ?? '' + ) +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('SidebarWorkspaceFilterSection', () => { + it('hides the default-branch exemption row while sleeping workspaces are shown', () => { + setState({ showSleepingWorkspaces: true }) + render() + expect(rowLabels()).not.toContain(EXEMPTION_LABEL) + }) + + it('shows the exemption row once "Hide sleeping" is ticked', () => { + setState({ showSleepingWorkspaces: false }) + render() + expect(rowLabels()).toContain(EXEMPTION_LABEL) + }) + + it('keeps the exemption row hidden even when it is switched off', () => { + setState({ showSleepingWorkspaces: true, alwaysShowDefaultBranchWorkspace: false }) + render() + expect(rowLabels()).not.toContain(EXEMPTION_LABEL) + }) +}) diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx index 20cddcfae49..216009ae8b7 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceFilterSection.tsx @@ -1,8 +1,8 @@ import React from 'react' import { CalendarClock, GitBranch, GitCommitHorizontal, Moon, SquareTerminal } from 'lucide-react' import { useAppStore } from '@/store' -import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' +import { FilterToggleRow } from './FilterToggleRow' const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilterSection() { const showSleepingWorkspaces = useAppStore((s) => s.showSleepingWorkspaces) @@ -17,6 +17,10 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) return ( <> @@ -34,6 +38,24 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter checked={!showSleepingWorkspaces} onChange={(hideSleeping) => setShowSleepingWorkspaces(!hideSleeping)} /> + {/* Why gated: the exemption only has an effect while sleeping workspaces + are being swept, so it stays hidden until its parent row is on. */} + {!showSleepingWorkspaces && ( + } + label={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranch', + 'Except default branch' + )} + ariaLabel={translate( + 'auto.components.sidebar.SidebarWorkspaceFilterSection.keepDefaultBranchAria', + 'Keep the default branch visible while hiding sleeping workspaces' + )} + checked={alwaysShowDefaultBranchWorkspace} + onChange={setAlwaysShowDefaultBranchWorkspace} + /> + )} } label={translate( @@ -74,45 +96,4 @@ const SidebarWorkspaceFilterSection = React.memo(function SidebarWorkspaceFilter ) }) -function FilterToggleRow({ - icon, - label, - checked, - onChange -}: { - icon: React.ReactNode - label: string - checked: boolean - onChange: (next: boolean) => void -}) { - return ( - - ) -} - export default SidebarWorkspaceFilterSection diff --git a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx index b087a3fb8f5..9ea6a9841ef 100644 --- a/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx +++ b/src/renderer/src/components/sidebar/SidebarWorkspaceOptionsMenu.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { DEFAULT_SHOW_SLEEPING_WORKSPACES } from '../../../../shared/constants' +import { isSleepingSweepExemptionNarrowingList } from './visible-worktrees' import SidebarRepositoryFilterSection from './SidebarRepositoryFilterSection' import SidebarWorkspaceFilterSection from './SidebarWorkspaceFilterSection' import { getSidebarHostVisibilityLabel, shouldShowHostScopeControls } from './sidebar-host-options' @@ -40,6 +41,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const repos = useAppStore((s) => s.repos) const setWorkspaceHostScope = useAppStore((s) => s.setWorkspaceHostScope) @@ -78,12 +80,19 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM const hasRepoFilter = selectedCount > 0 const hasSleepingFilter = showSleepingWorkspaces !== DEFAULT_SHOW_SLEEPING_WORKSPACES const hasHostVisibilityFilter = visibleWorkspaceHostIds !== null + // Why gated on the parent row: the exemption only narrows the list during the + // "Hide sleeping" sweep, which is also the only time its row is rendered. + const hasSleepingExemptionFilter = isSleepingSweepExemptionNarrowingList( + showSleepingWorkspaces, + alwaysShowDefaultBranchWorkspace + ) const hasAnyFilter = hasSleepingFilter || hideDefaultBranchWorkspace || hideAutomationGeneratedWorkspaces || hideCliCreatedWorkspaces || hideDetachedHeadWorkspaces || + hasSleepingExemptionFilter || hasRepoFilter || hasHostVisibilityFilter const activeFilterCount = @@ -92,6 +101,7 @@ const SidebarWorkspaceOptionsMenu = React.memo(function SidebarWorkspaceOptionsM (hideAutomationGeneratedWorkspaces ? 1 : 0) + (hideCliCreatedWorkspaces ? 1 : 0) + (hideDetachedHeadWorkspaces ? 1 : 0) + + (hasSleepingExemptionFilter ? 1 : 0) + (hasHostVisibilityFilter ? 1 : 0) + selectedCount const activeFilterLabel = `${activeFilterCount} ${activeFilterCount === 1 ? 'filter' : 'filters'}` diff --git a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx b/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx deleted file mode 100644 index fd4a460e29a..00000000000 --- a/src/renderer/src/components/sidebar/SshDisconnectedDialog.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import { useCallback, useEffect, useState } from 'react' -import { toast } from 'sonner' -import { Loader2, Server, ServerOff } from 'lucide-react' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' -import { useMountedRef } from '@/hooks/useMountedRef' -import { statusColor } from '@/components/settings/SshTargetCard' -import type { SshConnectionStatus } from '../../../../shared/ssh-types' -import { translate } from '@/i18n/i18n' - -type SshDisconnectedDialogProps = { - open: boolean - onOpenChange: (open: boolean) => void - targetId: string - targetLabel: string - status: SshConnectionStatus -} - -const STATUS_MESSAGES: Partial> = { - get disconnected() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This SSH host is not connected.' - ) - }, - get reconnecting() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', - 'Reconnecting to the remote host...' - ) - }, - get 'reconnection-failed'() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnectionFailed', - 'Reconnection to the remote host failed.' - ) - }, - get error() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.376bed88e5', - 'The connection to the remote host encountered an error.' - ) - }, - get 'auth-failed'() { - return translate( - 'auto.components.sidebar.SshDisconnectedDialog.authFailed', - 'Authentication to the remote host failed.' - ) - } -} - -function isReconnectable(status: SshConnectionStatus): boolean { - return ['disconnected', 'reconnection-failed', 'error', 'auth-failed'].includes(status) -} - -export function SshDisconnectedDialog({ - open, - onOpenChange, - targetId, - targetLabel, - status -}: SshDisconnectedDialogProps): React.JSX.Element { - const [connecting, setConnecting] = useState(false) - const mountedRef = useMountedRef() - - const handleReconnect = useCallback(async () => { - setConnecting(true) - try { - await window.api.ssh.connect({ targetId }) - if (mountedRef.current) { - onOpenChange(false) - } - } catch (err) { - toast.error( - err instanceof Error - ? err.message - : translate( - 'auto.components.sidebar.SshDisconnectedDialog.656368f3a2', - 'Reconnection failed' - ) - ) - } finally { - if (mountedRef.current) { - setConnecting(false) - } - } - }, [mountedRef, targetId, onOpenChange]) - - const isConnecting = - connecting || - status === 'connecting' || - status === 'deploying-relay' || - status === 'reconnecting' - const reconnectingMessage = - STATUS_MESSAGES.reconnecting ?? - translate( - 'auto.components.sidebar.SshDisconnectedDialog.reconnecting', - 'Reconnecting to the remote host...' - ) - const disconnectedMessage = - STATUS_MESSAGES.disconnected ?? - translate( - 'auto.components.sidebar.SshDisconnectedDialog.disconnected', - 'This SSH host is not connected.' - ) - const message = isConnecting - ? reconnectingMessage - : (STATUS_MESSAGES[status] ?? disconnectedMessage) - const showReconnect = isReconnectable(status) - - useEffect(() => { - // Window-level Enter handler. The dialog typically appears while focus - // is inside an embedded terminal (xterm) or editor (monaco) that - // aggressively reclaims focus, so dialog-scoped key handlers never - // fire. Listening on window (capture phase) catches Enter regardless - // of where focus actually lives while the dialog is open. - if (!open || !showReconnect || isConnecting) { - return undefined - } - const onKeyDown = (event: KeyboardEvent): void => { - if (event.key !== 'Enter' || event.defaultPrevented) { - return - } - if (event.isComposing) { - return - } - event.preventDefault() - event.stopPropagation() - void handleReconnect() - } - window.addEventListener('keydown', onKeyDown, true) - return () => window.removeEventListener('keydown', onKeyDown, true) - }, [open, showReconnect, isConnecting, handleReconnect]) - - return ( - - - - - {isConnecting ? ( - - ) : ( - - )} - {isConnecting - ? translate( - 'auto.components.sidebar.SshDisconnectedDialog.cb5938ae79', - 'Reconnecting...' - ) - : translate( - 'auto.components.sidebar.SshDisconnectedDialog.11552bf786', - 'SSH Disconnected' - )} - - {message} - - -
- -
- {targetLabel} -
- {isConnecting ? ( - - ) : ( - - )} -
- - - - {showReconnect && ( - - )} - -
-
- ) -} diff --git a/src/renderer/src/components/sidebar/SshTargetRow.tsx b/src/renderer/src/components/sidebar/SshTargetRow.tsx index 3ce6db39e76..98323550c97 100644 --- a/src/renderer/src/components/sidebar/SshTargetRow.tsx +++ b/src/renderer/src/components/sidebar/SshTargetRow.tsx @@ -4,10 +4,17 @@ * Why extracted: keeps AddRepoSteps.tsx under the 400-line oxlint limit * while isolating the inline-connect interaction logic. */ -import React, { useCallback, useRef, useState } from 'react' +import React from 'react' import { Loader2 } from 'lucide-react' import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types' import { translate } from '@/i18n/i18n' +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' +import { + beginSshConnect, + endSshConnect, + isSshConnectInFlight, + useSshConnectInFlight +} from '@/ssh/ssh-connect-in-flight' type Props = { target: SshTarget & { state?: SshConnectionState } @@ -22,15 +29,12 @@ export function SshTargetRow({ onSelect, onConnect }: Props): React.JSX.Element { - const [connecting, setConnecting] = useState(false) - const mountedRef = useRef(true) + // Why: the shared registry replaces local state — every SSH surface dials one connection + // per target, and it survives this row unmounting mid-connect. + const connecting = useSshConnectInFlight(target.id) const status = target.state?.status ?? 'disconnected' const isConnected = status === 'connected' - const isBusy = - connecting || - status === 'connecting' || - status === 'deploying-relay' || - status === 'reconnecting' + const isBusy = connecting || isConnectingSshStatus(status) const dotColor = isConnected ? 'bg-green-500' : isBusy @@ -47,26 +51,17 @@ export function SshTargetRow({ // Why: prevent the row's onClick from also firing and treating the click // as a selection when the target is disconnected. e.stopPropagation() - if (isBusy) { + if (isBusy || isSshConnectInFlight(target.id)) { return } - setConnecting(true) + beginSshConnect(target.id) void onConnect(target.id).finally(() => { - if (mountedRef.current) { - setConnecting(false) - } + endSshConnect(target.id) }) } - const handleRowRootRef = useCallback((node: HTMLDivElement | null): void => { - // Why: SSH connects can resolve after this row is removed; the row ref - // gives the async completion the same guard without a mount-only Effect. - mountedRef.current = node !== null - }, []) - return (
({ default: () =>
})) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => (
{children}
diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 9aa2f31b577..707ce16ec61 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -111,10 +111,6 @@ vi.mock('./WorktreeCardAgents', () => ({ ) })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 3887c52c79d..5aee22c8b16 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -124,10 +124,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () =>
})) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx index 3cc0f9dd1a0..98c2a1b3712 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.hosted-review-refresh.test.tsx @@ -63,10 +63,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx index 71fa30bed26..cbbf5a35b86 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.lineage.test.tsx @@ -53,10 +53,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx index 9df826e66d0..02927202e29 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.merged-pr-display.test.tsx @@ -68,10 +68,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx index 88d5d65d818..9da7873b388 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pinned-repo-icon.test.tsx @@ -59,10 +59,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx index 7df0cd25c94..fef27ea71cc 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.pr-display.test.tsx @@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx index d21085ab3cb..7456cce2faa 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.quick-actions.test.tsx @@ -72,10 +72,6 @@ vi.mock('./WorktreeCardAgents', () => ({ default: () => null })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx index 1b2b6c9f664..19ca3a4e441 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.ssh-reconnect-prompt.test.tsx @@ -13,6 +13,7 @@ const updateWorktreeMeta = vi.fn() let WorktreeCard: typeof WorktreeCardComponent let sshConnectionStates = new Map() let sshTargetLabels = new Map() +let removedSshTargetLabels = new Map() let runtimeStatusByEnvironmentId = new Map() let runtimeEnvironments: { id: string; name: string }[] = [] let sshStateByEnvironment = new Map() @@ -35,7 +36,7 @@ vi.mock('@/store', () => ({ remoteBranchConflictByWorktreeId: {}, runtimeEnvironments, runtimeStatusByEnvironmentId, - removedSshTargetLabels: new Map(), + removedSshTargetLabels, settings: null, sshConnectionStates, sshStateByEnvironment, @@ -70,24 +71,6 @@ vi.mock('./use-worktree-activity-status', () => ({ useWorktreeActivityStatus: () => 'idle' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: ({ - open, - status, - targetLabel - }: { - open: boolean - status: string - targetLabel: string - }) => ( -
- ) -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', @@ -137,6 +120,7 @@ describe('WorktreeCard SSH reconnect prompt', () => { vi.clearAllMocks() sshConnectionStates = new Map() sshTargetLabels = new Map() + removedSshTargetLabels = new Map() runtimeStatusByEnvironmentId = new Map() runtimeEnvironments = [] sshStateByEnvironment = new Map() @@ -144,7 +128,9 @@ describe('WorktreeCard SSH reconnect prompt', () => { worktreeCardProperties = ['status'] }) - it('does not auto-open the blocking reconnect dialog for a restored active disconnected SSH worktree', () => { + // Supersedes the old "does not auto-open the blocking reconnect dialog" assertion: + // the dialog is gone, so no card state can open one. + it('offers an inline reconnect control and never a blocking dialog for a disconnected SSH worktree', () => { sshConnectionStates.set('ssh-target-1', { status: 'disconnected' }) sshTargetLabels.set('ssh-target-1', 'Remote target') @@ -152,11 +138,82 @@ describe('WorktreeCard SSH reconnect prompt', () => { ) - // The dialog is blocking, so being the active/restored card must not steal - // focus app-wide; it only opens on deliberate click (see handleClick). - expect(markup).toContain('data-ssh-disconnected-dialog="closed"') - // The disconnected state is still discoverable via the non-blocking card chip. - expect(markup).toContain('SSH disconnected') + expect(markup).toContain('Connect to SSH host Remote target') + expect(markup).toContain('Connect') + expect(markup).not.toContain('ssh-disconnected-dialog') + // Why: a card-root opacity composites the whole subtree, so it would dim the control's + // destructive tint and spinner in the exact state the control exists for. + expect(markup).not.toContain('opacity-60') + }) + + it('names the failure state in the control verb rather than a generic Connect', () => { + sshConnectionStates.set('ssh-target-1', { status: 'auth-failed' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Reconnect SSH host Remote target') + expect(markup).toContain('authentication failed') + }) + + it('renders the passive host glyph, not a control, when the SSH host is connected', () => { + sshConnectionStates.set('ssh-target-1', { status: 'connected' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Project on SSH host Remote target') + expect(markup).not.toContain('Connect to SSH host') + }) + + // Why: the control keys off repo.connectionId, which is orthogonal to workspace kind — + // a folder workspace on a disconnected SSH host must get the same affordance. + it('offers the control for a folder workspace on a disconnected SSH host', () => { + sshConnectionStates.set('ssh-target-1', { status: 'error' }) + sshTargetLabels.set('ssh-target-1', 'Remote target') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Retry SSH connection to Remote target') + }) + + // Why: ssh:listTargets filters runtime-owned targets out, so "absent from the target list" + // is not evidence of removal — deriving targetRemoved from it would put every ephemeral-VM + // workspace card into the dead "host removed" state. + it('never reports a runtime-owned SSH target as removed', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('was removed') + expect(markup).toContain('Project on SSH host') + }) + + // Why: a removed host can never connect, so the card must not offer a Connect that only fails. + it('reports a removed SSH host once the target list no longer carries it', () => { + sshConnectionStates.set('ssh-target-1', { status: 'error' }) + removedSshTargetLabels.set('ssh-target-1', 'Remote target (removed)') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('was removed') + expect(markup).not.toContain('Retry SSH connection') }) it('marks a runtime-host worktree disconnected when its environment has no status', () => { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index 9cc437fc513..b53a03ec833 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -21,13 +21,13 @@ import { } from 'lucide-react' import CacheTimer, { usePromptCacheCountdownStartedAt } from './CacheTimer' import WorktreeContextMenu from './WorktreeContextMenu' -import { SshDisconnectedDialog } from './SshDisconnectedDialog' import { AutoRenameFailedDialog } from './AutoRenameFailedDialog' import { LinearAgentSkillSetupPrompt } from './LinearAgentSkillSetupPrompt' import WorktreeCardAgents from './WorktreeCardAgents' import { useWorktreeAgentRows } from './useWorktreeAgentRows' import { WorktreeCardStatusSlot } from './WorktreeCardStatusSlot' import { cn } from '@/lib/utils' +import { WorktreeCardSshHostControl } from './WorktreeCardSshHostControl' import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation' import { isFolderRepo } from '../../../../shared/repo-kind' import type { HostedReviewInfo } from '../../../../shared/hosted-review' @@ -94,7 +94,8 @@ import { DEFAULT_AGENT_ACTIVITY_DISPLAY_MODE } from '../../../../shared/constant import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' import { selectRuntimeAwareSshStatus, - selectRuntimeAwareSshTargetLabel + selectRuntimeAwareSshTargetLabel, + selectRuntimeAwareSshTargetRemoved } from '@/store/slices/runtime-environment-ssh' import { hydrateRuntimeEnvironmentSshState } from '@/runtime/runtime-environment-ssh-state' @@ -264,6 +265,9 @@ const WorktreeCard = React.memo(function WorktreeCard({ e.stopPropagation() openModal('edit-meta', { worktreeId: worktree.id, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on the clicked row instead of the ambiguous lookup. + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -279,6 +283,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ e.stopPropagation() openModal('edit-meta', { worktreeId: worktree.id, + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -362,9 +367,13 @@ const WorktreeCard = React.memo(function WorktreeCard({ } }, [sshOwnerEnvironmentId]) const isSshDisconnected = sshStatus != null && sshStatus !== 'connected' - // Why: terminal views have their own reconnect overlay; reserve the blocking dialog for non-terminal views (default to terminal when ambiguous). - const activeViewIsTerminal = useAppStore( - (s) => (s.activeTabTypeByWorktree?.[worktree.id] ?? 'terminal') === 'terminal' + // Why: only reported on positive evidence, so a removed host never offers a Connect that can + // only fail. Runtime-owned targets are excluded for the same reason sshStatus excludes them — + // ssh:listTargets filters them out, so "absent from the target list" is not evidence of removal. + const sshTargetRemoved = useAppStore((s) => + repo?.connectionId && !isRuntimeOwnedSshTargetId(repo.connectionId) + ? selectRuntimeAwareSshTargetRemoved(s, sshOwnerEnvironmentId, repo.connectionId) + : false ) const parsedRepoHost = parseExecutionHostId(repo?.executionHostId) @@ -390,8 +399,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ } return !s.runtimeStatusByEnvironmentId.get(runtimeOwnerEnvironmentId)?.status }) - // Why: the reconnect dialog blocks, so it never auto-shows for the active card (would steal app-wide focus); opens only on deliberate focus (handleClick). - const [showDisconnectedDialog, setShowDisconnectedDialog] = useState(false) const [titleRenaming, setTitleRenaming] = useState(false) const [showRenameErrorDialog, setShowRenameErrorDialog] = useState(false) // Why: read the target label from its owning host's store instead of exposing HUB-private SSH metadata as client-local state. @@ -877,10 +884,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree.id, worktree.hostId ?? (repo ? getRepoExecutionHostId(repo) : undefined) ) - // Why: a deliberate card click warrants the blocking reconnect prompt; skip it when a terminal already shows the overlay. - if (isSshDisconnected && !activeViewIsTerminal) { - setShowDisconnectedDialog(true) - } onActivate?.() }, [ @@ -893,7 +896,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ isDeleting, activationRowKey, isSshDisconnected, - activeViewIsTerminal, onActivate, onImmediateActivate, onSelectionGesture @@ -901,7 +903,11 @@ const WorktreeCard = React.memo(function WorktreeCard({ ) const handleRenameTitle = useCallback( - (displayName: string) => updateWorktreeMeta(worktree.id, { displayName }), + // Inline rename has no surface for the failure; the store already logs and + // refetches, which reverts the optimistic title in place. + async (displayName: string): Promise => { + await updateWorktreeMeta(worktree.id, { displayName }) + }, [updateWorktreeMeta, worktree.id] ) @@ -915,6 +921,7 @@ const WorktreeCard = React.memo(function WorktreeCard({ } openModal('edit-meta', { worktreeId: worktree.id, + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -928,7 +935,8 @@ const WorktreeCard = React.memo(function WorktreeCard({ worktree.displayName, worktree.id, worktree.linkedIssue, - worktree.linkedPR + worktree.linkedPR, + worktree.repoId ] ) @@ -1428,28 +1436,15 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} {repo?.connectionId && ( - - - - {isSshDisconnected ? ( - - ) : ( - - )} - - - - {isSshDisconnected - ? translate( - 'auto.components.sidebar.WorktreeCard.021538e1d1', - 'SSH disconnected' - ) - : translate( - 'auto.components.sidebar.WorktreeCard.ca74db7550', - 'Project on SSH host' - )} - - + )} {!repo?.connectionId && parsedRepoHost?.kind === 'runtime' && ( @@ -1457,7 +1452,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ {isRuntimeDisconnected ? ( - + // Passive by design: runtime ("Orca server") hosts have no + // renderer-reachable connect API, unlike the SSH glyph above which is + // now a control. Don't "fix" the inconsistency by wiring one up. + ) : ( )} @@ -1889,7 +1887,10 @@ const WorktreeCard = React.memo(function WorktreeCard({ ], titleRenaming && '!border-transparent !bg-transparent !shadow-none !ring-0', isDeleting && 'opacity-50 grayscale cursor-not-allowed', - (isSshDisconnected || isRuntimeDisconnected) && !isDeleting && 'opacity-60' + // Why: no SSH dim — the inline host control now states the disconnected state + // explicitly, and a subtree opacity would composite its destructive tint and spinner + // down to an illegible alpha (a descendant cannot escape an ancestor's opacity). + isRuntimeDisconnected && !isDeleting && 'opacity-60' )} data-worktree-card-surface="true" data-worktree-card-active={isActiveSurface ? activeSurfaceVariant : undefined} @@ -1940,16 +1941,6 @@ const WorktreeCard = React.memo(function WorktreeCard({ )} - {repo?.connectionId && ( - - )} - {typeof worktree.firstAgentMessageRenameError === 'string' && worktree.firstAgentMessageRenameError.length > 0 && ( ({ error: vi.fn() })) + +const environmentSshMocks = vi.hoisted(() => ({ + connectRuntimeEnvironmentSshTarget: vi.fn(), + resyncRuntimeEnvironmentSshTargets: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: toastMocks.error } })) + +vi.mock('@/runtime/runtime-environment-ssh-state', () => environmentSshMocks) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string, values?: Record) => + fallback.replace('{{value0}}', values?.value0 ?? '') +})) + +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children} +})) + +function installSshApi( + connect: ReturnType, + overrides: Record> = {} +): void { + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ssh: { + connect, + listTargets: vi.fn().mockResolvedValue([]), + listRemovedTargetLabels: vi.fn().mockResolvedValue({}), + ...overrides + } + } + }) +} + +function renderControl( + props: Partial> = {} +) { + return render( + {}} + {...props} + /> + ) +} + +describe('WorktreeCardSshHostControl', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + resetSshConnectInFlightForTests() + toastMocks.error.mockReset() + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockReset() + environmentSshMocks.resyncRuntimeEnvironmentSshTargets.mockReset() + installSshApi(vi.fn().mockResolvedValue(undefined)) + }) + + afterEach(() => { + cleanup() + }) + + it('offers a Connect control naming the host for a disconnected target', () => { + renderControl() + + expect(screen.getByRole('button', { name: 'Connect to SSH host devbox' })).toBeEnabled() + }) + + // Why: the verb must match the terminal overlay, host-header menu, and status-bar row. + it.each([ + ['auth-failed', 'Reconnect'], + ['error', 'Retry'], + ['reconnection-failed', 'Retry'], + ['disconnected', 'Connect'] + ] as const)('labels the %s state %s', (status, verb) => { + renderControl({ status }) + + expect(screen.getByRole('button')).toHaveTextContent(verb) + }) + + it('distinguishes an auth failure from a generic connection failure in the tooltip', () => { + const { container } = renderControl({ status: 'auth-failed' }) + expect(container.querySelector('[data-tooltip]')).toHaveTextContent( + 'devbox · authentication failed' + ) + + cleanup() + const retry = renderControl({ status: 'error' }) + expect(retry.container.querySelector('[data-tooltip]')).toHaveTextContent( + 'devbox · connection failed' + ) + }) + + it.each(['error', 'reconnection-failed', 'auth-failed'] as const)( + 'tints the %s state with the destructive token, not the quiet one', + (status) => { + renderControl({ status }) + + expect(screen.getByRole('button')).toHaveClass('text-destructive') + } + ) + + it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)( + 'shows a disabled busy control while the host is %s', + (status) => { + renderControl({ status }) + + const button = screen.getByRole('button', { name: 'Connecting to SSH host devbox' }) + // Why: aria-disabled, not disabled — a real `disabled` would let a second click fall + // through to the card and would kill the tooltip for the whole in-flight window. + expect(button).toHaveAttribute('aria-disabled', 'true') + expect(button).toHaveAttribute('aria-busy', 'true') + expect(button).not.toBeDisabled() + } + ) + + it('renders the passive host glyph, not a control, when connected', () => { + renderControl({ status: 'connected' }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: runtime-owned targets deliberately have no renderer-visible status; the card has + // always shown the plain host glyph there rather than a false disconnected state. + it('renders the passive host glyph for a null status', () => { + renderControl({ status: null }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: this is the exact bug targetRemoved exists to prevent — a Connect that can only fail. + it('never offers Connect for a removed host, even in a failed state', () => { + renderControl({ status: 'error', targetRemoved: true }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('SSH host devbox was removed')).toBeInTheDocument() + }) + + it('keeps the label available to assistive tech but not on screen in icon-only mode', () => { + renderControl({ iconOnly: true }) + + // Why: aria-label is the accessible name, so a second sr-only copy would be dead markup. + const button = screen.getByRole('button', { name: 'Connect to SSH host devbox' }) + expect(button).not.toHaveTextContent('Connect') + expect(button).toHaveClass('w-4') + }) + + it('connects and mirrors the returned state so deferred PTY reattach can resume', async () => { + const connectedState: SshConnectionState = { + targetId: 'ssh-target-1', + status: 'connected', + error: null, + reconnectAttempt: 0, + remotePlatform: 'linux' + } + const connect = vi.fn().mockResolvedValue(connectedState) + installSshApi(connect) + const user = userEvent.setup() + renderControl() + + await user.click(screen.getByRole('button')) + + expect(connect).toHaveBeenCalledWith({ targetId: 'ssh-target-1' }) + await waitFor(() => + expect(useAppStore.getState().sshConnectionStates.get('ssh-target-1')).toEqual(connectedState) + ) + }) + + // Why: reconnecting a host and navigating to its workspace are separate intents; the + // control sits inside the card's own click target. + it('does not activate the surrounding card when clicked', async () => { + const onCardClick = vi.fn() + const user = userEvent.setup() + render( +
+ {}} + /> +
+ ) + + await user.click(screen.getByRole('button')) + + expect(onCardClick).not.toHaveBeenCalled() + }) + + it('reports connect failures and resyncs target metadata so a ghost host converges', async () => { + const connect = vi.fn().mockRejectedValue(new Error('SSH target "ssh-target-1" not found')) + const listTargets = vi + .fn() + .mockResolvedValue([ + { id: 'ssh-live', label: 'devbox', host: 'devbox', port: 22, username: 'me' } + ]) + const listRemovedTargetLabels = vi + .fn() + .mockResolvedValue({ 'ssh-target-1': 'devbox (removed)' }) + installSshApi(connect, { listTargets, listRemovedTargetLabels }) + const user = userEvent.setup() + renderControl() + + await user.click(screen.getByRole('button')) + + await waitFor(() => + expect(toastMocks.error).toHaveBeenCalledWith('SSH target "ssh-target-1" not found') + ) + await waitFor(() => { + expect(useAppStore.getState().sshTargetLabels.get('ssh-live')).toBe('devbox') + expect(useAppStore.getState().removedSshTargetLabels.get('ssh-target-1')).toBe( + 'devbox (removed)' + ) + }) + }) + + it('routes connect to the owning Orca server for a remote-owned target', async () => { + const connect = vi.fn().mockResolvedValue(undefined) + installSshApi(connect) + environmentSshMocks.connectRuntimeEnvironmentSshTarget.mockResolvedValue(null) + const user = userEvent.setup() + renderControl({ sshOwnerEnvironmentId: 'env-1' }) + + await user.click(screen.getByRole('button')) + + await waitFor(() => + expect(environmentSshMocks.connectRuntimeEnvironmentSshTarget).toHaveBeenCalledWith( + 'env-1', + 'ssh-target-1' + ) + ) + // The local ssh API must never see a remote host's target. + expect(connect).not.toHaveBeenCalled() + }) + + // Why: N cards can share one host, and a passphrase-gated target would prompt N times. + it('suppresses a sibling card dialing a host that is already connecting', async () => { + const connect = vi.fn().mockReturnValue(new Promise(() => {})) + installSshApi(connect) + const user = userEvent.setup() + render( + <> + {}} + /> + {}} + /> + + ) + + const [first, second] = screen.getAllByRole('button') + await user.click(first) + + await waitFor(() => expect(second).toHaveAttribute('aria-disabled', 'true')) + expect(connect).toHaveBeenCalledTimes(1) + }) + + // Why: aria-disabled leaves the button clickable, so the handler owns the guard. + it('ignores a second click while its own connect is in flight', async () => { + const connect = vi.fn().mockReturnValue(new Promise(() => {})) + installSshApi(connect) + const user = userEvent.setup() + renderControl() + + const button = screen.getByRole('button') + await user.click(button) + await user.click(button) + + expect(connect).toHaveBeenCalledTimes(1) + }) + + // Why: the sidebar scroll root treats a bubbled Enter as "focus the terminal", which would + // cancel the button's activation and move focus off the card. + it('does not let Enter or Space bubble to the sidebar key handler', async () => { + const onContainerKeyDown = vi.fn() + const user = userEvent.setup() + render( +
+ {}} + /> +
+ ) + + screen.getByRole('button').focus() + await user.keyboard('{Enter}') + await user.keyboard(' ') + + expect(onContainerKeyDown).not.toHaveBeenCalled() + }) + + it('shows the connected glyph even if a stale removal tombstone is present', () => { + renderControl({ status: 'connected', targetRemoved: true }) + + expect(screen.queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByText('Project on SSH host devbox')).toBeInTheDocument() + }) + + // Why: the title row hosts sibling h-4 pills; a taller state would shift every card on the host. + it('carries one height class across every actionable state', () => { + const heights = new Set() + for (const status of [ + 'disconnected', + 'error', + 'auth-failed', + 'reconnection-failed', + 'connecting', + 'deploying-relay', + 'reconnecting' + ] as const) { + cleanup() + renderControl({ status }) + const classes = screen.getByRole('button').className + heights.add(classes.split(' ').find((token) => token.startsWith('h-')) ?? 'none') + } + + expect([...heights]).toEqual(['h-4']) + }) + + // Why: the passive branch swaps the 16px button for a bare glyph span; a larger icon there + // would make the row taller in exactly the states with nothing to act on. + it.each([ + ['connected', false], + [null, false], + ['error', true] + ] as const)('sizes the passive glyph for %s at size-3', (status, targetRemoved) => { + const { container } = renderControl({ status, targetRemoved }) + + expect(container.querySelector('svg')).toHaveClass('size-3') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx new file mode 100644 index 00000000000..890baba32e7 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeCardSshHostControl.tsx @@ -0,0 +1,283 @@ +import { useCallback } from 'react' +import { Loader2, Server, ServerOff } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { + connectRuntimeEnvironmentSshTarget, + resyncRuntimeEnvironmentSshTargets +} from '@/runtime/runtime-environment-ssh-state' +import { canConnectSshStatus, isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' +import { sshConnectingLabel, sshConnectVerb } from '@/ssh/ssh-connect-verb' +import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' +import { + isSshConnectInFlight, + trackSshConnect, + useSshConnectInFlight +} from '@/ssh/ssh-connect-in-flight' +import type { SshConnectionStatus } from '../../../../shared/ssh-types' + +type WorktreeCardSshHostControlProps = { + targetId: string + /** Card passes `sshTargetLabel || repo.displayName` — the selector can return a bare target id. */ + targetLabel: string + /** Null for runtime-owned targets: renders the passive connected glyph, as before. */ + status: SshConnectionStatus | null + targetRemoved: boolean + /** Non-null when the SSH target belongs to a remote Orca server; routes connect to that runtime. */ + sshOwnerEnvironmentId: string | null + /** True when the row cannot afford a visible label: icon-only with an sr-only label. */ + iconOnly: boolean + onPointerDown: React.PointerEventHandler +} + +// One shape for every state, from the sibling rename-failed control in the same title row +// (WorktreeCard.tsx). States differ only by color token, so the pill never changes height. +const PILL_BASE = + 'h-4 shrink-0 gap-0.5 rounded !px-0.5 text-[10px] font-medium leading-none has-[>svg]:!px-0.5' +const PILL_QUIET = + 'text-muted-foreground border border-worktree-sidebar-border bg-worktree-sidebar shadow-none hover:bg-worktree-sidebar-accent hover:text-foreground focus-visible:border-worktree-sidebar-border focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring' +const PILL_FAILED = + 'text-destructive border border-destructive/40 bg-destructive/10 hover:bg-destructive/15 hover:text-destructive focus-visible:border-destructive/40 focus-visible:ring-1 focus-visible:ring-worktree-sidebar-ring' + +function PassiveGlyph({ + icon, + tooltip, + accessibleName, + targetLabel +}: { + icon: React.ReactNode + tooltip: string + accessibleName: string + targetLabel: string +}): React.JSX.Element { + return ( + + + + {icon} + {accessibleName} + + + + {tooltip} + + + ) +} + +export function WorktreeCardSshHostControl({ + targetId, + targetLabel, + status, + targetRemoved, + sshOwnerEnvironmentId, + iconOnly, + onPointerDown +}: WorktreeCardSshHostControlProps): React.JSX.Element | null { + const setSshConnectionState = useAppStore((store) => store.setSshConnectionState) + // Why: shared registry, not local state — the terminal overlay and every other card on + // this host dial the same connection, and the store status lags a click by one IPC hop. + const inFlight = useSshConnectInFlight(targetId) + + const handleConnect = useCallback(async () => { + if (isSshConnectInFlight(targetId) || isConnectingSshStatus(status)) { + return + } + try { + if (sshOwnerEnvironmentId) { + // Bucket state is written inside the helper, mirroring the local path. + await trackSshConnect( + targetId, + connectRuntimeEnvironmentSshTarget(sshOwnerEnvironmentId, targetId) + ) + } else { + // Why: track the connect request, not this bounded wait — the backend is still + // dialing after the UI timeout fires, so releasing here would let the next click + // raise a second credential prompt. + const connectState = await withUiConnectTimeout( + trackSshConnect(targetId, window.api.ssh.connect({ targetId })), + SSH_RECONNECT_UI_TIMEOUT_MS + ) + if (connectState) { + // Why: ssh.connect can resolve before the global state-change IPC lands; + // the waiting deferred PTY reattach path keys off this renderer store. + setSshConnectionState(targetId, connectState) + } + } + } catch (err) { + toast.error( + err instanceof Error + ? err.message + : translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectFailed', + 'SSH connection failed' + ) + ) + // Why: a failed connect usually means the renderer's target metadata is stale + // (target removed, or re-added under a new id). Resync so the control converges to + // the removed state instead of offering the same failing Connect forever (STA-1468). + // Apply the target list first — a removed-labels failure must not discard it. + if (sshOwnerEnvironmentId) { + void resyncRuntimeEnvironmentSshTargets(sshOwnerEnvironmentId).catch(() => {}) + } else { + void (async () => { + const targets = await window.api.ssh.listTargets() + useAppStore.getState().setSshTargetsMetadata(targets) + const removedLabels = await window.api.ssh.listRemovedTargetLabels() + useAppStore.getState().setRemovedSshTargetLabels(removedLabels) + })().catch(() => {}) + } + } + }, [setSshConnectionState, sshOwnerEnvironmentId, status, targetId]) + + // A live connection outranks a stale removal tombstone. A null status is a runtime-owned + // target: no renderer-reachable connect, and the card has always shown the plain host glyph + // there rather than a false disconnected state. + if (status === null || status === 'connected') { + return ( + } + tooltip={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectedTooltip', + 'Project on SSH host' + )} + accessibleName={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectedName', + 'Project on SSH host {{value0}}', + { value0: targetLabel } + )} + /> + ) + } + + // Why: a removed host can never connect, so it is checked before any failure status — + // offering Connect there is the exact bug targetRemoved exists to prevent. It also drops the + // destructive tint: a removed host is a settled fact, not an error to act on. + if (targetRemoved) { + return ( + } + tooltip={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.removedTooltip', + 'SSH host removed — reconnect unavailable' + )} + accessibleName={translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.removedName', + 'SSH host {{value0}} was removed', + { value0: targetLabel } + )} + /> + ) + } + + const connecting = inFlight || isConnectingSshStatus(status) + const canConnect = canConnectSshStatus(status) + if (!connecting && !canConnect) { + // Defensive: every remaining member is either connecting or recoverable, but never + // render a dead button if the union grows. + return null + } + + const failed = status === 'error' || status === 'reconnection-failed' || status === 'auth-failed' + const label = connecting ? sshConnectingLabel() : sshConnectVerb(status) + const accessibleName = connecting + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectingName', + 'Connecting to SSH host {{value0}}', + { value0: targetLabel } + ) + : status === 'auth-failed' + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedName', + 'Reconnect SSH host {{value0}} — authentication failed', + { value0: targetLabel } + ) + : failed + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.retryName', + 'Retry SSH connection to {{value0}}', + { value0: targetLabel } + ) + : translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.connectName', + 'Connect to SSH host {{value0}}', + { value0: targetLabel } + ) + const tooltip = connecting + ? accessibleName + : status === 'auth-failed' + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.authFailedTooltip', + '{{value0}} · authentication failed', + { value0: targetLabel } + ) + : failed + ? translate( + 'auto.components.sidebar.WorktreeCardSshHostControl.failedTooltip', + '{{value0}} · connection failed', + { value0: targetLabel } + ) + : accessibleName + + return ( + + + + + + {tooltip} + + + ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index ea77889e6c7..ac01350de30 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -598,6 +598,9 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ const handleRename = useCallback(() => { openModal('edit-meta', { worktreeId: worktree.id, + // Why: the same workspace ID can exist under two hosts. Naming the owner + // keeps the dialog on this row instead of the ambiguous lookup. + repoId: worktree.repoId, currentDisplayName: worktree.displayName, currentIssue: worktree.linkedIssue, currentPR: worktree.linkedPR, @@ -606,6 +609,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ }) }, [ worktree.id, + worktree.repoId, worktree.displayName, worktree.linkedIssue, worktree.linkedPR, diff --git a/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx new file mode 100644 index 00000000000..6a7306d0100 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeIssueLinkField.tsx @@ -0,0 +1,260 @@ +import React, { useCallback, useId, useMemo } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { LinearIcon } from '@/components/icons/LinearIcon' +import { ChevronDown, ExternalLink, Github, LoaderCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import { + ISSUE_LINK_PROVIDERS, + isIssueLinkProvider, + type IssueLinkProvider +} from '../../../../shared/issue-link-input' + +// Why: the value must clear the chip and the open-issue button that float over +// the input's right edge. Provider labels are localized, so the text share is +// measured in `ch` rather than baked into a Tailwind `pr-*` step that a longer +// translation would overlap. +const ADORNMENT_RESERVE_PX = 70 +const MAX_RESERVE = '55%' + +/** Exported for test: happy-dom drops `min()`, so this cannot be read back off a style. */ +export function issueAdornmentReserve(providerLabel: string): string { + return `min(calc(${ADORNMENT_RESERVE_PX}px + ${providerLabel.length}ch), ${MAX_RESERVE})` +} + +function providerLabel(provider: IssueLinkProvider): string { + return provider === 'linear' + ? translate('auto.components.sidebar.WorktreeIssueLinkField.25852bfc59', 'Linear') + : translate('auto.components.sidebar.WorktreeIssueLinkField.5b440069e6', 'GitHub') +} + +function ProviderIcon({ + provider, + className +}: { + provider: IssueLinkProvider + className?: string +}): React.JSX.Element { + return provider === 'linear' ? ( + + ) : ( + + ) +} + +export type WorktreeIssueLinkFieldProps = { + inputRef: React.RefObject + value: string + provider: IssueLinkProvider + /** Set when the current value cannot be saved, so the row can explain why. */ + isInvalid: boolean + /** Names the links this save would drop; both slots can be filled at once. */ + displacedLinkLabels: string[] | null + /** Folder workspaces have no persisted link slots — see the design doc. */ + isReadOnly: boolean + canOpenIssue: boolean + openingIssue: boolean + /** The last open attempt resolved to nothing — see useWorktreeIssueLink. */ + openIssueFailed: boolean + onValueChange: (value: string) => void + onProviderChange: (provider: IssueLinkProvider) => void + onOpenIssue: () => void + onKeyDown: (event: React.KeyboardEvent) => void +} + +export function WorktreeIssueLinkField(props: WorktreeIssueLinkFieldProps): React.JSX.Element { + const { + inputRef, + value, + provider, + isInvalid, + displacedLinkLabels, + isReadOnly, + canOpenIssue, + openingIssue, + openIssueFailed, + onValueChange, + onProviderChange, + onOpenIssue, + onKeyDown + } = props + + // Why: the helper `

` is the only surface for the invalid and displacement + // states, so it has to be announced with the field rather than just seen. + const helperId = useId() + const inputId = useId() + const label = providerLabel(provider) + const openIssueLabel = translate( + 'auto.components.sidebar.WorktreeIssueLinkField.161b2d053a', + 'Open linked issue' + ) + + const handleProviderChange = useCallback( + (next: string) => { + if (isIssueLinkProvider(next)) { + onProviderChange(next) + } + }, + [onProviderChange] + ) + + const helperText = useMemo(() => { + if (isReadOnly) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.d4785f9954', + "Issue links are set when a folder workspace is created and can't be changed here yet." + ) + } + if (isInvalid) { + return provider === 'linear' + ? translate( + 'auto.components.sidebar.WorktreeIssueLinkField.964d9bc00a', + 'Not a Linear issue key or linear.app issue URL.' + ) + : translate( + 'auto.components.sidebar.WorktreeIssueLinkField.0a7a2c6efd', + 'Not a GitHub issue number or issue URL.' + ) + } + // Why: ranked above displacement because it answers the click the user just + // made, and it clears as soon as they edit the value that caused it. + if (openIssueFailed) { + return provider === 'linear' + ? translate( + 'auto.components.sidebar.WorktreeIssueLinkField.d8c8a30d1f', + "Couldn't open that issue. Check the identifier and your Linear connection." + ) + : translate( + 'auto.components.sidebar.WorktreeIssueLinkField.269198eeda', + "Couldn't open that issue. Check the number and your GitHub connection." + ) + } + // Whole sentences per arity rather than a joined list: a translated " and " + // fragment would not survive languages that order or punctuate lists differently. + if (displacedLinkLabels && displacedLinkLabels.length > 1) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.72486800ff', + 'Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.', + { first: displacedLinkLabels[0], second: displacedLinkLabels[1] } + ) + } + if (displacedLinkLabels?.length) { + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.2c245ac134', + 'Saving unlinks {{link}} — a workspace tracks one issue.', + { link: displacedLinkLabels[0] } + ) + } + return translate( + 'auto.components.sidebar.WorktreeIssueLinkField.f047887705', + 'Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.' + ) + }, [displacedLinkLabels, isInvalid, isReadOnly, openIssueFailed, provider]) + + return ( +

+ +
+ onValueChange(e.target.value)} + onKeyDown={onKeyDown} + disabled={isReadOnly} + aria-invalid={isInvalid || undefined} + placeholder={translate( + 'auto.components.sidebar.WorktreeIssueLinkField.662ae142f8', + 'Issue #, or a GitHub or Linear URL' + )} + className="h-8 text-xs" + style={{ paddingRight: issueAdornmentReserve(label) }} + /> +
+ {/* Why: `modal` would stack a second scroll-lock layer over the + Dialog's, whose nested teardown strands pointer-events on body. */} + + + + + + + {ISSUE_LINK_PROVIDERS.map((item) => ( + + + {providerLabel(item)} + + ))} + + + + + + + + + {openIssueLabel} + + +
+
+ {/* Why: two lines are reserved up front. The dialog is centered by + transform, so a wrap-count change would shift the field being typed in. */} + {/* Why: the failure and displacement messages appear without focus moving + here, so only a live region announces them. */} +

+ {helperText} +

+
+ ) +} diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 82630b66d8b..5a644f0d265 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -144,10 +144,6 @@ vi.mock('./CacheTimer', () => ({ // NOTE: intentionally NOT mocking ./WorktreeCardAgents — we render the real one. -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts index 53d16e0d7b4..9d97e502065 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts @@ -212,26 +212,6 @@ vi.mock('./WorktreeContextMenu', () => ({ WORKTREE_CONTEXT_MENU_SCOPE_ATTR: 'data-orca-context-menu-scope' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: ({ - open, - status, - targetId, - targetLabel - }: { - open: boolean - status: string - targetId: string - targetLabel: string - }) => - React.createElement('aside', { - 'data-lineage-ssh-dialog': open ? 'open' : 'closed', - 'data-ssh-status': status, - 'data-ssh-target-id': targetId, - 'data-ssh-target-label': targetLabel - }) -})) - vi.mock('@/components/ui/tooltip', () => ({ Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 7a5dfbb13f9..52f3a591473 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -142,10 +142,6 @@ vi.mock('./WorktreeCardAgents', () => ({ SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index 0311879c5b7..00c9e47a0c8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -143,10 +143,6 @@ vi.mock('./WorktreeCardAgents', () => ({ SUPPRESS_WORKTREE_LIST_SCROLL_ADJUSTMENT_EVENT: 'orca:test-suppress-scroll-adjustment' })) -vi.mock('./SshDisconnectedDialog', () => ({ - SshDisconnectedDialog: () => null -})) - vi.mock('./WorktreeContextMenu', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}, CLOSE_ALL_CONTEXT_MENUS_EVENT: 'orca:test-close-context-menus', diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index e21ccdd01e9..422b765f082 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5222,6 +5222,7 @@ const WorktreeList = React.memo(function WorktreeList({ const hideAutomationGeneratedWorkspaces = useAppStore((s) => s.hideAutomationGeneratedWorkspaces) const hideCliCreatedWorkspaces = useAppStore((s) => s.hideCliCreatedWorkspaces) const hideDetachedHeadWorkspaces = useAppStore((s) => s.hideDetachedHeadWorkspaces) + const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace) const filterRepoIds = useAppStore((s) => s.filterRepoIds) const openModal = useAppStore((s) => s.openModal) const openSettingsPage = useAppStore((s) => s.openSettingsPage) @@ -5509,6 +5510,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, repoMap, workspaceHostScope, visibleWorkspaceHostIds, @@ -5526,6 +5528,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, settings, @@ -6487,6 +6490,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope }), @@ -6497,6 +6501,7 @@ const WorktreeList = React.memo(function WorktreeList({ hideAutomationGeneratedWorkspaces, hideCliCreatedWorkspaces, hideDetachedHeadWorkspaces, + alwaysShowDefaultBranchWorkspace, visibleWorkspaceHostIds, workspaceHostScope ] @@ -6509,6 +6514,9 @@ const WorktreeList = React.memo(function WorktreeList({ ) const setHideCliCreatedWorkspaces = useAppStore((s) => s.setHideCliCreatedWorkspaces) const setHideDetachedHeadWorkspaces = useAppStore((s) => s.setHideDetachedHeadWorkspaces) + const setAlwaysShowDefaultBranchWorkspace = useAppStore( + (s) => s.setAlwaysShowDefaultBranchWorkspace + ) const setFilterRepoIds = useAppStore((s) => s.setFilterRepoIds) const setVisibleWorkspaceHostIds = useAppStore((s) => s.setVisibleWorkspaceHostIds) @@ -6532,6 +6540,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (actions.resetHideDetachedHeadWorkspaces) { setHideDetachedHeadWorkspaces(false) } + if (actions.resetAlwaysShowDefaultBranchWorkspace) { + setAlwaysShowDefaultBranchWorkspace(true) + } if (actions.resetVisibleWorkspaceHostIds) { setVisibleWorkspaceHostIds(null) } @@ -6542,6 +6553,7 @@ const WorktreeList = React.memo(function WorktreeList({ setHideAutomationGeneratedWorkspaces, setHideCliCreatedWorkspaces, setHideDetachedHeadWorkspaces, + setAlwaysShowDefaultBranchWorkspace, setVisibleWorkspaceHostIds, filterState ]) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx new file mode 100644 index 00000000000..b45585a4628 --- /dev/null +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.test.tsx @@ -0,0 +1,574 @@ +// @vitest-environment happy-dom + +import { act, type ReactNode } from 'react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type { + FolderWorkspace, + LinearIssue, + Repo, + Worktree, + WorktreeMeta +} from '../../../../shared/types' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' + +// Why: Radix tooltips need a provider the dialog does not own, and the menu's +// portal needs real layout. Stand-ins keep these tests on provider selection. +vi.mock('@/components/ui/tooltip', () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipContent: () => null, + TooltipTrigger: ({ children }: { children?: ReactNode }) => <>{children} +})) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React = await import('react') + const SelectContext = React.createContext<(value: string) => void>(() => {}) + const Passthrough = ({ children }: { children?: ReactNode }) => <>{children} + return { + DropdownMenu: Passthrough, + DropdownMenuTrigger: Passthrough, + DropdownMenuContent: Passthrough, + DropdownMenuRadioGroup: ({ + value, + onValueChange, + children + }: { + value: string + onValueChange: (value: string) => void + children?: ReactNode + }) => ( + +
{children}
+
+ ), + DropdownMenuRadioItem: ({ value, children }: { value: string; children?: ReactNode }) => { + const onSelect = React.useContext(SelectContext) + return ( + + ) + } + } +}) + +import WorktreeMetaDialog from './WorktreeMetaDialog' + +const REPO_ID = 'repo-1' +const WORKTREE_ID = 'repo-1::/repo/worktrees/feature' + +const initialState = useAppStore.getInitialState() +const updateWorktreeMeta = + vi.fn< + ( + id: string, + updates: Partial + ) => Promise<{ ok: true } | { ok: false; error: string }> + >() +const fetchLinearIssue = vi.fn<(...args: never[]) => Promise>() +const openUrl = vi.fn<(url: string) => void>() + +/** Only `url` is read by the open-issue path. */ +function makeLinearIssue(url: string): LinearIssue { + return { url } as LinearIssue +} + +function makeRepo(id: string = REPO_ID, path: string = '/repo'): Repo { + return { id, path, displayName: 'orca', badgeColor: '#999999', addedAt: 1 } +} + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: WORKTREE_ID, + repoId: REPO_ID, + path: '/repo/worktrees/feature', + displayName: 'Feature work', + branch: 'feature', + head: 'abc123', + isBare: false, + isMainWorktree: false, + comment: 'existing note', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + ...overrides + } +} + +function makeFolderWorkspace(overrides: Partial = {}): FolderWorkspace { + return { + id: 'fw-1', + projectGroupId: 'pg-1', + name: 'Docs folder', + folderPath: '/repo/docs', + linkedTask: { + provider: 'linear', + type: 'issue', + number: 901, + title: 'Fix auth', + url: 'https://linear.app/acme/issue/STA-901', + linearIdentifier: 'STA-901' + }, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1, + ...overrides + } +} + +function openDialog( + options: { + worktree?: Partial + worktreeId?: string + folderWorkspace?: Partial + /** Extra owners of the same workspace ID, which the index reads as ambiguous. */ + otherRepos?: { repoId: string; worktree?: Partial }[] + modalRepoId?: string + linearViewerOrganizationUrlKey?: string + } = {} +): void { + const worktree = makeWorktree(options.worktree) + const otherRepos = options.otherRepos ?? [] + useAppStore.setState({ + repos: [makeRepo(), ...otherRepos.map((other) => makeRepo(other.repoId, `/${other.repoId}`))], + worktreesByRepo: { + [REPO_ID]: [worktree], + ...Object.fromEntries( + otherRepos.map((other) => [ + other.repoId, + [makeWorktree({ repoId: other.repoId, ...other.worktree })] + ]) + ) + }, + ...(options.folderWorkspace + ? { folderWorkspaces: [makeFolderWorkspace(options.folderWorkspace)] } + : {}), + ...(options.linearViewerOrganizationUrlKey + ? { + linearStatus: { + connected: true, + viewer: { + displayName: 'Viewer', + email: null, + organizationName: 'Active', + organizationUrlKey: options.linearViewerOrganizationUrlKey + } + } + } + : {}), + activeModal: 'edit-meta', + modalData: { + worktreeId: options.worktreeId ?? worktree.id, + ...(options.modalRepoId ? { repoId: options.modalRepoId } : {}), + currentDisplayName: worktree.displayName, + currentComment: worktree.comment, + focus: 'comment' + }, + updateWorktreeMeta, + fetchLinearIssue: fetchLinearIssue as unknown as ReturnType< + typeof useAppStore.getState + >['fetchLinearIssue'] + }) + render() +} + +function issueInput(): HTMLInputElement { + return screen.getByPlaceholderText('Issue #, or a GitHub or Linear URL') +} + +function providerChip(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Issue provider' }) +} + +function saveButton(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Save' }) +} + +function openIssueButton(): HTMLButtonElement { + return screen.getByRole('button', { name: 'Open linked issue' }) +} + +describe('WorktreeMetaDialog issue link row', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + updateWorktreeMeta.mockReset() + updateWorktreeMeta.mockResolvedValue({ ok: true }) + fetchLinearIssue.mockReset() + fetchLinearIssue.mockResolvedValue(null) + openUrl.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { shell: { openUrl } } + }) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + }) + + it('seeds the chip and value from a GitHub link', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + expect(providerChip().textContent).toContain('GitHub') + expect(issueInput().value).toBe('42') + }) + + it('seeds the chip and value from a Linear link', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + expect(providerChip().textContent).toContain('Linear') + expect(issueInput().value).toBe('STA-335') + }) + + it('flips to Linear when a linear.app issue URL is pasted', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { + target: { value: 'https://linear.app/acme/issue/STA-335/fix-the-thing' } + }) + + expect(providerChip().textContent).toContain('Linear') + }) + + // Why: Linear and Jira issue keys are the same shape, so only a URL may steer + // the provider — a bare key must never override the user's explicit choice. + it('keeps the chip on GitHub when a bare issue key is typed', () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { target: { value: 'GH-1234' } }) + + expect(providerChip().textContent).toContain('GitHub') + expect(providerChip().textContent).not.toContain('Linear') + }) + + it('flips to GitHub when a GitHub issue URL is pasted over a Linear link', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { + target: { value: 'https://github.com/acme/orca/issues/77' } + }) + + expect(providerChip().textContent).toContain('GitHub') + }) + + it('names the Linear issue that switching to GitHub would unlink', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'GitHub' })) + fireEvent.change(issueInput(), { target: { value: '99' } }) + + expect( + screen.getByText('Saving unlinks Linear STA-335 — a workspace tracks one issue.') + ).toBeTruthy() + }) + + // Both slots can hold a link at once — naming only one understates the save. + it('names both links when clearing the field would drop both', () => { + openDialog({ worktree: { linkedIssue: 42, linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: '' } }) + + expect( + screen.getByText( + 'Saving unlinks Linear STA-335 and GitHub #42 — a workspace tracks one issue.' + ) + ).toBeTruthy() + }) + + // The warning above only promises the displacement — this asserts the payload + // that carries it out, which is where the one-issue-per-workspace rule lives. + it('clears the displaced GitHub link when a Linear value is saved', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.click(screen.getByRole('menuitemradio', { name: 'Linear' })) + fireEvent.change(issueInput(), { target: { value: 'STA-335' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedLinearIssue).toBe('STA-335') + expect(updates.linkedIssue).toBeNull() + }) + + // A GitHub-only save must carry no Linear keys: persistence gates the remote + // Linear capability on key presence, so a synthetic clear fails the save. + it('sends no Linear keys when the workspace has no Linear link', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + fireEvent.change(issueInput(), { target: { value: '99' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedIssue).toBe(99) + expect(updates).not.toHaveProperty('linkedLinearIssue') + }) + + // updateWorktreeMeta stamps lastActivityAt on any comment write, which would + // reorder the workspace under the time-decay sidebar sort. + it('sends no comment when only the issue link changed', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + expect(updateWorktreeMeta.mock.calls[0]?.[1] ?? {}).not.toHaveProperty('comment') + }) + + // A failed save refetches and reverts the optimistic write, so closing here + // would report success for an edit that silently undid itself. + it('keeps the dialog open and reports why when the save fails', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + updateWorktreeMeta.mockResolvedValue({ ok: false, error: 'Runtime is offline' }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + expect(screen.getByRole('alert').textContent).toBe('Runtime is offline') + expect(useAppStore.getState().activeModal).toBe('edit-meta') + }) + + it('leaves the Linear link alone when only the comment is edited', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), { + target: { value: 'updated note' } + }) + + expect(screen.queryByText(/Saving unlinks/)).toBeNull() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(Object.keys(updates)).not.toContain('linkedLinearIssue') + expect(Object.keys(updates)).not.toContain('linkedIssue') + expect(updates.comment).toBe('updated note') + }) + + it('blocks saving an unparseable Linear value', () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'not an issue' } }) + + expect(screen.getByText('Not a Linear issue key or linear.app issue URL.')).toBeTruthy() + expect(saveButton().disabled).toBe(true) + }) + + it('is read-only for a folder workspace', () => { + openDialog({ worktreeId: folderWorkspaceKey('fw-1') }) + + expect(issueInput().disabled).toBe(true) + expect(providerChip().disabled).toBe(true) + expect( + screen.getByText( + "Issue links are set when a folder workspace is created and can't be changed here yet." + ) + ).toBeTruthy() + }) + + // Folder workspaces live outside worktreesByRepo, so the indexed lookup alone + // leaves the row blank and the link it does hold looks lost. + it('shows a folder workspace its own linked issue', () => { + openDialog({ worktreeId: folderWorkspaceKey('fw-1'), folderWorkspace: {} }) + + expect(issueInput().value).toBe('STA-901') + expect(providerChip().textContent).toContain('Linear') + }) + + // A background `orca worktree set` must not move the baseline mid-edit: the + // field would read as dirty and a comment-only save would write the stale seed. + it('keeps the baseline frozen when the store changes while open', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [makeWorktree({ linkedLinearIssue: 'STA-999' })] + } + }) + }) + fireEvent.change(screen.getByPlaceholderText('Notes about this worktree...'), { + target: { value: 'still working' } + }) + await act(async () => { + fireEvent.click(saveButton()) + }) + + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.comment).toBe('still working') + expect(updates).not.toHaveProperty('linkedLinearIssue') + expect(updates).not.toHaveProperty('linkedIssue') + }) + + // A bare key names no organization. Building one from the connected viewer + // opens a not-found page — or a colliding issue — for every other workspace + // the user belongs to, and skips the lookup that would have said so. + it('resolves a bare Linear key across workspaces rather than the active organization', async () => { + fetchLinearIssue.mockResolvedValue(makeLinearIssue('https://linear.app/other/issue/STA-999')) + openDialog({ + worktree: { linkedLinearIssue: 'STA-335' }, + linearViewerOrganizationUrlKey: 'active-org' + }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + await act(async () => { + fireEvent.click(openIssueButton()) + }) + + expect(fetchLinearIssue.mock.calls[0]?.slice(0, 2)).toEqual(['STA-999', 'all']) + expect(openUrl).toHaveBeenCalledWith('https://linear.app/other/issue/STA-999') + }) + + // The stored key belongs to the persisted identifier, so it stays authoritative + // for it — no lookup, no round trip. + it('opens a stored Linear link directly from its organization key', async () => { + openDialog({ + worktree: { + linkedLinearIssue: 'STA-335', + linkedLinearIssueOrganizationUrlKey: 'acme' + }, + linearViewerOrganizationUrlKey: 'active-org' + }) + + await act(async () => { + fireEvent.click(openIssueButton()) + }) + + expect(fetchLinearIssue).not.toHaveBeenCalled() + expect(openUrl).toHaveBeenCalledWith('https://linear.app/acme/issue/STA-335') + }) + + // Promise.race cannot cancel the lookup, and the field stays editable while it + // runs — a late result must not open the issue the user just replaced. + it('does not open a lookup result after the field moved on', async () => { + let resolveLookup: ((issue: LinearIssue | null) => void) | undefined + fetchLinearIssue.mockReturnValue( + new Promise((resolve) => { + resolveLookup = resolve + }) + ) + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: 'STA-999' } }) + fireEvent.click(openIssueButton()) + fireEvent.change(issueInput(), { target: { value: 'STA-777' } }) + await act(async () => { + resolveLookup?.(makeLinearIssue('https://linear.app/acme/issue/STA-999')) + }) + + expect(openUrl).not.toHaveBeenCalled() + expect( + screen.queryByText( + "Couldn't open that issue. Check the identifier and your Linear connection." + ) + ).toBeNull() + }) + + // Displacement is decided at save time, not at open: a link added by the CLI + // while the dialog sat open must not outlive the save that warned about it. + it('clears a Linear link added while the dialog was open', async () => { + openDialog({ worktree: { linkedIssue: 42 } }) + + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [REPO_ID]: [makeWorktree({ linkedIssue: 42, linkedLinearIssue: 'STA-999' })] + } + }) + }) + fireEvent.change(issueInput(), { target: { value: '99' } }) + + expect( + screen.getByText('Saving unlinks Linear STA-999 — a workspace tracks one issue.') + ).toBeTruthy() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates.linkedIssue).toBe(99) + expect(updates.linkedLinearIssue).toBeNull() + }) + + // Same issue, different spelling: the link is unchanged, so its title and + // SSH/runtime source context must survive the save. + it('keeps the linked work item when the value is only respelled', async () => { + openDialog({ + worktree: { + linkedLinearIssue: 'STA-335', + linkedWorkItem: { + provider: 'linear', + type: 'issue', + number: 335, + title: 'Fix auth', + url: 'https://linear.app/acme/issue/STA-335' + } + } + }) + + fireEvent.change(issueInput(), { target: { value: 'sta-335' } }) + + expect(screen.queryByText(/Saving unlinks/)).toBeNull() + + await act(async () => { + fireEvent.click(saveButton()) + }) + + await waitFor(() => expect(updateWorktreeMeta).toHaveBeenCalledTimes(1)) + const updates = updateWorktreeMeta.mock.calls[0]?.[1] ?? {} + expect(updates).not.toHaveProperty('linkedWorkItem') + expect(updates).not.toHaveProperty('linkedTaskSourceContext') + expect(updates).not.toHaveProperty('linkedLinearIssue') + }) + + // The owner index reports a duplicated workspace ID as ambiguous rather than + // guessing, so the opening row has to name its own bucket. + it('shows the clicked row when the same workspace ID exists under two hosts', () => { + openDialog({ + worktree: { linkedIssue: 42 }, + otherRepos: [{ repoId: 'repo-2', worktree: { linkedIssue: 77 } }], + modalRepoId: 'repo-2' + }) + + expect(issueInput().value).toBe('77') + expect(providerChip().textContent).toContain('GitHub') + }) + + it('dispatches nothing when the dialog is cancelled', async () => { + openDialog({ worktree: { linkedLinearIssue: 'STA-335' } }) + + fireEvent.change(issueInput(), { target: { value: '99' } }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + }) + + expect(updateWorktreeMeta).not.toHaveBeenCalled() + expect(useAppStore.getState().activeModal).toBe('none') + }) +}) diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 5163a839ebf..4445520fd72 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -10,23 +10,40 @@ import { } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { getDisplacedLinkLabels } from './worktree-issue-displacement' import { buildWorktreeMetaUpdates, parseGitHubWorkItemNumberForMetaField, - type WorktreeMetaSavedPayload + type WorktreeMetaDraft, + type WorktreeMetaSavedPayload, + type WorktreeMetaSnapshot } from './worktree-meta-updates' import { useWorktreeIssueLink } from './use-worktree-issue-link' +import { useWorktreeMetaWorkspace } from './use-worktree-meta-workspace' +import { WorktreeIssueLinkField } from './WorktreeIssueLinkField' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' -import { ExternalLink, LoaderCircle } from 'lucide-react' import { useMountedRef } from '@/hooks/useMountedRef' import { translate } from '@/i18n/i18n' +import { isWorkItemLinkQueryTooLarge } from '../../../../shared/new-workspace/work-item-link-query-bounds' +import { + getIssueLinkProviderFromUrl, + parseIssueLinkInput, + type IssueLinkProvider +} from '../../../../shared/issue-link-input' function resizeCommentTextarea(textarea: HTMLTextAreaElement): void { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` } +/** Only read before the first open, when nothing can be saved yet. */ +const EMPTY_SNAPSHOT: WorktreeMetaSnapshot = { + displayName: '', + comment: '', + issueInput: '', + issueProvider: 'github' +} + const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const activeModal = useAppStore((s) => s.activeModal) const modalData = useAppStore((s) => s.modalData) @@ -40,9 +57,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { const worktreeId = typeof modalData.worktreeId === 'string' ? modalData.worktreeId : '' const currentDisplayName = typeof modalData.currentDisplayName === 'string' ? modalData.currentDisplayName : '' - const currentIssue = - typeof modalData.currentIssue === 'number' ? String(modalData.currentIssue) : '' - const currentPR = typeof modalData.currentPR === 'number' ? String(modalData.currentPR) : '' const currentComment = typeof modalData.currentComment === 'string' ? modalData.currentComment : '' const focusField = typeof modalData.focus === 'string' ? modalData.focus : 'comment' @@ -51,15 +65,44 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { ? (modalData.afterSave as (payload: WorktreeMetaSavedPayload) => void | Promise) : null + // Why: the opening row names its repo bucket for workspace IDs the owner index + // reads as ambiguous across hosts. + const ownerRepoId = typeof modalData.repoId === 'string' ? modalData.repoId : null + const { + worktree, + linkedIssue, + linkedLinearIssue, + currentIssue, + currentProvider, + isFolderWorkspace, + liveLinks + } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId }) + // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet. + const currentPR = + typeof modalData.currentPR === 'number' + ? String(modalData.currentPR) + : worktree?.linkedPR != null + ? String(worktree.linkedPR) + : '' + const [displayNameInput, setDisplayNameInput] = useState('') const [issueInput, setIssueInput] = useState('') + const [issueProvider, setIssueProvider] = useState('github') const [prInput, setPrInput] = useState('') const [commentInput, setCommentInput] = useState('') const [saving, setSaving] = useState(false) - const { canOpenIssue, openingIssue, handleOpenIssue, resetOpeningIssue } = useWorktreeIssueLink({ - worktreeId, - issueInput - }) + const [saveError, setSaveError] = useState(null) + const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT) + const { canOpenIssue, openingIssue, openIssueFailed, handleOpenIssue, resetOpeningIssue } = + useWorktreeIssueLink({ + worktreeId, + ownerRepoId, + issueInput, + issueProvider, + linearOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null, + linkedLinearIssue: worktree?.linkedLinearIssue ?? null, + linearSourceContext: worktree?.linkedTaskSourceContext ?? null + }) const issueInputRef = useRef(null) const prInputRef = useRef(null) @@ -70,12 +113,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { if (isOpen && !prevIsOpenRef.current) { setDisplayNameInput(currentDisplayName) setIssueInput(currentIssue) + setIssueProvider(currentProvider) setPrInput(currentPR) setCommentInput(currentComment) + // Why: the baseline is frozen with the seed instead of tracking the store. + // A background `orca worktree set --linear-issue` while the dialog is open + // would otherwise move it, making the untouched field read as dirty — and + // the next comment-only save would write the stale seed back over the new link. + setSnapshot({ + displayName: currentDisplayName, + comment: currentComment, + issueInput: currentIssue, + issueProvider: currentProvider, + linkedLinearIssueOrganizationUrlKey: worktree?.linkedLinearIssueOrganizationUrlKey ?? null + }) + setSaveError(null) resetOpeningIssue() } prevIsOpenRef.current = isOpen + const draft = useMemo( + () => ({ displayNameInput, issueInput, issueProvider, prInput, commentInput }), + [displayNameInput, issueInput, issueProvider, prInput, commentInput] + ) + + // Why: a URL names its provider unambiguously. A bare key does not — Linear + // and Jira identifiers are the same shape — so typing never steers the chip. + const handleIssueInputChange = useCallback((next: string) => { + setIssueInput(next) + // Why: the same bound the save gate applies. Detection parses on every + // keystroke, and the field accepts pasted URLs of any length. + const detected = isWorkItemLinkQueryTooLarge(next) ? null : getIssueLinkProviderFromUrl(next) + if (detected) { + setIssueProvider(detected) + } + }, []) + const setCommentTextareaRef = useCallback( (textarea: HTMLTextAreaElement | null) => { textareaRef.current = textarea @@ -92,18 +165,42 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { resizeCommentTextarea(event.currentTarget) }, []) + // Why: bound the parse before it runs on every keystroke — the field accepts + // pasted URLs and has no length cap of its own. + const issueInvalid = useMemo(() => { + const trimmed = issueInput.trim() + if (trimmed === '' || isFolderWorkspace) { + return false + } + return ( + isWorkItemLinkQueryTooLarge(trimmed) || parseIssueLinkInput(trimmed, issueProvider) === null + ) + }, [isFolderWorkspace, issueInput, issueProvider]) + const canSave = useMemo(() => { if (!worktreeId) { return false } - const trimmedIssue = issueInput.trim() const trimmedPR = prInput.trim() - const issueValid = - trimmedIssue === '' || parseGitHubWorkItemNumberForMetaField(trimmedIssue, 'issue') !== null + // Same quadratic-parse bound as the issue field — this runs on every keystroke. const prValid = - trimmedPR === '' || parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null - return issueValid && prValid - }, [worktreeId, issueInput, prInput]) + trimmedPR === '' || + (!isWorkItemLinkQueryTooLarge(trimmedPR) && + parseGitHubWorkItemNumberForMetaField(trimmedPR, 'pr') !== null) + return !issueInvalid && prValid + }, [worktreeId, issueInvalid, prInput]) + + const displacedLinkLabels = useMemo( + () => + getDisplacedLinkLabels({ + draft, + snapshot, + isFolderWorkspace, + linkedIssue, + linkedLinearIssue + }), + [draft, snapshot, isFolderWorkspace, linkedIssue, linkedLinearIssue] + ) const handleOpenChange = useCallback( (open: boolean) => { @@ -119,16 +216,22 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { return } setSaving(true) + // Why: a stale failure from the previous attempt must not sit under the + // spinner for the whole in-flight save. + setSaveError(null) try { - const updates = buildWorktreeMetaUpdates({ - displayNameInput, - currentDisplayName, - issueInput, - prInput, - commentInput - }) + const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks) - await updateWorktreeMeta(worktreeId, updates) + const result = await updateWorktreeMeta(worktreeId, updates) + // Why: a failed save refetches and reverts the optimistic write. Closing + // here would report success for an edit that silently undid itself, and + // would discard the name, comment and PR changes in the same payload. + if (!result.ok) { + if (mountedRef.current) { + setSaveError(result.error) + } + return + } closeModal() // Why: follow-up refreshes should not turn a successful metadata save // into a failed dialog. @@ -145,11 +248,9 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { }, [ worktreeId, canSave, - displayNameInput, - currentDisplayName, - issueInput, - prInput, - commentInput, + draft, + snapshot, + liveLinks, updateWorktreeMeta, closeModal, afterSave, @@ -204,8 +305,8 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { {translate( - 'auto.components.sidebar.WorktreeMetaDialog.65770ad0f0', - 'Edit GitHub links and notes for this workspace.' + 'auto.components.sidebar.WorktreeMetaDialog.a0d191b7a7', + 'Edit issue links, pull request links, and notes for this workspace.' )} @@ -234,58 +335,21 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() {

-
- -
- setIssueInput(e.target.value)} - onKeyDown={handleIssueKeyDown} - placeholder={translate( - 'auto.components.sidebar.WorktreeMetaDialog.741279e7b7', - 'Issue # or GitHub URL' - )} - className="h-8 pr-9 text-xs" - /> - - - - - - {translate( - 'auto.components.sidebar.WorktreeMetaDialog.029ea5ec57', - 'Open GitHub issue' - )} - - -
-

- {translate( - 'auto.components.sidebar.WorktreeMetaDialog.7c454be4c5', - 'Paste an issue URL, or enter a number. Leave blank to remove the link.' - )} -

-
+
+ {saveError ? ( +

+ {saveError} +

+ ) : null} +
- {busy ? ( + {busy || connectInFlight ? ( - ) : isReconnectable(status) ? ( + ) : canConnectSshStatus(status) ? ( ) : status === 'connected' ? ( )} diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx index 6d25e856ef5..536d3e9c9cb 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx @@ -3,6 +3,7 @@ import { cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import type { PtyTransport } from './pty-transport' +import { useExpandCollapseActions } from './expand-collapse' import { useTerminalKeyboardShortcuts } from './keyboard-handlers' import { installTerminalImeCompositionRoute, @@ -365,4 +366,74 @@ describe('Windows IME keyboard ownership', () => { hook.unmount() harness.dispose() }) + + // Why: STA-3291 — busy panes render mid-composition (title updates), and the + // chord owner must survive those renders or held-modifier CJK input leaks + // newlines. Wiring mirrors TerminalPane: dep objects rebuilt every render. + it('absorbs the bare Enter redispatch when re-renders land mid-composition', () => { + const harness = createHarness() + const factoryFields = { + expandedPaneIdRef: { current: null }, + expandedStyleSnapshotRef: { current: new Map() }, + containerRef: { current: null }, + managerRef: { current: null }, + setExpandedPaneId: vi.fn(), + setTabPaneExpanded: vi.fn(), + pendingPaneSizeRefreshFrameIdsRef: { current: [] }, + persistLayoutSnapshot: vi.fn() + } + const hook = renderHook(() => { + const actions = useExpandCollapseActions({ ...factoryFields, tabId: 'tab-1' }) + useTerminalKeyboardShortcuts({ + ...harness.deps, + setExpandedPane: actions.setExpandedPane, + restoreExpandedLayout: actions.restoreExpandedLayout, + refreshPaneSizes: actions.refreshPaneSizes, + toggleExpandPane: actions.toggleExpandPane + }) + }) + + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Shift', + code: 'ShiftLeft', + keyCode: 16, + timeStamp: 1, + shiftKey: true + }) + ) + hook.rerender() + harness.startComposition() + hook.rerender() + for (let repeat = 0; repeat < 5; repeat++) { + harness.terminalInput.dispatchEvent( + keyboardEvent('keydown', { + key: 'Process', + code: 'Enter', + keyCode: 229, + timeStamp: 10 + repeat, + isComposing: true, + shiftKey: true, + repeat: repeat > 0 + }) + ) + hook.rerender() + } + + const redispatch = keyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + timeStamp: 40 + }) + harness.terminalInput.dispatchEvent(redispatch) + + expect(redispatch.defaultPrevented).toBe(true) + const newlineSends = harness.sendInput.mock.calls.filter( + ([data]) => typeof data === 'string' && data.includes('\r') + ) + expect(newlineSends).toHaveLength(0) + hook.unmount() + harness.dispose() + }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fdace14f0d6..b1eb0900865 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -8246,7 +8246,7 @@ export function connectPanePty( const alreadyConnected = useAppStore.getState().sshConnectionStates.get(connectionId)?.status === 'connected' if (!alreadyConnected) { - // Wait for the user-driven connect (SshDisconnectedDialog → passphrase → ssh.connect) to complete. + // Wait for the user-driven connect (sidebar card control or terminal reconnect overlay → passphrase → ssh.connect) to complete. // Why: resolve on terminal-failure statuses too ('auth-failed'/'error'/'reconnection-failed') so it can't hang forever if the user cancels or the connect fails. const outcome = await new Promise((resolve) => { // Why: 'disconnected' counts as terminal only after a non-disconnected status was seen (a real connect attempt that returned to 'disconnected'). diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts new file mode 100644 index 00000000000..886516414ca --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-deadline-reattach.test.ts @@ -0,0 +1,381 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +describe('remote runtime pty reattach after the bounded recovery window', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribedTerminalHandles(): string[] { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .flatMap((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.Subscribe) { + return [] + } + const payload = decodeTerminalStreamJson<{ terminal: string }>(frame.payload) + return payload ? [payload.terminal] : [] + }) + } + + function emitSnapshot(streamId: number, data: string): void { + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson({ kind: 'scrollback' }) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(data) + }) + ) + subscriptionCallbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array() + }) + ) + } + + function hostSnapshot( + terminal: string, + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: 'tab-1::pane:1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'tab-1::pane:1', + parentTabId: 'tab-1', + leafId: 'pane:1', + title: 'Claude Code', + isActive: true, + status: 'ready', + terminal + } + ] + } + } + + async function attachStalePane() { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onError = vi.fn() + const onPtyExit = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit, + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError } + }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + + // The host keeps publishing the same live handle, so bounded replacement polling finds + // no replacement and stops quietly without retiring the pane. + runtimeCall.mockImplementation(async (args: { method: string }) => { + if (args.method !== 'session.tabs.list') { + return { ok: true, result: {} } + } + hostListCalls += 1 + return { ok: true, result: hostSnapshot('terminal-stale', hostListCalls + 1, 'epoch-1') } + }) + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return { transport, onError, onPtyExit } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot('terminal-stale', 1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + return { + ok: true, + result: { + terminal: { + handle: 'terminal-stale', + tabId: params.paneKey.slice(0, separator), + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: { terminal: { handle: 'terminal-stale' } } } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('reattaches from a rotated host handle published after the recovery cutoff', async () => { + vi.useFakeTimers() + try { + const { transport, onPtyExit } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + await vi.advanceTimersByTimeAsync(50_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + // The cutoff must not tear down the accepted-snapshot listener; it is the only path back. + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-after-timeout', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => + expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-after-timeout' }) + ) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(transport.getPtyId()).toBe('remote:env-1@@terminal-after-timeout') + expect(onPtyExit).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches when the post-cutoff snapshot republishes the same live handle', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale', 'terminal-stale']) + // The snapshot is already-received host evidence; reattaching must cost no inventory RPC. + expect(hostListCalls).toBe(listCallsAtCutoff) + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('ignores a same-handle snapshot while automatic recovery is still running', async () => { + vi.useFakeTimers() + try { + const { transport } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(16_000) + expect(transport.getRecoveryState?.().phase).not.toBe('disconnected') + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 3, 'epoch-2'), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(1_000) + + expect(subscribedTerminalHandles()).toEqual(['terminal-stale']) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('reattaches from a same-handle snapshot delivered inside the Reconnect window', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(handleEvents.getWebSessionTerminalHandleSubscriberCountForTests()).toBe(1) + const listCallsAtCutoff = hostListCalls + + expect(transport.retryRecovery?.()).toBe(true) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + // Reconnect must not disarm the only path back: the click opens a window, it does not spend one. + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('revives a latched require-replacement pane when online or system resume fires', async () => { + vi.useFakeTimers() + try { + const { transport, onError } = await attachStalePane() + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const listCallsAtCutoff = hostListCalls + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + await vi.advanceTimersByTimeAsync(1_000) + expect(hostListCalls).toBeGreaterThan(listCallsAtCutoff) + + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot('terminal-stale', 4, 'epoch-3'), + 'env-1' + ) + await vi.waitFor(() => expect(subscribedTerminalHandles()).toHaveLength(2)) + + emitSnapshot(latestSubscribePayload().streamId, 'reattached') + expect(transport.isConnected()).toBe(true) + expect(onError).not.toHaveBeenCalled() + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) + + it('leaves Reconnect available when online fires on a pane latched during the attach wait', async () => { + vi.useFakeTimers() + try { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + runtimeCall.mockImplementation(async (request: { method: string }) => { + if (request.method === 'session.tabs.activate') { + return { + ok: false, + error: { + code: 'remote_runtime_unavailable', + message: 'Remote Orca runtime connection closed' + } + } + } + return { ok: true, result: {} } + }) + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'web-terminal-tab-1', + leafId: 'pane:1', + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-stale', + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const callsBeforeRetry = runtimeCall.mock.calls.length + // The attach wait is single-shot and the cutoff already settled it; replaying it would spin the banner with no RPC in flight. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + expect(runtimeCall.mock.calls.length).toBe(callsBeforeRetry) + transport.destroy?.() + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts new file mode 100644 index 00000000000..5892ee2c19d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-latched-pane-retention.test.ts @@ -0,0 +1,357 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson +} from '../../../../shared/terminal-stream-protocol' +import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' + +// Why: the recovery cutoff no longer tears down the retry registry entry or the accepted-snapshot +// listener, so those two module-global collections are the only places a latched pane can accumulate. +describe('remote runtime pty latched-pane retention', () => { + const runtimeCall = vi.fn() + const runtimeSubscribe = vi.fn() + const refreshSessionTabsSnapshot = vi.fn(async () => {}) + const subscriptionSendBinary = vi.fn() + let subscriptionCallbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { code: string; message: string }) => void + onClose?: () => void + } | null = null + let hostListCalls = 0 + + function emitMultiplexReady(): void { + subscriptionCallbacks?.onResponse({ ok: true, result: { type: 'ready' } }) + } + + function latestSubscribePayload(): { streamId: number; terminal: string } { + const frame = subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .findLast((candidate) => candidate?.opcode === TerminalStreamOpcode.Subscribe) + if (!frame) { + throw new Error('missing terminal subscribe frame') + } + const payload = decodeTerminalStreamJson<{ streamId: number; terminal: string }>(frame.payload) + if (!payload) { + throw new Error('invalid terminal subscribe payload') + } + return payload + } + + function subscribeFrameCount(): number { + return subscriptionSendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe).length + } + + // Why: every pane needs its own host surface, otherwise a later pane reuses the earlier pane's + // multiplexed stream and never enters recovery at all. + const paneIdentity = (pane: number) => ({ + hostTabId: `tab-${pane}`, + tabId: `web-terminal-tab-${pane}`, + leafId: `pane:${pane}`, + handle: `terminal-stale-${pane}` + }) + + const PANE_COUNT = 24 + + // Why: one payload carries every pane's surface, matching the real per-worktree session.tabs + // response that any polling pane receives. + function hostSnapshot( + snapshotVersion: number, + publicationEpoch: string + ): RuntimeMobileSessionTabsResult { + return { + worktree: 'wt-1', + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: `${paneIdentity(0).hostTabId}::${paneIdentity(0).leafId}`, + activeTabType: 'terminal' as const, + tabs: Array.from({ length: PANE_COUNT }, (_unused, pane) => { + const identity = paneIdentity(pane) + return { + type: 'terminal' as const, + id: `${identity.hostTabId}::${identity.leafId}`, + parentTabId: identity.hostTabId, + leafId: identity.leafId, + title: 'Claude Code', + isActive: pane === 0, + status: 'ready' as const, + terminal: identity.handle + } + }) + } + } + + async function attachStalePane(pane: number) { + const identity = paneIdentity(pane) + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: identity.tabId, + leafId: identity.leafId, + onPtyExit: vi.fn(), + onPtyRebind: vi.fn() + }) + const subscribesBefore = subscribeFrameCount() + transport.attach({ + existingPtyId: `remote:env-1@@${identity.handle}`, + cols: 80, + rows: 24, + callbacks: { onError: vi.fn() } + }) + await vi.waitFor(() => expect(subscribeFrameCount()).toBeGreaterThan(subscribesBefore)) + + // The host keeps publishing the same live handle, so bounded replacement polling finds no + // replacement and stops quietly: the exact shape that leaves the pane latched but revivable. + subscriptionCallbacks?.onResponse({ + ok: true, + result: { + type: 'error', + streamId: latestSubscribePayload().streamId, + message: 'terminal_handle_stale' + } + }) + return transport + } + + async function registries() { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const recoveryState = await import('./remote-runtime-pty-recovery-state') + return { + subscribers: handleEvents.getWebSessionTerminalHandleSubscriberCountForTests(), + scheduled: recoveryState.getScheduledRemoteRuntimePtyRecoveryCountForTests() + } + } + + beforeEach(() => { + vi.resetModules() + vi.doUnmock('../../runtime/remote-runtime-terminal-multiplexer') + vi.doMock('@/runtime/web-runtime-session', () => ({ + refreshWebRuntimeSessionTabsSnapshot: refreshSessionTabsSnapshot + })) + vi.clearAllMocks() + subscriptionCallbacks = null + hostListCalls = 0 + subscriptionSendBinary.mockReset() + runtimeCall.mockImplementation(async (request: { method: string; params?: unknown }) => { + if (request.method === 'session.tabs.list') { + hostListCalls += 1 + return { ok: true, result: hostSnapshot(hostListCalls + 1, 'epoch-1') } + } + if (request.method === 'session.tabs.activate') { + return { ok: true, result: hostSnapshot(1, 'epoch-1') } + } + if (request.method === 'terminal.resolvePane') { + const params = request.params as { paneKey: string; worktreeId: string } + const separator = params.paneKey.indexOf(':') + const paneTabId = params.paneKey.slice(0, separator) + const pane = Number(paneTabId.slice(paneTabId.lastIndexOf('-') + 1)) + return { + ok: true, + result: { + terminal: { + handle: paneIdentity(pane).handle, + tabId: paneTabId, + leafId: params.paneKey.slice(separator + 1), + worktreeId: params.worktreeId + } + } + } + } + return { ok: true, result: {} } + }) + runtimeSubscribe.mockImplementation( + async (_args: unknown, callbacks: typeof subscriptionCallbacks) => { + subscriptionCallbacks = callbacks + queueMicrotask(emitMultiplexReady) + return { unsubscribe: vi.fn(), sendBinary: subscriptionSendBinary } + } + ) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } } + }) + }) + + it('returns listener and retry-registry counts to baseline across destroy cycles', async () => { + vi.useFakeTimers() + try { + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + const latched: { subscribers: number; scheduled: number }[] = [] + const settled: { subscribers: number; scheduled: number }[] = [] + + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + latched.push(await registries()) + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + + // A latched pane deliberately holds exactly one listener and one registry entry... + expect(latched).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 1, scheduled: 1 }))) + // ...and destroy releases both, so twenty cycles do not accumulate anything. + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('returns to baseline when a latched pane is detached rather than destroyed', async () => { + vi.useFakeTimers() + try { + const settled: { subscribers: number; scheduled: number }[] = [] + for (let cycle = 0; cycle < 20; cycle += 1) { + const transport = await attachStalePane(cycle) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + transport.detach?.() + await vi.advanceTimersByTimeAsync(1_000) + settled.push(await registries()) + } + expect(settled).toEqual(Array.from({ length: 20 }, () => ({ subscribers: 0, scheduled: 0 }))) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('holds one listener and one registry entry per concurrently latched pane', async () => { + vi.useFakeTimers() + try { + const transports: Awaited>[] = [] + for (let pane = 0; pane < 8; pane += 1) { + transports.push(await attachStalePane(pane)) + await vi.advanceTimersByTimeAsync(66_000) + } + // Retention is per live pane, not per timeout: eight latched panes hold eight of each. + expect(await registries()).toEqual({ subscribers: 8, scheduled: 8 }) + + for (const transport of transports) { + transport.destroy?.() + } + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('leaves a latched pane fully quiescent — no timers, no RPCs, no growth', async () => { + vi.useFakeTimers() + try { + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const callsAtLatch = runtimeCall.mock.calls.length + const timersAtLatch = vi.getTimerCount() + // Nothing is armed once the window is spent: the pane costs one listener and one registry + // entry, and nothing else, no matter how long it stays latched. + expect(timersAtLatch).toBe(0) + + await vi.advanceTimersByTimeAsync(10 * 60_000) + + expect(runtimeCall.mock.calls.length).toBe(callsAtLatch) + expect(vi.getTimerCount()).toBe(0) + expect(await registries()).toEqual(baseline) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack listeners, registry entries or timers across repeated revive cycles', async () => { + vi.useFakeTimers() + try { + const { retryAllRemoteRuntimePtyRecoveriesNow } = + await import('./remote-runtime-pty-recovery-state') + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + + const baseline = await registries() + const timersAtFirstLatch = vi.getTimerCount() + const subscribesAtFirstLatch = subscribeFrameCount() + const observed: { + subscribers: number + scheduled: number + timers: number + revived: number + }[] = [] + + for (let cycle = 0; cycle < 25; cycle += 1) { + const revived = retryAllRemoteRuntimePtyRecoveriesNow() + // A second trigger in the same window must find nothing to advance, so an online/resume + // storm cannot stack fresh recovery epochs on one pane. + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + observed.push({ ...(await registries()), timers: vi.getTimerCount(), revived }) + } + + expect(observed).toEqual( + Array.from({ length: 25 }, () => ({ + subscribers: baseline.subscribers, + scheduled: baseline.scheduled, + timers: timersAtFirstLatch, + revived: 1 + })) + ) + // Each revive re-derives the handle; it must not leave extra live stream subscriptions behind. + expect(subscribeFrameCount()).toBe(subscribesAtFirstLatch) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not stack anything when host snapshots arrive repeatedly at a latched pane', async () => { + vi.useFakeTimers() + try { + const handleEvents = await import('../../runtime/web-session-terminal-handle-events') + const transport = await attachStalePane(0) + await vi.advanceTimersByTimeAsync(66_000) + expect(transport.getRecoveryState?.().phase).toBe('disconnected') + const baseline = await registries() + + const subscribesAtLatch = subscribeFrameCount() + + for (let cycle = 0; cycle < 50; cycle += 1) { + handleEvents.queueAcceptedWebSessionTerminalSnapshot( + hostSnapshot(10 + cycle, `epoch-${cycle}`), + 'env-1' + ) + await vi.advanceTimersByTimeAsync(100) + } + + const after = await registries() + expect(after.subscribers).toBeLessThanOrEqual(baseline.subscribers) + expect(after.scheduled).toBeLessThanOrEqual(baseline.scheduled) + // At most one same-handle resubscribe per spent window: 50 snapshots inside one window + // must not become 50 subscribes. + expect(subscribeFrameCount() - subscribesAtLatch).toBeLessThanOrEqual(1) + + transport.destroy?.() + await vi.advanceTimersByTimeAsync(1_000) + expect(await registries()).toEqual({ subscribers: 0, scheduled: 0 }) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts index f2556b737b0..07a9199d307 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.test.ts @@ -69,6 +69,7 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(state.isActive).toBe(false) expect(state.isCurrent(epoch)).toBe(false) expect(onChange).toHaveBeenCalled() + state.dispose() }) it('advances a pending backoff immediately via retryNow and the active registry', async () => { @@ -154,16 +155,56 @@ describe('RemoteRuntimePtyRecoveryState', () => { expect(retryNow).not.toHaveBeenCalled() }) - it('removes timed-out panes from the scheduled recovery registry', async () => { + it('keeps a timed-out pane revivable through the scheduled recovery registry', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + // Why: the backoff ladder outlasts the recovery window, so a retry is still armed when the cutoff lands. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS - 100) + state.schedule(firstEpoch, retry) + + await vi.advanceTimersByTimeAsync(100) + expect(state.currentPhase).toBe('disconnected') + + await vi.advanceTimersByTimeAsync(300_000) + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('revives a parked retry that never armed a backoff timer', async () => { + vi.useFakeTimers() + const state = new RemoteRuntimePtyRecoveryState() + const retry = vi.fn() + const firstEpoch = state.begin() + expect(state.parkRetryForExternalTrigger(firstEpoch, retry)).toBe(true) + + // Why: parking must not fire on its own, so the pane still latches at the cutoff. + await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS + 300_000) + expect(state.currentPhase).toBe('disconnected') + expect(retry).not.toHaveBeenCalled() + + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(1) + expect(retry).toHaveBeenCalledWith(firstEpoch + 1) + expect(state.currentPhase).toBe('recovering') + state.dispose() + }) + + it('refuses to park over an armed backoff or a stale epoch', () => { vi.useFakeTimers() const state = new RemoteRuntimePtyRecoveryState() const epoch = state.begin() state.schedule(epoch, vi.fn()) - const retryNow = vi.spyOn(state, 'retryNow') - await vi.advanceTimersByTimeAsync(REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) - retryAllRemoteRuntimePtyRecoveriesNow() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) - expect(retryNow).not.toHaveBeenCalled() + state.cancel() + expect(state.parkRetryForExternalTrigger(epoch, vi.fn())).toBe(false) + expect(retryAllRemoteRuntimePtyRecoveriesNow()).toBe(0) + state.dispose() }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts index df6814a4028..0b609ff876c 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-recovery-state.ts @@ -11,6 +11,10 @@ export type RemoteRuntimePtyRecoveryPhase = // Why: system resume / network online need to advance pending pane backoffs without a second coordinator. const scheduledRecoveries = new Set() +export function getScheduledRemoteRuntimePtyRecoveryCountForTests(): number { + return scheduledRecoveries.size +} + export function retryAllRemoteRuntimePtyRecoveriesNow(): number { let advanced = 0 // Why: a synchronous retry failure can schedule the same state again. @@ -101,14 +105,43 @@ export class RemoteRuntimePtyRecoveryState { return true } + // Why: a wait that ends with no liveness evidence arms no timer, so park a retry or online/resume/reconnect find nothing to revive. + parkRetryForExternalTrigger(epoch: number, retry: (epoch: number) => void): boolean { + if (!this.isCurrent(epoch) || this.pendingRetry !== null) { + return false + } + this.pendingRetry = retry + this.pendingEpoch = epoch + scheduledRecoveries.add(this) + return true + } + + // Why: a one-shot retry whose owner already resolved elsewhere would otherwise survive the cutoff as fake revivable work. + discardPendingRetry(retry: (epoch: number) => void): void { + if (this.pendingRetry !== retry) { + return + } + this.clearRetryTimer() + } + // Why: resume/online should fire an already-scheduled backoff immediately, not start a new epoch. retryNow(): boolean { - if (this.phase !== 'backoff' || this.pendingRetry === null || this.pendingEpoch === null) { + if (this.pendingRetry === null || this.pendingEpoch === null) { + return false + } + if (this.phase !== 'backoff' && this.phase !== 'disconnected') { return false } const retry = this.pendingRetry - const epoch = this.pendingEpoch + const latched = this.phase === 'disconnected' this.clearRetryTimer() + if (latched) { + // Why: the deadline only stops auto-retry; an explicit trigger opens a fresh recovery window. + this.epoch += 1 + this.attempt = 0 + this.armDeadline(this.epoch) + } + const epoch = this.epoch this.phase = 'recovering' this.onChange?.() retry(epoch) @@ -159,7 +192,8 @@ export class RemoteRuntimePtyRecoveryState { return } this.deadlineTimer = null - this.clearRetryTimer() + // Why: the cutoff stops self-initiated retries but must keep the pane revivable by online/resume/reconnect. + this.stopRetryTimer() this.phase = 'disconnected' this.onChange?.() }, REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS) @@ -172,11 +206,15 @@ export class RemoteRuntimePtyRecoveryState { this.clearDeadlineTimer() } - private clearRetryTimer(): void { + private stopRetryTimer(): void { if (this.retryTimer) { clearTimeout(this.retryTimer) this.retryTimer = null } + } + + private clearRetryTimer(): void { + this.stopRetryTimer() this.pendingRetry = null this.pendingEpoch = null scheduledRecoveries.delete(this) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index eedd195c71e..c9e0ea4352f 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -187,6 +187,9 @@ export function createRemoteRuntimePtyTransport( let recoveryReplacementPolicy: HostHandleReplacementPolicy = 'reuse' let recoveryReplacementPolicyHandle: string | null = null let stopWaitingForPublishedHandle: (() => void) | null = null + let publishedHandleWaitEpoch: number | null = null + // Why: a spent auto-recovery window is the evidence that licenses reattaching the fenced handle; explicit retries must not erase it. + let autoRecoveryWindowSpent = false let settleHostSessionAttachRetry: ((retry: boolean) => void) | null = null let resubscribeInventoryEpoch: number | null = null let resubscribeInventoryWindows = 0 @@ -236,12 +239,18 @@ export function createRemoteRuntimePtyTransport( } const recovery = new RemoteRuntimePtyRecoveryState(() => { - if (recovery.currentPhase === 'disconnected') { + if (recovery.currentPhase === 'disposed') { clearPublishedHandleWait() + } + if (recovery.currentPhase === 'disconnected') { + autoRecoveryWindowSpent = true // Why: cached pixels may remain, but no stream from the exhausted epoch may keep delivering or accepting terminal traffic. subscriptionGeneration += 1 closeMultiplexedStream() } + if (recovery.currentPhase === 'idle') { + autoRecoveryWindowSpent = false + } if ( recovery.currentPhase === 'disconnected' || recovery.currentPhase === 'disposed' || @@ -562,11 +571,16 @@ export function createRemoteRuntimePtyTransport( if (settleHostSessionAttachRetry === settle) { settleHostSessionAttachRetry = null } + // Why: this wait is single-shot, so replaying it after the cutoff would strand the pane in 'recovering' with no RPC in flight. + recovery.discardPendingRetry(scheduledRetry) resolve(retry) } + const scheduledRetry = (): void => { + settle(true) + } settleHostSessionAttachRetry?.(false) settleHostSessionAttachRetry = settle - if (!recovery.schedule(recoveryEpoch, () => settle(true))) { + if (!recovery.schedule(recoveryEpoch, scheduledRetry)) { settle(false) } }) @@ -1257,6 +1271,7 @@ export function createRemoteRuntimePtyTransport( function clearPublishedHandleWait(): void { stopWaitingForPublishedHandle?.() stopWaitingForPublishedHandle = null + publishedHandleWaitEpoch = null } function isCurrentRemoteTerminal(targetHandle: string, targetPtyId: string | null): boolean { @@ -1329,9 +1344,30 @@ export function createRemoteRuntimePtyTransport( retireRemoteTerminalId() return } - if (!update.terminalHandle || update.terminalHandle === previousHandle) { + if (!update.terminalHandle) { return } + if (update.terminalHandle === previousHandle) { + // Why: once the auto-recovery window is spent, a host still publishing this surface is evidence the fenced handle outlived the stale error. + if (!autoRecoveryWindowSpent || getCurrentMultiplexedStream(previousHandle)) { + return + } + // Why: one reattach per spent window, so a handle that really is dead is not retried on every host snapshot. + autoRecoveryWindowSpent = false + const reattachEpoch = recovery.begin() + clearPublishedHandleWait() + const reusedPtyId = remotePtyId + void subscribeToHandle(reattachEpoch, true).catch((error) => { + if (!recoverAfterSubscribeFailure(error, previousHandle, reusedPtyId)) { + handleRemoteTerminalError(error) + } + }) + return + } + if (recovery.currentPhase === 'disconnected') { + // Why: without a live epoch a failed resubscribe is swallowed as already-latched, leaving a pane with no handle and no way back. + recovery.begin() + } rebindRemoteTerminalHandle(update.terminalHandle) const reboundHandle = handle const reboundPtyId = remotePtyId @@ -1440,6 +1476,13 @@ export function createRemoteRuntimePtyTransport( code: 'remote_runtime_unavailable' }) } + // Why: liveness is unknown, so auto-retry stops here; keep an unarmed retry parked for online/resume/reconnect to fire. + recovery.parkRetryForExternalTrigger(recoveryEpoch, (nextEpoch) => { + scheduleResubscribeAfterTransportClose( + handle ? getRecoveryReplacementPolicy(handle) : 'reuse', + nextEpoch + ) + }) return } if (!nextHandle) { @@ -1507,8 +1550,12 @@ export function createRemoteRuntimePtyTransport( clearPendingViewportClaim() } strengthenRecoveryReplacementPolicy(handle, replacementPolicy) - if (replacementPolicy === 'require-replacement' && stopWaitingForPublishedHandle) { - // Why: once recovery is handed to accepted snapshots, repeated sends to the stale handle must not re-arm inventory RPCs. + if ( + replacementPolicy === 'require-replacement' && + stopWaitingForPublishedHandle && + // Why: only the epoch that handed recovery to accepted snapshots is blocked; a newer epoch is a fresh attempt, not a repeated stale send. + publishedHandleWaitEpoch === recoveryEpoch + ) { return } if (resubscribeEpoch === recoveryEpoch) { @@ -1529,6 +1576,7 @@ export function createRemoteRuntimePtyTransport( if (tabId && isWebTerminalSurfaceTabId(tabId)) { // Why: subscribe before polling so a fresh host snapshot can't land in the gap between the inventory loop and its event-driven fallback. waitForPublishedHostSessionHandle(toHostSessionTabId(tabId), resubscribeHandle) + publishedHandleWaitEpoch = recoveryEpoch } resubscribeEpoch = recoveryEpoch resubscribeRequestedHandle = null 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 23a7475f5dc..bc230ecd419 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 @@ -1664,7 +1664,8 @@ export function useTerminalPaneLifecycle({ manager: mgr, getPtyIdForLeaf: (leafId) => useAppStore.getState().terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId], - closeTab: () => closeTerminalTab(tabId), + // Why: CLI-driven pane close; its caller is answered immediately and cannot wait on a modal. + closeTab: () => closeTerminalTab(tabId, { skipRunningProcessConfirm: true }), closeTabPreservingPty: () => { const store = useAppStore.getState() if (detail.retireSurface && detail.leafId) { diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts new file mode 100644 index 00000000000..3dc808e6abc --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.test.ts @@ -0,0 +1,409 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock, inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({ + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose, + RUNNING_CLOSE_PROBE_TIMEOUT_MS +} from './running-terminal-close-guard' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab-1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {}, + ...overrides + }) +} + +function guard(onClose = vi.fn(), onCancel?: () => void): void { + guardRunningTerminalClose({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + onClose, + ...(onCancel ? { onCancel } : {}) + }) +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('shouldConfirmRunningTerminalClose', () => { + it('confirms a plain interactive close', () => { + expect(shouldConfirmRunningTerminalClose(undefined)).toBe(true) + expect(shouldConfirmRunningTerminalClose({})).toBe(true) + expect(shouldConfirmRunningTerminalClose({ reason: 'user' })).toBe(true) + expect(shouldConfirmRunningTerminalClose({ hostCloseReason: 'user' })).toBe(true) + }) + + it.each([ + ['post-confirmation re-entry', { force: true }], + ['non-interactive CLI reject', { rejectPinned: true }], + ['bulk/CLI opt-out', { skipRunningProcessConfirm: true }], + ['pty lifecycle echo', { reason: 'pty-exit' as const }], + ['cleanup teardown', { reason: 'cleanup' as const }], + ['host-only pty lifecycle echo', { hostCloseReason: 'pty-exit' as const }], + ['pty-scoped lifecycle close', { lifecyclePtyId: 'pty-a' }] + ])('never prompts for a %s', (_label, options) => { + expect(shouldConfirmRunningTerminalClose(options)).toBe(false) + }) +}) + +describe('guardRunningTerminalClose', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes synchronously and never probes when the tab has no live pty', () => { + setState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + // Why: a mounting pane is bound into the layout before ptyIdsByTabId catches up. Reading + // only the liveness map would let a close slip through that window with no prompt. + it('prompts for a pane the layout has bound but the liveness map has not', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledWith(expect.anything(), 'pty-a') + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1' }) + }) + + it('probes each pty once when the map and the layout name the same one', async () => { + guard() + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(1) + }) + + it('closes without probing when the user turned the prompt off', () => { + setState({ + settings: { + activeRuntimeEnvironmentId: null, + skipCloseTerminalWithRunningProcessConfirm: true + } + }) + const onClose = vi.fn() + + guard(onClose) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(visibleRequest()).toBeNull() + }) + + it('closes an idle terminal without a prompt', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'zsh', + hasChildProcesses: false + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('defers a busy terminal behind a confirmation that carries the tab label', async () => { + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-1', + tabLabel: 'npm run dev', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('runs onCancel and keeps the tab when the confirmation is dismissed', async () => { + const onClose = vi.fn() + const onCancel = vi.fn() + + guard(onClose, onCancel) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + }) + + it('fails open and closes when the probe rejects (wedged relay / legacy provider)', async () => { + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('rpc_timeout')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('fails open when a remote handle reports the inspection as unavailable', async () => { + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: null, + hasChildProcesses: true, + unavailable: true + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + it('prompts once for a split tab where only the second pane is busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-b' ? 'claude' : 'zsh', + hasChildProcesses: ptyId === 'pty-b' + })) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(2) + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', copyKind: 'agent' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('does not use an idle sibling pane to pick the agent copy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + inspectRuntimeTerminalProcessMock.mockImplementation(async (_settings, ptyId: string) => ({ + foregroundProcess: ptyId === 'pty-a' ? 'npm' : 'zsh', + hasChildProcesses: ptyId === 'pty-a' + })) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('prefers the agent copy regardless of pty spawn order when both panes are busy', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'codex' } } + }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('ignores a pane whose agent status is unknown', async () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: { agentType: 'unknown' } } }) + + guard() + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('command') + }) + + it('survives legacy layout leaf ids that are not stable pane uuids', async () => { + setState({ terminalLayoutsByTabId: { 'tab-1': { ptyIdsByLeafId: { leaf: 'pty-a' } } } }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + // Why: makePaneKey throws on a tab id containing ':'; the dialog must never be the + // reason a close silently stops happening. + it('closes rather than wedging when the copy-kind lookup throws', async () => { + getStateMock.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: null }, + ptyIdsByTabId: { 'tab:1': ['pty-a'] }, + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } } }, + agentStatusByPaneKey: {} + }) + const onClose = vi.fn() + + guardRunningTerminalClose({ terminalTabId: 'tab:1', tabLabel: 'weird', onClose }) + await settleProbe() + + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab:1', copyKind: 'command' }) + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes rather than wedging when raising the confirmation throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + // Why: a remote inspect can take its full 15s RPC timeout. Closing at 4s would kill a + // running remote command with no prompt — the exact failure this guard exists to stop. + it('prompts instead of closing when a wedged remote probe never settles', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + expect(onClose).not.toHaveBeenCalled() + + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(onClose).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-1', tabLabel: 'npm run dev' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('treats every pane as a candidate when picking the copy for a timed-out probe', async () => { + setState({ + ptyIdsByTabId: { 'tab-1': ['pty-a', 'pty-b'] }, + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } } + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + + guard() + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) + + it('closes rather than wedging when the timed-out prompt throws', async () => { + const requestSpy = vi + .spyOn(useRunningTerminalCloseConfirmStore.getState(), 'requestRunningTerminalCloseConfirm') + .mockImplementation(() => { + throw new Error('subscriber blew up') + }) + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {})) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + requestSpy.mockRestore() + + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('ignores a slow probe that resolves after the timeout already prompted', async () => { + vi.useFakeTimers() + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + const onClose = vi.fn() + + guard(onClose) + vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS) + vi.useRealTimers() + await settleProbe() + + expect(onClose).not.toHaveBeenCalled() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) + + // Why: an SSH drop zeroes ptyIdsByTabId while the layout still names the pane. The stale + // binding is probed, that probe fails on the dead link, and the close falls open — so a + // reconnecting tab stays closable instead of being blocked behind a prompt for a pty + // nobody can reach. Documented so the behavior is a decision, not an accident. + it('closes a reconnecting ssh tab whose pty ids were already zeroed', async () => { + setState({ ptyIdsByTabId: { 'tab-1': [] } }) + inspectRuntimeTerminalProcessMock.mockRejectedValue(new Error('ssh_disconnected')) + const onClose = vi.fn() + + guard(onClose) + await settleProbe() + + expect(onClose).toHaveBeenCalledTimes(1) + expect(visibleRequest()).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal/running-terminal-close-guard.ts b/src/renderer/src/components/terminal/running-terminal-close-guard.ts new file mode 100644 index 00000000000..73f63fc85f7 --- /dev/null +++ b/src/renderer/src/components/terminal/running-terminal-close-guard.ts @@ -0,0 +1,154 @@ +import { useAppStore } from '@/store' +import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection' +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import type { TerminalTabCloseReason } from '@/store/slices/terminal-tab-retirement' +import type { AppState } from '@/store/types' +import { resolveBusyPtyCloseCopyKind } from './terminal-close-copy-kind' + +export type RunningTerminalCloseGuardOptions = { + force?: boolean + rejectPinned?: boolean + reason?: TerminalTabCloseReason + hostCloseReason?: TerminalTabCloseReason + lifecyclePtyId?: string + skipRunningProcessConfirm?: boolean +} + +/** Upper bound on how long a close may wait on the probe before it asks instead. A remote + * inspect RPC can hang for its full 15s timeout, and an X button that looks dead for 15s + * is the same class of bug as one that never asks — but an unanswered probe is not + * evidence of an idle shell, so the timeout raises the prompt rather than killing a + * possibly-running remote command (#10142). */ +export const RUNNING_CLOSE_PROBE_TIMEOUT_MS = 4_000 + +/** Whether this close is an interactive user action that should stop and ask before + * killing a live child process. Lifecycle echoes, bulk closes, CLI/RPC closes and the + * post-confirmation re-entry are all excluded. */ +export function shouldConfirmRunningTerminalClose( + options?: RunningTerminalCloseGuardOptions +): boolean { + if (options?.force === true || options?.rejectPinned === true) { + return false + } + if (options?.skipRunningProcessConfirm === true || options?.lifecyclePtyId !== undefined) { + return false + } + const isUserReason = (reason: TerminalTabCloseReason | undefined): boolean => + reason === undefined || reason === 'user' + return isUserReason(options?.reason) && isUserReason(options?.hostCloseReason) +} + +/** Every PTY the tab could still own. `ptyIdsByTabId` is the liveness map the rest of the + * app reads, but a mounting pane is bound into the layout before the map catches up, and + * the store's own teardown collector unions both for exactly that reason — reading only + * the map would let a close slip through the window with no prompt. A stale id costs + * nothing: its probe fails and the guard falls open. */ +function collectTabPtyIds( + state: Pick, + terminalTabId: string +): string[] { + const ptyIds = new Set() + for (const ptyId of state.ptyIdsByTabId?.[terminalTabId] ?? []) { + if (ptyId) { + ptyIds.add(ptyId) + } + } + const ptyIdsByLeafId = state.terminalLayoutsByTabId?.[terminalTabId]?.ptyIdsByLeafId ?? {} + for (const ptyId of Object.values(ptyIdsByLeafId)) { + if (typeof ptyId === 'string' && ptyId) { + ptyIds.add(ptyId) + } + } + return [...ptyIds] +} + +/** + * Routes an interactive terminal-tab close through the running-process confirmation. + * Closes immediately when nothing is running, so idle tabs keep today's behavior. + */ +export function guardRunningTerminalClose(params: { + terminalTabId: string + tabLabel: string + onClose: () => void + onCancel?: () => void +}): void { + const { terminalTabId, tabLabel, onClose, onCancel } = params + const state = useAppStore.getState() + const settings = state.settings + const ptyIds = collectTabPtyIds(state, terminalTabId) + // Why: no PTY at all means there is nothing to probe (parked/hibernated tab, or a + // teardown that already cleared both maps), and the opt-out setting means the answer is + // already known. Both keep the close fully synchronous. + if (ptyIds.length === 0 || settings?.skipCloseTerminalWithRunningProcessConfirm === true) { + onClose() + return + } + + // Why: the timeout, the probe result and the error path race to decide this close, so the + // first one to land owns it instead of trusting those races to stay mutually exclusive. + let decided = false + const closeNow = (): void => { + if (decided) { + return + } + decided = true + onClose() + } + const confirmClose = (busyPtyIds: readonly string[]): void => { + if (decided) { + return + } + const copyKind = resolveBusyPtyCloseCopyKind(terminalTabId, busyPtyIds) + useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({ + terminalTabId, + tabLabel, + copyKind, + onConfirm: onClose, + ...(onCancel ? { onCancel } : {}) + }) + // Why: only once the prompt is actually up — if either call above throws, the close must + // still be free to fall through and happen. + decided = true + } + + const probeTimeout = setTimeout(() => { + try { + // Why: a probe that has not answered yet is unknown, not idle. Ask, treating every pty + // as a candidate, so a degraded relay costs a click instead of a killed remote command. + confirmClose(ptyIds) + } catch { + closeNow() + } + }, RUNNING_CLOSE_PROBE_TIMEOUT_MS) + + void Promise.allSettled(ptyIds.map((ptyId) => inspectRuntimeTerminalProcess(settings, ptyId))) + .then((results) => { + clearTimeout(probeTimeout) + if (decided) { + return + } + // Why: fail open on an *answered* probe, matching the Cmd+W pane path — a rejection + // (wedged relay, legacy provider) or a stale remote handle is not evidence of a live + // child, and a close button that silently does nothing is worse than closing a busy tab. + const busyPtyIds = ptyIds.filter((_, index) => { + const result = results[index] + return ( + result?.status === 'fulfilled' && + result.value.hasChildProcesses && + result.value.unavailable !== true + ) + }) + if (busyPtyIds.length === 0) { + closeNow() + return + } + confirmClose(busyPtyIds) + }) + // Why: allSettled never rejects, so this only fires when the decision above throws (a + // copy-kind lookup, a store subscriber). Without it the tab would silently never close + // and the user would get no feedback at all; the pane path it replaced had this catch. + .catch(() => { + clearTimeout(probeTimeout) + closeNow() + }) +} diff --git a/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts new file mode 100644 index 00000000000..3ad4229e233 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-confirm-keyboard-vs-mouse.test.ts @@ -0,0 +1,131 @@ +/** + * Regression for #10142: the tab X button / middle-click used to bypass the + * running-process close confirmation that Cmd+W enforces. Assertions unchanged + * from the adjudicated repro. + * + * Mouse close path: SortableTab (X onClick / onAuxClick button===1) -> onClose + * -> Terminal.tsx handleCloseTab -> closeTerminalTab() -> running-process guard. + * Keyboard path: Cmd+W -> TerminalPane.handleRequestClosePane -> inspectRuntimeTerminalProcess + * (split panes) or closeTerminalTab's guard (last pane) -> CloseTerminalDialog. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + requestPinnedTabCloseConfirmMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + requestPinnedTabCloseConfirmMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: vi.fn(), + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { closeTerminalTab } from './terminal-tab-actions' + +// A non-pinned terminal tab whose PTY has a live child process (e.g. `sleep 300`). +function stateWithBusyTerminalTab(closeTab: () => void): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { id: 'tab-busy', entityId: 'tab-busy', contentType: 'terminal', isPinned: false }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { leaf: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn() + } +} + +describe('#10142 close confirmation policy is the same for keyboard and mouse', () => { + const closeTab = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(stateWithBusyTerminalTab(closeTab)) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'sleep', + hasChildProcesses: true + }) + }) + + // Control: the keyboard entry point does probe for running children. + it('keyboard Cmd+W path probes for running child processes before closing', () => { + const source = readFileSync(join(__dirname, '../terminal-pane/TerminalPane.tsx'), 'utf8') + const handler = source.slice(source.indexOf('const handleRequestClosePane')) + expect(handler.slice(0, handler.indexOf('useImperativeHandle'))).toContain( + 'inspectRuntimeTerminalProcess' + ) + }) + + // Control: the harness does observe a guard when one exists — pinning blocks the same mouse close. + it('mouse close routes a pinned tab through its confirmation guard', () => { + const state = stateWithBusyTerminalTab(closeTab) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) consults the running-process probe', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalled() + }) + + it('mouse close (X button / middle-click) does not drop a busy tab without confirmation', async () => { + closeTerminalTab('tab-busy') + await Promise.resolve() + + expect(closeTab).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts new file mode 100644 index 00000000000..960eaaa8c9c --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.test.ts @@ -0,0 +1,67 @@ +/** + * The keyboard (pane-scoped) and tab-strip (pty-scoped) close prompts must word the same + * close the same way, so both resolve their copy through this module (#10142). + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { getStateMock } = vi.hoisted(() => ({ getStateMock: vi.fn() })) + +vi.mock('@/store', () => ({ useAppStore: { getState: getStateMock } })) + +import { resolveBusyPtyCloseCopyKind, resolveLeafCloseCopyKind } from './terminal-close-copy-kind' + +const LEAF_A = '11111111-1111-4111-8111-111111111111' +const LEAF_B = '22222222-2222-4222-8222-222222222222' + +function setState(overrides: Record = {}): void { + getStateMock.mockReturnValue({ + terminalLayoutsByTabId: { + 'tab-1': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a', [LEAF_B]: 'pty-b' } } + }, + agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'claude' } }, + ...overrides + }) +} + +describe('terminal close copy kind', () => { + beforeEach(() => { + vi.clearAllMocks() + setState() + }) + + it('agrees between the pane lookup and the busy-pty lookup for the same pane', () => { + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('agent') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('agent') + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_A)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a'])).toBe('command') + }) + + it('prefers the agent copy when a split has both busy', () => { + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-a', 'pty-b'])).toBe('agent') + }) + + it.each([ + ['a missing leaf id', undefined], + ['a null leaf id', null], + ['a legacy non-uuid leaf id', 'leaf'] + ])('falls back to the command copy for %s', (_label, leafId) => { + expect(resolveLeafCloseCopyKind('tab-1', leafId)).toBe('command') + }) + + it('never throws on a tab id that makePaneKey would reject', () => { + setState({ + terminalLayoutsByTabId: { 'tab:1': { ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } } } + }) + + expect(resolveLeafCloseCopyKind('tab:1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab:1', ['pty-b'])).toBe('command') + }) + + it('ignores an unknown agent type', () => { + setState({ agentStatusByPaneKey: { [`tab-1:${LEAF_B}`]: { agentType: 'unknown' } } }) + + expect(resolveLeafCloseCopyKind('tab-1', LEAF_B)).toBe('command') + expect(resolveBusyPtyCloseCopyKind('tab-1', ['pty-b'])).toBe('command') + }) +}) diff --git a/src/renderer/src/components/terminal/terminal-close-copy-kind.ts b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts new file mode 100644 index 00000000000..e012987ba4d --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-close-copy-kind.ts @@ -0,0 +1,38 @@ +import { useAppStore } from '@/store' +import type { CloseTerminalDialogCopyKind } from '../terminal-pane/CloseTerminalDialog' +import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id' + +/** + * Single source of truth for "is this pane an agent?" in the close confirmation, so the + * keyboard (pane-scoped) and tab-strip (pty-scoped) prompts cannot word the same close + * differently. Each caller resolves its own leaf id; only the policy is shared. + */ +export function resolveLeafCloseCopyKind( + tabId: string, + leafId: string | null | undefined +): CloseTerminalDialogCopyKind { + // Why: legacy layouts and mid-attach panes carry non-UUID or missing leaf ids, and + // makePaneKey throws on those — a dialog must never be the thing that breaks a close. + if (!leafId || !isTerminalLeafId(leafId) || !tabId || tabId.includes(':')) { + return 'command' + } + const agentStatusByPaneKey = useAppStore.getState().agentStatusByPaneKey ?? {} + const agentType = agentStatusByPaneKey[makePaneKey(tabId, leafId)]?.agentType + return agentType && agentType !== 'unknown' ? 'agent' : 'command' +} + +/** Copy for a whole-tab close, given the PTYs that reported a live child. Agent panes win + * in a mixed split: stopping an agent mid-task is the costlier surprise. */ +export function resolveBusyPtyCloseCopyKind( + tabId: string, + busyPtyIds: readonly string[] +): CloseTerminalDialogCopyKind { + const ptyIdsByLeafId = + useAppStore.getState().terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {} + for (const [leafId, ptyId] of Object.entries(ptyIdsByLeafId)) { + if (busyPtyIds.includes(ptyId) && resolveLeafCloseCopyKind(tabId, leafId) === 'agent') { + return 'agent' + } + } + return 'command' +} diff --git a/src/renderer/src/components/terminal/terminal-tab-actions.ts b/src/renderer/src/components/terminal/terminal-tab-actions.ts index 3dd2762f7f3..55934e7ae88 100644 --- a/src/renderer/src/components/terminal/terminal-tab-actions.ts +++ b/src/renderer/src/components/terminal/terminal-tab-actions.ts @@ -9,11 +9,20 @@ import { resolveHostSessionTabIdForWebSessionTab } from '@/runtime/web-session-tabs-sync' import { resolveTerminalWorktreeRoute } from '@/lib/terminal-worktree-route' -import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard' +import { + guardPinnedTabClose, + isUnifiedTabPinned, + resolvePinnedTabLabel, + shouldConfirmPinnedTabClose +} from '@/store/pinned-tab-close-guard' import type { TerminalTabCloseReason, TerminalTabRetirementPlan } from '@/store/slices/terminal-tab-retirement' +import { + guardRunningTerminalClose, + shouldConfirmRunningTerminalClose +} from './running-terminal-close-guard' import { closeLocalTerminalTabState } from './close-local-terminal-tab-state' import { getTerminalIncarnationHandle } from './terminal-close-incarnation' import { @@ -25,20 +34,6 @@ import { export type { PrecomputedTerminalCloseState } from './terminal-close-target' export { closeOtherTerminalTabs, closeTerminalTabsToRight } from './terminal-tab-bulk-actions' -type TerminalTabActionState = ReturnType - -function isPinnedVisibleTab( - state: TerminalTabActionState, - worktreeId: string, - visibleId: string -): boolean { - return ( - (state.unifiedTabsByWorktree?.[worktreeId] ?? []).some( - (tab) => (tab.id === visibleId || tab.entityId === visibleId) && tab.isPinned - ) ?? false - ) -} - export function closeTerminalTab( tabId: string, options?: { @@ -51,6 +46,9 @@ export function closeTerminalTab( hostCloseReason?: TerminalTabCloseReason /** PTY whose lifecycle event initiated the host close. */ lifecyclePtyId?: string + /** Set by callers that must never raise a modal (bulk closes, CLI/RPC, lifecycle + * fallbacks) and by the re-entry that runs once the user confirmed. */ + skipRunningProcessConfirm?: boolean captureRecentlyClosed?: boolean localPtyTeardownOwnedExternally?: boolean precomputedRetirementPlan?: TerminalTabRetirementPlan @@ -82,7 +80,7 @@ export function closeTerminalTab( if ( options?.reason !== 'pty-exit' && !options?.force && - isPinnedVisibleTab(state, owningWorktreeId, terminalTabId) + isUnifiedTabPinned(state, owningWorktreeId, terminalTabId) ) { // Why: background lifecycle callers cannot safely wait on a modal whose // owner may be unattended; reject pinned tabs without bypassing the guard. @@ -90,10 +88,30 @@ export function closeTerminalTab( options.onCancel?.() return } - guardPinnedTabClose({ - isPinned: true, + // Why: the pin prompt supersedes the running-process one only when it actually + // appears. With `confirmClosePinnedTab` off it says nothing, so fall through and let + // a busy pinned tab still get asked — Cmd+W did exactly that before #10142. + if (shouldConfirmPinnedTabClose(state)) { + guardPinnedTabClose({ + isPinned: true, + tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), + onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + ...(options?.onCancel ? { onCancel: options.onCancel } : {}) + }) + return + } + } + + // Why: the X button, middle-click and the tab menu used to skip the running-process + // prompt that Cmd+W enforced (#10142). Guarding here — above the web-runtime branch so + // host-backed tabs are covered too — gives every close path one shared policy. + if (shouldConfirmRunningTerminalClose(options)) { + guardRunningTerminalClose({ + terminalTabId, tabLabel: resolvePinnedTabLabel(state, owningWorktreeId, terminalTabId), - onClose: () => closeTerminalTab(tabId, { ...options, force: true }), + // Why: re-enter instead of continuing inline so pinned/route/precomputed state is + // re-validated against fresh state after an arbitrarily long dialog. + onClose: () => closeTerminalTab(tabId, { ...options, skipRunningProcessConfirm: true }), ...(options?.onCancel ? { onCancel: options.onCancel } : {}) }) return diff --git a/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts new file mode 100644 index 00000000000..066c5795ef9 --- /dev/null +++ b/src/renderer/src/components/terminal/terminal-tab-close-running-confirm.test.ts @@ -0,0 +1,263 @@ +/** + * Which `closeTerminalTab` callers reach the running-process confirmation (#10142). + * Kept out of terminal-tab-actions.test.ts, which is already at its max-lines budget. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + closeWebRuntimeSessionTabMock, + getStateMock, + inspectRuntimeTerminalProcessMock, + isWebRuntimeSessionActiveMock, + requestPinnedTabCloseConfirmMock, + resolveHostSessionTabIdForWebSessionTabMock +} = vi.hoisted(() => ({ + closeWebRuntimeSessionTabMock: vi.fn(), + getStateMock: vi.fn(), + inspectRuntimeTerminalProcessMock: vi.fn(), + isWebRuntimeSessionActiveMock: vi.fn(() => false), + requestPinnedTabCloseConfirmMock: vi.fn(), + resolveHostSessionTabIdForWebSessionTabMock: vi.fn<() => string | null>(() => null) +})) + +vi.mock('@/store', () => ({ + useAppStore: { getState: getStateMock } +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + activateWebRuntimeSessionTab: vi.fn(), + closeWebRuntimeSessionTab: closeWebRuntimeSessionTabMock, + createWebRuntimeSessionTerminal: vi.fn(), + isWebRuntimeSessionActive: isWebRuntimeSessionActiveMock, + isWebTerminalSurfaceTabId: vi.fn(() => false), + toHostSessionTabId: vi.fn((tabId: string) => tabId) +})) + +vi.mock('@/runtime/web-session-tabs-sync', () => ({ + getLatestWebSessionTabsPublicationEpoch: vi.fn(() => 'epoch-1'), + resolveHostSessionTabIdForWebSessionTab: resolveHostSessionTabIdForWebSessionTabMock +})) + +vi.mock('@/runtime/runtime-terminal-inspection', () => ({ + inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock +})) + +import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm' +import { closeTerminalTab } from './terminal-tab-actions' + +const LEAF = '33333333-3333-4333-8333-333333333333' + +const closeTab = vi.fn() + +function busyTabState(overrides: Record = {}): Record { + return { + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: true }, + tabsByWorktree: { 'wt-1': [{ id: 'tab-busy' }, { id: 'tab-other' }] }, + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'tab-busy', + entityId: 'tab-busy', + contentType: 'terminal', + isPinned: false, + label: 'dev server' + }, + { id: 'tab-other', entityId: 'tab-other', contentType: 'terminal', isPinned: false } + ] + }, + ptyIdsByTabId: { 'tab-busy': ['pty-busy'] }, + terminalLayoutsByTabId: { 'tab-busy': { ptyIdsByLeafId: { [LEAF]: 'pty-busy' } } }, + agentStatusByPaneKey: {}, + openFiles: [], + browserTabsByWorktree: {}, + activeWorktreeId: 'wt-1', + activeTabId: 'tab-busy', + closeTab, + requestPinnedTabCloseConfirm: requestPinnedTabCloseConfirmMock, + setActiveTab: vi.fn(), + setActiveFile: vi.fn(), + setActiveTabType: vi.fn(), + setActiveBrowserTab: vi.fn(), + setActiveWorktree: vi.fn(), + ...overrides + } +} + +function visibleRequest() { + return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm +} + +async function settleProbe(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +describe('closeTerminalTab running-process confirmation', () => { + beforeEach(() => { + vi.clearAllMocks() + getStateMock.mockReturnValue(busyTabState()) + isWebRuntimeSessionActiveMock.mockReturnValue(false) + resolveHostSessionTabIdForWebSessionTabMock.mockReturnValue(null) + inspectRuntimeTerminalProcessMock.mockResolvedValue({ + foregroundProcess: 'npm', + hasChildProcesses: true + }) + }) + + afterEach(() => { + const store = useRunningTerminalCloseConfirmStore.getState() + while (visibleRequest()) { + store.dismissRunningTerminalClose() + } + }) + + it('closes a busy tab only after the confirmation is accepted', async () => { + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ + terminalTabId: 'tab-busy', + tabLabel: 'dev server', + copyKind: 'command' + }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('keeps the tab and runs onCancel when the confirmation is dismissed', async () => { + const onCancel = vi.fn() + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onCancel, onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onClosed).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('still reports the close through onClosed after the confirmation', async () => { + const onClosed = vi.fn() + + closeTerminalTab('tab-busy', { onClosed }) + await settleProbe() + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(onClosed).toHaveBeenCalledTimes(1) + }) + + it('stays fully synchronous for a tab with no live pty', () => { + getStateMock.mockReturnValue(busyTabState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} })) + + closeTerminalTab('tab-busy') + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it.each([ + ['force (post-confirmation re-entry)', { force: true }], + ['rejectPinned (CLI tab close request)', { rejectPinned: true }], + ['skipRunningProcessConfirm (bulk / CLI)', { skipRunningProcessConfirm: true }], + ['reason pty-exit', { reason: 'pty-exit' as const }], + ['reason cleanup', { reason: 'cleanup' as const }], + ['hostCloseReason pty-exit', { hostCloseReason: 'pty-exit' as const }], + ['lifecyclePtyId', { lifecyclePtyId: 'pty-busy' }] + ])('never probes for a close carrying %s', (_label, options) => { + closeTerminalTab('tab-busy', options) + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + }) + + it('shows only the pinned dialog for a pinned busy tab, and its confirm does not probe', () => { + const state = busyTabState() + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + + expect(requestPinnedTabCloseConfirmMock).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + + const onConfirm = requestPinnedTabCloseConfirmMock.mock.calls[0]![0].onConfirm as () => void + onConfirm() + + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + // Why: the pin prompt supersedes this one only when it actually appears. With the pin + // confirmation off it says nothing, and the running command must still be announced. + it('still asks about a running command on a pinned tab when pin confirmation is off', async () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(requestPinnedTabCloseConfirmMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + expect(visibleRequest()).toMatchObject({ terminalTabId: 'tab-busy' }) + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeTab).toHaveBeenCalledWith('tab-busy') + }) + + it('rejects a pinned tab for a CLI close even when pin confirmation is off', () => { + const state = busyTabState({ + settings: { activeRuntimeEnvironmentId: null, confirmClosePinnedTab: false } + }) + ;(state.unifiedTabsByWorktree as Record)[ + 'wt-1' + ]![0]!.isPinned = true + getStateMock.mockReturnValue(state) + const onCancel = vi.fn() + + closeTerminalTab('tab-busy', { rejectPinned: true, onCancel }) + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + }) + + it('confirms before telling a paired host to close its busy tab', async () => { + isWebRuntimeSessionActiveMock.mockReturnValue(true) + getStateMock.mockReturnValue( + busyTabState({ settings: { activeRuntimeEnvironmentId: 'web-runtime' } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(closeWebRuntimeSessionTabMock).not.toHaveBeenCalled() + expect(closeTab).not.toHaveBeenCalled() + + useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose() + + expect(closeWebRuntimeSessionTabMock).toHaveBeenCalledTimes(1) + }) + + it('picks the agent copy for a busy agent pane', async () => { + getStateMock.mockReturnValue( + busyTabState({ agentStatusByPaneKey: { [`tab-busy:${LEAF}`]: { agentType: 'claude' } } }) + ) + + closeTerminalTab('tab-busy') + await settleProbe() + + expect(visibleRequest()?.copyKind).toBe('agent') + }) +}) diff --git a/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts new file mode 100644 index 00000000000..05ac5130e36 --- /dev/null +++ b/src/renderer/src/components/worktree-jump-palette-sleeping-filter.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isSleepingSweepExemptWorkspace } from './sidebar/visible-worktrees' +import type { Worktree } from '../../../shared/types' + +const source = readFileSync(join(__dirname, 'WorktreeJumpPalette.tsx'), 'utf8') + +function makeWorktree(overrides: Partial = {}): Worktree { + return { + id: 'wt-main', + repoId: 'repo1', + path: '/tmp/repo1', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('Cmd+J empty-query "Hide sleeping" pass (#8873)', () => { + // Why source-level: the palette re-implements the sidebar's filter pass + // inline, so the only structural guarantee that the two agree is that both + // call the shared predicate. A behavioral copy here would not catch a + // hand-rolled duplicate creeping back in. + it('routes the sleeping sweep through the shared exemption predicate', () => { + const start = source.indexOf('const emptyQueryVisibleWorktrees = useMemo(') + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf('const { visibleWorktreesForState', start) + const filterPass = source.slice(start, end) + + expect(filterPass).toContain( + '!isSleepingSweepExemptWorkspace(worktree, alwaysShowDefaultBranchWorkspace)' + ) + expect(filterPass).toContain('alwaysShowDefaultBranchWorkspace,') + }) + + it('reads the flag from the same store field the sidebar uses', () => { + expect(source).toContain( + 'const alwaysShowDefaultBranchWorkspace = useAppStore((s) => s.alwaysShowDefaultBranchWorkspace)' + ) + }) + + it('exempts a project entry point by default and honours an explicit opt-out', () => { + const main = makeWorktree() + + expect(isSleepingSweepExemptWorkspace(main, undefined)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, true)).toBe(true) + expect(isSleepingSweepExemptWorkspace(main, false)).toBe(false) + }) + + it('never exempts a non-main workspace', () => { + const feature = makeWorktree({ id: 'wt-feature', isMainWorktree: false }) + + expect(isSleepingSweepExemptWorkspace(feature, true)).toBe(false) + expect(isSleepingSweepExemptWorkspace(feature, undefined)).toBe(false) + }) +}) diff --git a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts index 1e5f5e45e35..b2367e33721 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch-targets.test.ts @@ -73,6 +73,8 @@ describe('getEditorExternalWatchTargets', () => { rightSidebarExplorerView: args.rightSidebarExplorerView ?? 'files', gitStatusHugeByWorktree: args.gitStatusHugeByWorktree ?? {}, sshConnectionStates: args.sshConnectionStates ?? new Map(), + folderWorkspaces: [], + projectGroups: [], settings: args.runtimeEnvironmentId === undefined ? null diff --git a/src/renderer/src/hooks/useEditorExternalWatch.ts b/src/renderer/src/hooks/useEditorExternalWatch.ts index 6ac41b3b6be..1b4ff62a44d 100644 --- a/src/renderer/src/hooks/useEditorExternalWatch.ts +++ b/src/renderer/src/hooks/useEditorExternalWatch.ts @@ -32,6 +32,8 @@ import { import { isGitRepoKind } from '../../../shared/repo-kind' import { markFileChangedOnDisk } from '@/components/editor/editor-changed-on-disk-mark' import { getDiskBaselineSignature } from '@/components/editor/diff-content-signature' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' +import { getFolderWorkspaceConnectionId } from '@/lib/folder-workspace-connection' // Why: atomic writes burst same-path events; one reload dispatch each fans out into N EditorPanel rebuilds that can wedge the renderer (issue #826), so debounce per (worktreeId+path). const EXTERNAL_RELOAD_DEBOUNCE_MS = 75 @@ -95,6 +97,8 @@ export type EditorExternalWatchTargetState = Pick< | 'rightSidebarExplorerView' | 'gitStatusHugeByWorktree' | 'sshConnectionStates' + | 'folderWorkspaces' + | 'projectGroups' > let cachedOpenFiles: AppState['openFiles'] | null = null @@ -107,6 +111,8 @@ let cachedRightSidebarTab: AppState['rightSidebarTab'] | null = null let cachedRightSidebarExplorerView: AppState['rightSidebarExplorerView'] | null = null let cachedGitStatusHugeByWorktree: AppState['gitStatusHugeByWorktree'] | null = null let cachedSshConnectionStates: AppState['sshConnectionStates'] | null = null +let cachedFolderWorkspaces: AppState['folderWorkspaces'] | null = null +let cachedProjectGroups: AppState['projectGroups'] | null = null let cachedWatchedTargetsSnapshot: WatchedTargetsSnapshot = { targets: [], targetsKey: '' } export function getWatchedTargetKey(target: WatchedTarget): string { @@ -132,7 +138,9 @@ export function getEditorExternalWatchTargets( cachedRightSidebarTab === state.rightSidebarTab && cachedRightSidebarExplorerView === state.rightSidebarExplorerView && cachedGitStatusHugeByWorktree === state.gitStatusHugeByWorktree && - cachedSshConnectionStates === state.sshConnectionStates + cachedSshConnectionStates === state.sshConnectionStates && + cachedFolderWorkspaces === state.folderWorkspaces && + cachedProjectGroups === state.projectGroups ) { return cachedWatchedTargetsSnapshot } @@ -183,18 +191,31 @@ export function getEditorExternalWatchTargets( const sortedWorktreeIds = Array.from(targetOwnersByWorktreeId.keys()).sort() for (const id of sortedWorktreeIds) { const wt = findWorktreeById(state.worktreesByRepo, id) - if (!wt) { + const workspaceScope = parseWorkspaceKey(id) + const folderWorkspace = + workspaceScope?.type === 'folder' + ? state.folderWorkspaces.find( + (workspace) => workspace.id === workspaceScope.folderWorkspaceId + ) + : undefined + if (!wt && !folderWorkspace) { + continue + } + const repo = wt ? state.repos.find((r) => r.id === wt.repoId) : undefined + const connectionId = folderWorkspace + ? getFolderWorkspaceConnectionId(state, folderWorkspace.id) + : repo?.connectionId + if (connectionId === undefined && folderWorkspace) { continue } - const repo = state.repos.find((r) => r.id === wt.repoId) const owners = Array.from(targetOwnersByWorktreeId.get(id) ?? []).sort((a, b) => (a ?? '').localeCompare(b ?? '') ) for (const owner of owners) { const target = { worktreeId: id, - worktreePath: wt.path, - connectionId: repo?.connectionId ?? undefined, + worktreePath: wt?.path ?? folderWorkspace!.folderPath, + connectionId: connectionId ?? undefined, runtimeEnvironmentId: owner } nextTargets.push(target) @@ -213,6 +234,8 @@ export function getEditorExternalWatchTargets( cachedRightSidebarExplorerView = state.rightSidebarExplorerView cachedGitStatusHugeByWorktree = state.gitStatusHugeByWorktree cachedSshConnectionStates = state.sshConnectionStates + cachedFolderWorkspaces = state.folderWorkspaces + cachedProjectGroups = state.projectGroups if (targetsKey === cachedWatchedTargetsSnapshot.targetsKey) { return cachedWatchedTargetsSnapshot diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index e59b6db6b95..c13cf20057a 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -3382,7 +3382,10 @@ describe('useIpcEvents browser tab close routing', () => { closeTerminalListenerRef.current?.({ tabId: 'terminal-1' }) - expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1') + // The CLI/RPC caller is answered immediately, so this close must never raise a modal. + expect(closeTerminalTabMock).toHaveBeenCalledWith('terminal-1', { + skipRunningProcessConfirm: true + }) }) it('acknowledges whole-tab close only after the fresh session is durably persisted', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index be6a5e4b2d3..bed3e4e86c8 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -24,6 +24,7 @@ import type { SplitTerminalPaneDetail, CloseTerminalPaneDetail } from '@/constan import { getVisibleWorktreeIds } from '@/components/sidebar/visible-worktrees' import { activateTabNumberShortcut } from '@/lib/tab-number-shortcuts' import { nextEditorFontZoomLevel, computeEditorFontSize } from '@/lib/editor-font-zoom' +import { canConnectSshStatus } from '@/ssh/ssh-connection-recoverability' import type { TerminalLayoutSnapshot, TerminalPaneLayoutNode, @@ -1965,7 +1966,8 @@ export function useIpcEvents(): void { const detail: CloseTerminalPaneDetail = { tabId, paneRuntimeId } window.dispatchEvent(new CustomEvent(CLOSE_TERMINAL_PANE_EVENT, { detail })) } else { - closeTerminalTab(tabId) + // Why: the CLI/RPC caller is answered immediately, so it cannot wait on a modal. + closeTerminalTab(tabId, { skipRunningProcessConfirm: true }) } }) ) @@ -2805,7 +2807,7 @@ export function useIpcEvents(): void { const previous = store.sshConnectionStates?.get(targetId) store.setSshConnectionState(targetId, state) - if (['disconnected', 'auth-failed', 'reconnection-failed', 'error'].includes(state.status)) { + if (canConnectSshStatus(state.status)) { reconnectAuthorityByTarget.delete(targetId) reconnectCoordinator.invalidate(targetId) // Why: remote agent list is tied to a live relay; clear on disconnect so reconnect re-detects against the new relay. diff --git a/src/renderer/src/i18n/i18n.ts b/src/renderer/src/i18n/i18n.ts index 3f35e2ec224..0d5cbad54cf 100644 --- a/src/renderer/src/i18n/i18n.ts +++ b/src/renderer/src/i18n/i18n.ts @@ -95,6 +95,24 @@ export async function setRendererUiLanguage(language: UiLanguage): Promise const registeredPluginLanguages = new Set() let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +/** + * BCP-47 tag for `Intl` formatting. Plugin catalogs register under a synthetic + * `plugin` resource language that `Intl` rejects, so fall back to the tag + * the pack declares (for example `ru-RU`) and finally to the default locale. + */ +export function getIntlLocale(): string { + const active = i18n.language + const pack = pluginLanguagePacks.find((entry) => entry.resourceLanguage === active) + const candidate = pack?.locale ?? active + try { + // Why: an empty result means Intl has no data for the tag, so fall through + // to the default locale instead of letting Intl pick the runtime one. + return Intl.DateTimeFormat.supportedLocalesOf(candidate)[0] ?? DEFAULT_LOCALE + } catch { + return DEFAULT_LOCALE + } +} + export function resolveRendererResourceLanguage(language: string): string { return pluginLanguagePacks.find((pack) => pack.id === language)?.resourceLanguage ?? language } diff --git a/src/renderer/src/i18n/integration-card-status-localization.test.ts b/src/renderer/src/i18n/integration-card-status-localization.test.ts new file mode 100644 index 00000000000..739d4ed5edf --- /dev/null +++ b/src/renderer/src/i18n/integration-card-status-localization.test.ts @@ -0,0 +1,44 @@ +/** + * Integration cards build their status pill inside the JSX call, which the + * coverage audit does not inspect — it looks at JSX attributes and object + * properties, not conditional expressions passed to a prop. These assertions + * pin the catalog contract so a card that goes back to a bare literal fails here. + */ +import { describe, expect, it } from 'vitest' +import en from './locales/en.json' + +function lookup(key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + en as unknown + ) + return typeof value === 'string' ? value : undefined +} + +const REQUIRED_KEYS: Record = { + // Linear and other task trackers + 'auto.components.settings.task.tracker.integration.cards.statusConnected': 'Connected', + 'auto.components.settings.task.tracker.integration.cards.statusNotConnected': 'Not connected', + // Jira + 'auto.components.settings.jira.integration.card.statusConnected': 'Connected', + 'auto.components.settings.jira.integration.card.statusNotConnected': 'Not connected', + // Bitbucket, Azure DevOps, Gitea + 'auto.components.settings.token.source.control.integration.cards.statusConnected': 'Connected', + 'auto.components.settings.token.source.control.integration.cards.statusConfigured': 'Configured', + 'auto.components.settings.token.source.control.integration.cards.statusUnavailable': + 'Unavailable', + 'auto.components.settings.token.source.control.integration.cards.statusNotConfigured': + 'Not configured', + 'auto.components.settings.token.source.control.integration.cards.statusOptionalSetup': + 'Optional setup', + 'auto.components.settings.token.source.control.integration.cards.statusAuthFailed': 'Auth failed' +} + +describe('integration card status labels', () => { + it.each(Object.entries(REQUIRED_KEYS))('keeps %s in the English catalog', (key, expected) => { + expect(lookup(key)).toBe(expected) + }) +}) diff --git a/src/renderer/src/i18n/intl-locale.test.ts b/src/renderer/src/i18n/intl-locale.test.ts new file mode 100644 index 00000000000..69f0de08455 --- /dev/null +++ b/src/renderer/src/i18n/intl-locale.test.ts @@ -0,0 +1,79 @@ +/** + * `getIntlLocale()` exists because plugin catalogs register under a synthetic + * `plugin` resource language. Passing that straight to `Intl` throws, and + * passing `undefined` silently falls back to the OS locale rather than the + * language the user selected. + * + * The branch assertions stub `supportedLocalesOf` so they describe the helper's + * logic rather than whichever locales the runtime's ICU build happens to carry. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getIntlLocale, i18n, setRendererPluginLanguagePacks } from './i18n' +import { pluginLanguageResourceId } from '../../../shared/plugins/plugin-language-pack-artifact' +import { DEFAULT_LOCALE } from './supported-languages' + +const PACK_ID = 'plugin:smwbev.russian/ru-RU' as const +const PACK_RESOURCE = pluginLanguageResourceId(PACK_ID) +const PACK = { + id: PACK_ID, + resourceLanguage: PACK_RESOURCE, + pluginKey: 'smwbev.russian', + locale: 'ru-RU', + catalog: {} +} + +type Packs = Parameters[0] + +async function activate(language: string, packs: Packs = []): Promise { + setRendererPluginLanguagePacks(packs) + await i18n.changeLanguage(language) +} + +/** Stubs ICU lookup so a locale counts as supported only when listed. */ +function withSupportedLocales(supported: readonly string[]): void { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation((requested) => { + const tags = Array.isArray(requested) ? requested : [requested as string] + return tags.filter((tag) => supported.includes(tag)) as string[] + }) +} + +afterEach(async () => { + vi.restoreAllMocks() + setRendererPluginLanguagePacks([]) + await i18n.changeLanguage(DEFAULT_LOCALE) +}) + +describe('getIntlLocale', () => { + it('passes a supported built-in locale straight through', async () => { + withSupportedLocales(['es']) + await activate('es') + expect(getIntlLocale()).toBe('es') + }) + + it('resolves a plugin resource language to the locale the pack declares', async () => { + withSupportedLocales(['ru-RU']) + await activate(PACK_RESOURCE, [PACK]) + // Without the pack lookup this would reach Intl as `plugin`. + expect(getIntlLocale()).toBe('ru-RU') + }) + + it('falls back to the default locale when ICU has no data for the tag', async () => { + withSupportedLocales([]) + await activate('es') + // Returning the tag here would let Intl silently format with the runtime locale. + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('falls back to the default locale when Intl rejects the tag', async () => { + vi.spyOn(Intl.DateTimeFormat, 'supportedLocalesOf').mockImplementation(() => { + throw new RangeError('invalid language tag') + }) + await activate(PACK_RESOURCE) + expect(getIntlLocale()).toBe(DEFAULT_LOCALE) + }) + + it('keeps the synthetic resource language unusable for Intl directly', () => { + // Unstubbed on purpose: this is a property of the tag, not of ICU data. + expect(() => Intl.DateTimeFormat.supportedLocalesOf(PACK_RESOURCE)).toThrow(RangeError) + }) +}) diff --git a/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts b/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts new file mode 100644 index 00000000000..5067c0b8f8a --- /dev/null +++ b/src/renderer/src/i18n/ja-technical-literal-mistranslations.test.ts @@ -0,0 +1,42 @@ +/** + * Guards the source control "Push"/"Pull" button mistranslations so + * bootstrap re-translation cannot silently regress them. + */ +import { describe, expect, it } from 'vitest' +import ja from './locales/ja.json' + +function findByKey(node: unknown, key: string): string | undefined { + if (!node || typeof node !== 'object') { + return undefined + } + if (Array.isArray(node)) { + for (const item of node) { + const found = findByKey(item, key) + if (found !== undefined) { + return found + } + } + return undefined + } + const record = node as Record + if (typeof record[key] === 'string') { + return record[key] as string + } + for (const value of Object.values(record)) { + const found = findByKey(value, key) + if (found !== undefined) { + return found + } + } + return undefined +} + +describe('ja technical literal / sense fixes', () => { + it('translates the source control Push action as プッシュ, not 押す', () => { + expect(findByKey(ja, '95550cff15')).toBe('プッシュ') + }) + + it('translates the source control Pull action as プル, not 引く', () => { + expect(findByKey(ja, 'd64292a938')).toBe('プル') + }) +}) diff --git a/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts new file mode 100644 index 00000000000..f00f4686b0e --- /dev/null +++ b/src/renderer/src/i18n/ko-ui-semantic-mistranslations.test.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs' + +import { describe, expect, it } from 'vitest' +import ko from './locales/ko.json' + +const correctedValues = { + 'auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef': + ') 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다.', + 'auto.components.settings.source.control.action.recipe.options.commitMessage': + '스테이징된 변경 사항에서 commit 메시지를 생성합니다.', + 'auto.components.settings.DevToolsPane.orcaCloudDescription': + '자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다.', + 'auto.components.settings.EphemeralVmsPane.whatTitle': '이 스킬로 함께 하는 작업', + 'auto.components.settings.EphemeralVmsPane.recipes': '레시피', + 'auto.components.right.sidebar.SourceControl.a5e5a11090': + '모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다.', + 'auto.components.right.sidebar.SourceControl.6d7f2a47e5': '폴더의 변경 사항 취소', + 'auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a': + '이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다.', + 'auto.components.right.sidebar.source.control.primary.action.5a477d80cb': + '모든 변경 사항 스테이징' +} as const + +function getLocaleValue(path: string): unknown { + return path.split('.').reduce((node, segment) => { + if (!node || typeof node !== 'object' || Array.isArray(node)) { + return undefined + } + return (node as Record)[segment] + }, ko) +} + +describe('Korean UI semantic mistranslation fixes', () => { + it('keeps the corrected values in the Korean catalog', () => { + for (const [path, expected] of Object.entries(correctedValues)) { + expect(getLocaleValue(path), path).toBe(expected) + } + }) + + it('keeps the corrected values in the translation overrides', () => { + const overrides = JSON.parse( + fs.readFileSync( + new URL('../../../../config/scripts/locale-ko-key-overrides.json', import.meta.url), + 'utf8' + ) + ) as Record + + for (const [path, expected] of Object.entries(correctedValues)) { + expect(overrides[path]?.ko, path).toBe(expected) + } + }) +}) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index b8580ef27f2..a6b8d016fcf 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -270,6 +270,7 @@ "4a18052018": "Local {{value0}} is behind {{value1}}", "903b51c2ed": "Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}} because {{value2}}", "0216895fb5": "Failed to delete branch", + "c6cf133786": "This workspace is no longer available.", "19db0085fb": "Local branch deleted", "2b0afc7f14": "Could not keep local {{value0}} up to date", "670864ab52": "Keeping local {{value0}} up to date", @@ -279,7 +280,8 @@ "3b57982bf6": "Git could not safely delete branch \"{{value0}}\" after deleting workspace \"{{value1}}\", so Orca kept it to avoid losing local commits.", "81f13f48d2": "Git could not safely delete branch \"{{value0}}\" after deleting worktree \"{{value1}}\", so Orca kept it to avoid losing local commits.", "runtimeScopeForbiddenTitle": "This connection has limited (mobile) access", - "runtimeScopeForbiddenDescription": "Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server." + "runtimeScopeForbiddenDescription": "Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.", + "a17f4d2e93": "Could not update this workspace." }, "repos": { "2975400634": "Update Orca server to open non-Git folders on this runtime.", @@ -293,7 +295,8 @@ "3be0f7df04": "Cannot open folder on selected runtime", "15cf5319ec": "{{path}} was checked on {{hostName}}, but that host did not report a usable folder.", "2dcd706774": "Project also exists in another profile", - "presenceProfileOverflow": "{{names}} +{{count}} more" + "presenceProfileOverflow": "{{names}} +{{count}} more", + "removeProjectFailed": "Failed to remove project" }, "settings": { "e12dab333b": "Failed to switch servers", @@ -4309,7 +4312,9 @@ "ee240a39eb": "Edit filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "SidebarHeader": { "25a95899c9": "Add Project", @@ -4374,7 +4379,9 @@ "82594419ba": "Filters", "automationCreated": "Hide automation-created", "cliCreated": "Hide CLI-created", - "detachedHead": "Hide detached HEAD" + "detachedHead": "Hide detached HEAD", + "keepDefaultBranch": "Except default branch", + "keepDefaultBranchAria": "Keep the default branch visible while hiding sleeping workspaces" }, "sidebarHostOptions": { "3e102f111c": "All hosts", @@ -4442,19 +4449,6 @@ "folderPathIdentity": "Branch / folder path", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "Connecting...", - "89385db176": "Dismiss", - "656368f3a2": "Reconnection failed", - "376bed88e5": "The connection to the remote host encountered an error.", - "4afcca1d24": "Reconnect", - "11552bf786": "SSH Disconnected", - "cb5938ae79": "Reconnecting...", - "disconnected": "This SSH host is not connected.", - "reconnecting": "Reconnecting to the remote host...", - "reconnectionFailed": "Reconnection to the remote host failed.", - "authFailed": "Authentication to the remote host failed." - }, "SshTargetRow": { "4677394048": "Connecting…", "75ad429b5d": "Connect" @@ -4688,7 +4682,8 @@ "65770ad0f0": "Edit GitHub links and notes for this workspace.", "382fd11a3e": "Edit Worktree Details", "2174f17011": "Save", - "61d6f612cf": "Saving..." + "61d6f612cf": "Saving...", + "a0d191b7a7": "Edit issue links, pull request links, and notes for this workspace." }, "WorktreeOpenInMenu": { "localOnly": "Local only", @@ -4769,7 +4764,8 @@ "0899ebdb28": "Git already forgot this workspace, but its directory is still on disk. Use Force Delete to remove the orphaned directory.", "locked": "This workspace is locked by Git. Run git worktree unlock from its repository, then retry deletion.", "lockedReason": "This workspace is locked by Git. Git reported: {{value0}}. Run git worktree unlock from its repository, then retry deletion.", - "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway." + "unstoppedPty": "Orca could not confirm every terminal in this workspace has exited, so it stopped before deleting any files. Use Force Delete to remove it anyway.", + "unstoppedPtyLive": "This workspace still has running terminals, so Orca stopped before deleting any files. Force Delete will kill them and discard any uncommitted work they hold." } } }, @@ -5149,6 +5145,39 @@ "3b7ea51793": "Clear search", "7f1c2e94a5": "Search text is too long — the board is unfiltered", "9a4d0f6b21": "Too long" + }, + "WorktreeIssueLinkField": { + "25852bfc59": "Linear", + "5b440069e6": "GitHub", + "161b2d053a": "Open linked issue", + "d4785f9954": "Issue links are set when a folder workspace is created and can't be changed here yet.", + "964d9bc00a": "Not a Linear issue key or linear.app issue URL.", + "0a7a2c6efd": "Not a GitHub issue number or issue URL.", + "72486800ff": "Saving unlinks {{first}} and {{second}} — a workspace tracks one issue.", + "2c245ac134": "Saving unlinks {{link}} — a workspace tracks one issue.", + "d8c8a30d1f": "Couldn't open that issue. Check the identifier and your Linear connection.", + "269198eeda": "Couldn't open that issue. Check the number and your GitHub connection.", + "f047887705": "Paste a GitHub or Linear URL, or enter a number. Leave blank to remove the link.", + "ad78f9bee2": "Issue", + "662ae142f8": "Issue #, or a GitHub or Linear URL", + "929c98d05a": "Issue provider" + }, + "worktreeIssueDisplacement": { + "3f61c0a8d2": "Linear {{value}}", + "9c4b7e1f60": "GitHub #{{value}}" + }, + "WorktreeCardSshHostControl": { + "connectFailed": "SSH connection failed", + "removedTooltip": "SSH host removed — reconnect unavailable", + "removedName": "SSH host {{value0}} was removed", + "connectedTooltip": "Project on SSH host", + "connectedName": "Project on SSH host {{value0}}", + "connectingName": "Connecting to SSH host {{value0}}", + "authFailedName": "Reconnect SSH host {{value0}} — authentication failed", + "retryName": "Retry SSH connection to {{value0}}", + "connectName": "Connect to SSH host {{value0}}", + "authFailedTooltip": "{{value0}} · authentication failed", + "failedTooltip": "{{value0}} · connection failed" } }, "shared": { @@ -6640,7 +6669,10 @@ "f79972271a": "No GitHub remote found for this repo.", "4d039317f4": "Website favicon", "acf31559a0": "Enter a valid website URL.", - "868c5c9b56": "Failed to import repo icon" + "868c5c9b56": "Failed to import repo icon", + "emojiTooLongForRepoIcon": "This emoji can't be used as a repo icon.", + "currentEmojiSelection": "Current: {{value0}}", + "searchEmojiPlaceholder": "Search emoji" }, "RepositoryPane": { "15a99d9b9f": "Relative paths resolve from this project root.", @@ -8480,7 +8512,9 @@ "d5c5b47bb9": "update", "fb854902d9": "connecting", "9a9f8d4910": "Connect Jira Cloud to browse, create, and link issues.", - "74f3063026": "{{value0}} site{{value1}} connected" + "74f3063026": "{{value0}} site{{value1}} connected", + "statusConnected": "Connected", + "statusNotConnected": "Not connected" } } }, @@ -9303,7 +9337,9 @@ "disconnect_all": "Disconnect", "account_scope_prefix": "Account scope", "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", - "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." + "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it.", + "statusConnected": "Connected", + "statusNotConnected": "Not connected" } } } @@ -9344,7 +9380,13 @@ "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" + "0fa5629dad": "Pull requests and build statuses", + "statusConnected": "Connected", + "statusUnavailable": "Unavailable", + "statusNotConfigured": "Not configured", + "statusAuthFailed": "Auth failed", + "statusConfigured": "Configured", + "statusOptionalSetup": "Optional setup" } } } @@ -9808,6 +9850,10 @@ "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." }, + "BrowserLinkRoutingSetting": { + "description": "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. {{shortcut}} always uses your system browser.", + "descriptionBase": "Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor." + }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", "titleOrca": "Hold Shift to open in Orca", @@ -10804,7 +10850,6 @@ "a54ae21c6f": "Status:", "e4e3af15ee": "View full details", "b8c4e2a1f7": "View full logs", - "2524d1fb83": "Log tail available in full details.", "a2fb3f4408": "Showing first 100 jobs", "df137989b3": "Showing first 20 annotations", "1f2b980522": "Loading check details…", @@ -10856,7 +10901,8 @@ "5e6e5a13fa": "View", "actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked.", "b3195cba33": "Unmark author as bot", - "f588b46a6c": "Mark author as bot" + "f588b46a6c": "Mark author as bot", + "checkDetailsLoadFailed": "Failed to load check details." }, "empty": { "state": { @@ -13477,7 +13523,8 @@ "4a9568f773": "Back", "4f86e2a10b": "Skip tour", "d974f32a83": "Dismiss tour", - "ffa4412b66": "next" + "ffa4412b66": "next", + "complete": "Done" }, "ContextualTourProgressDots": { "7734cb8ad3": "of", @@ -13487,7 +13534,17 @@ "tour": { "overlay": { "measurement": { - "38b3155418": "Next" + "38b3155418": "Next", + "automations": { + "intro": { + "title": "What is an automation?", + "body": "Automations run agent work on a schedule. Add an automation by clicking this button." + }, + "results": { + "title": "Find the results", + "body": "Runs show when automations ran, what happened, and where to inspect their output." + } + } } } } @@ -13909,7 +13966,20 @@ "a21f6c33ad": "Automation source refreshed.", "53f06f0ad5": "Retry source", "pendingAutomationMissing": "Automation no longer available.", - "pendingAutomationRunMissing": "Run history no longer available." + "pendingAutomationRunMissing": "Run history no longer available.", + "noSearchMatches": "No automations match your search.", + "paused": "Paused", + "runCount": "{{count}} runs", + "runCount_one": "{{count}} run", + "runCount_other": "{{count}} runs", + "projectDefaultBaseRef": "project default", + "createFromBaseRef": "Create from {{baseRef}}", + "missingWorkspace": "Missing workspace", + "runUsageSummary": "{{cost}} est. · {{tokens}} tokens", + "usageUnavailable": "Usage unavailable", + "noRunUsageYet": "No run usage yet", + "noWorkspace": "No workspace", + "latestSavedOutput": "Latest saved output" }, "CreateFromPicker": { "f061f49e3f": "Search repo branches...", @@ -14022,6 +14092,13 @@ "5a7863909c": "Run setup for each new workspace", "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." + }, + "AutomationListSearchField": { + "tooLong": "Search text is too long — list is unfiltered", + "label": "Search automations", + "placeholder": "Search by name, project, or prompt", + "tooLongShort": "Too long", + "clear": "Clear search" } }, "agent": { @@ -14559,6 +14636,19 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "Failed to load the GitLab job log.", + "emptyTrace": "No log is available for this GitLab job.", + "timedOut": "Timed out loading the GitLab job log." + } + }, + "ssh": { + "sshConnectVerb": { + "reconnect": "Reconnect", + "retry": "Retry", + "connect": "Connect", + "connecting": "Connecting…" } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index b9d562579e3..dbe94a85ab4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4227,7 +4227,9 @@ "ee240a39eb": "Editar filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarHeader": { "92154beb7e": "Nuevo espacio de trabajo", @@ -4292,7 +4294,9 @@ "82594419ba": "Filtros", "automationCreated": "Ocultar creados por automatizaciones", "cliCreated": "Hide CLI-created", - "detachedHead": "Ocultar HEAD desacoplado" + "detachedHead": "Ocultar HEAD desacoplado", + "keepDefaultBranch": "Excepto la rama predeterminada", + "keepDefaultBranchAria": "Mantener visible la rama predeterminada al ocultar los espacios de trabajo en reposo" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Diseño de actividad del Agent", @@ -4352,19 +4356,6 @@ "folderPathIdentity": "Rama / ruta de carpeta", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "Conectando...", - "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": "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." - }, "SshTargetRow": { "4677394048": "Conectando…", "75ad429b5d": "Conectar" @@ -6479,7 +6470,10 @@ "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" + "868c5c9b56": "No se pudo importar el icono del repositorio", + "emojiTooLongForRepoIcon": "Este emoji no se puede usar como icono del repositorio.", + "currentEmojiSelection": "Actual: {{value0}}", + "searchEmojiPlaceholder": "Buscar emoji" }, "RepositoryPane": { "15a99d9b9f": "Las rutas relativas se resuelven desde la raíz de este proyecto.", @@ -9672,6 +9666,10 @@ "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." }, + "BrowserLinkRoutingSetting": { + "description": "Abre los enlaces http(s) del terminal, Markdown y el editor en el navegador integrado de Orca. {{shortcut}} siempre usa el navegador del sistema.", + "descriptionBase": "Abre los enlaces http(s) del terminal, Markdown y el editor en el navegador integrado de Orca." + }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", "titleOrca": "Hold Shift to open in Orca", @@ -10636,7 +10634,6 @@ "fd46a70f1a": "Iniciado", "a54ae21c6f": "Estado:", "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 check…", @@ -10689,7 +10686,8 @@ "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.", "b3195cba33": "Desmarcar autor como bot", - "f588b46a6c": "Marcar autor como bot" + "f588b46a6c": "Marcar autor como bot", + "checkDetailsLoadFailed": "No se pudieron cargar los detalles del check." }, "empty": { "state": { @@ -14348,6 +14346,19 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "No se pudo cargar el registro del job de GitLab.", + "emptyTrace": "No hay ningún registro disponible para este job de GitLab.", + "timedOut": "Se agotó el tiempo de espera al cargar el registro del job de GitLab." + } + }, + "ssh": { + "sshConnectVerb": { + "connect": "Conectar", + "connecting": "Conectando…", + "reconnect": "Reconectar", + "retry": "Reintentar" } } }, @@ -14601,25 +14612,25 @@ }, "browser": { "loadFailure": { - "connectionNotSecure": "Connection isn't secure", - "cantReachHost": "Can't reach {{value0}}", - "cantLoadPage": "Can't load this page", - "certificateNameMismatch": "The certificate doesn't match {{value0}}.", - "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", - "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", - "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", - "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", - "retry": "Retry", - "copyAddress": "Copy Address", - "addressCopied": "Copied the current page address.", - "openExternally": "Open Externally", - "tryHttps": "Try HTTPS", - "proceedUnsafe": "Proceed Anyway (Unsafe)", - "connecting": "Connecting…", - "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", - "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", - "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", - "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + "connectionNotSecure": "La conexión no es segura", + "cantReachHost": "No se puede acceder a {{value0}}", + "cantLoadPage": "No se puede cargar esta página", + "certificateNameMismatch": "El certificado no coincide con {{value0}}.", + "certificateDateInvalid": "El certificado de {{value0}} no es válido en la fecha y hora actuales.", + "certificateAuthorityInvalid": "Orca no confía en la autoridad que emitió el certificado de {{value0}}.", + "certificateVerificationFailed": "Orca no pudo verificar el certificado de {{value0}}.", + "trustedCertificateGuidance": "Para el desarrollo local, usa un certificado local de confianza siempre que sea posible.", + "retry": "Reintentar", + "copyAddress": "Copiar dirección", + "addressCopied": "Se copió la dirección de la página actual.", + "openExternally": "Abrir externamente", + "tryHttps": "Probar HTTPS", + "proceedUnsafe": "Continuar (no seguro)", + "connecting": "Conectando…", + "certificateChallengeExpired": "Esta aprobación de certificado caducó. Vuelve a intentarlo con la página para solicitar una nueva.", + "certificateChallengeChanged": "La solicitud de certificado cambió. Vuelve a intentarlo con la página y revisa la nueva advertencia.", + "certificateChallengeUnavailable": "Esta solicitud de certificado ya no está disponible. Vuelve a intentarlo con la página para solicitar una nueva.", + "certificateProceedFailed": "Orca no pudo aprobar esta solicitud de certificado. Vuelve a intentarlo con la página e inténtalo de nuevo." } }, "runtimeRpc": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index f45c99439bd..830e17809cd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4208,7 +4208,9 @@ "ee240a39eb": "フィルターの編集", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarHeader": { "92154beb7e": "新規ワークスペース", @@ -4221,7 +4223,7 @@ "SidebarNav": { "80611a8b10": "検索", "0c3395fd32": "ワークツリーとブラウザタブを検索する", - "c86d83b5c3": "新規", + "c86d83b5c3": "新機能", "1b5c41caee": "Orca モバイル", "9c95e1ce91": "エージェント", "f323383e9a": "自動化", @@ -4273,7 +4275,9 @@ "82594419ba": "フィルター", "automationCreated": "自動化で作成されたワークスペースを非表示", "cliCreated": "Hide CLI-created", - "detachedHead": "分離HEADを非表示" + "detachedHead": "分離HEADを非表示", + "keepDefaultBranch": "デフォルトのブランチを除く", + "keepDefaultBranchAria": "スリープ中のワークスペースを非表示にしてもデフォルトのブランチは表示したままにする" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent アクティビティのレイアウト", @@ -4333,19 +4337,6 @@ "folderPathIdentity": "ブランチ / フォルダパス", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "接続中...", - "89385db176": "閉じる", - "656368f3a2": "再接続に失敗しました", - "376bed88e5": "リモート ホストへの接続でエラーが発生しました。", - "4afcca1d24": "再接続", - "11552bf786": "SSH が切断されました", - "cb5938ae79": "再接続中...", - "disconnected": "This remote repository is not connected.", - "reconnecting": "リモートホストに再接続中...", - "reconnectionFailed": "リモートホストへの再接続に失敗しました。", - "authFailed": "リモートホストへの認証に失敗しました。" - }, "SshTargetRow": { "4677394048": "接続中…", "75ad429b5d": "接続" @@ -4755,7 +4746,7 @@ "ddf25b6262": "エメラルド", "7cebab6d4a": "アンバー", "1b81da243a": "バイオレット", - "6437a8c253": "空", + "6437a8c253": "空色", "fc3b92756c": "青", "52e3c6e2a4": "中性" } @@ -6501,7 +6492,10 @@ "f79972271a": "この repo の GitHub リモートが見つかりません。", "4d039317f4": "ウェブサイトのファビコン", "acf31559a0": "有効な Web サイトの URL を入力。", - "868c5c9b56": "repo のインポートに失敗しましたアイコン" + "868c5c9b56": "repo のインポートに失敗しましたアイコン", + "emojiTooLongForRepoIcon": "この絵文字は repo アイコンとして使用できません。", + "currentEmojiSelection": "現在: {{value0}}", + "searchEmojiPlaceholder": "絵文字を検索" }, "RepositoryPane": { "15a99d9b9f": "相対パスは、このプロジェクトのルートから解決されます。", @@ -9672,6 +9666,10 @@ "unpackedHelp": "開発ビルドはソースチェックアウトから更新する必要があります。", "legacyHelp": "リモート更新を有効にするには、このサーバーを手動で一度更新してください。" }, + "BrowserLinkRoutingSetting": { + "description": "ターミナル、Markdown、エディターの http(s) リンクを Orca の組み込みブラウザで開きます。{{shortcut}} は常にシステム ブラウザを使用します。", + "descriptionBase": "ターミナル、Markdown、エディターの http(s) リンクを Orca の組み込みブラウザで開きます。" + }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", "titleOrca": "Hold Shift to open in Orca", @@ -10636,7 +10634,6 @@ "fd46a70f1a": "開始しました", "a54ae21c6f": "状態:", "e4e3af15ee": "詳細を表示", - "2524d1fb83": "ログテールで詳細を確認できます。", "a2fb3f4408": "最初の 100 件のジョブを表示しています", "df137989b3": "最初の 20 個の注釈を表示", "1f2b980522": "チェックの詳細を読み込んでいます…", @@ -10689,7 +10686,8 @@ "5e6e5a13fa": "表示", "actionRequiredHint": "マージが解除される前に、GitHubで手動アクションが必要です(例:ワークフロー実行の承認)。", "b3195cba33": "作成者のボット指定を解除", - "f588b46a6c": "作成者をボットとして指定" + "f588b46a6c": "作成者をボットとして指定", + "checkDetailsLoadFailed": "チェック詳細の読み込みに失敗しました。" }, "empty": { "state": { @@ -10987,8 +10985,8 @@ "ed93b4f14f": "Commit", "946a8a05ea": "このブランチの {{value0}} を作成します", "e7ffa46946": "{{value0}} を作成する", - "95550cff15": "押す", - "d64292a938": "引く", + "95550cff15": "プッシュ", + "d64292a938": "プル", "795f1509c5": "同期", "390abeab93": "フォースプッシュ", "1884cf34af": "このブランチをオリジンに公開します", @@ -11014,19 +11012,19 @@ "8c6d15a07d": "PR を作成", "d37e68f61d": "レビュー用にブランチを準備中…", "c72e5e65d1": "このブランチを準備して {{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.", + "b8e4f2a901": "リモート操作が終了するまでお待ちください。", + "c9f3a1b802": "{{value0}} を作成する前に競合を解決してください。", + "d2a8c4e703": "このブランチに {{value0}} に含める変更はありません。", + "e3b9d5f814": "デフォルトブランチから {{value0}} を作成することはできません。", + "f4c0e6a925": "{{value0}} を作成する前に変更を commit してください。", + "a5d1f7b036": "{{value0}} を作成する前に commit を公開してください。", + "b6e2a8c147": "{{value0}} を作成する前に commit をプッシュしてください。", + "c7f3b9d258": "{{value0}} を作成する前にこのブランチを同期してください。", + "d8a4c0e369": "{{value0}} を作成する前に認証してください。", + "e9b5d1f470": "{{value0}} を作成する前にブランチをチェックアウトしてください。", + "f0c6e2a581": "このブランチはまだ {{value0}} の準備ができていません。", "8f9a0b1c2d": "コミットするものがありません。ブランチは最新です。", - "h3i4j5k607": "Checking whether this branch can create a {{value0}}…" + "h3i4j5k607": "このブランチが {{value0}} を作成できるか確認中…" } } } @@ -14348,6 +14346,19 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab ジョブのログを読み込めませんでした。", + "emptyTrace": "この GitLab ジョブのログはありません。", + "timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。" + } + }, + "ssh": { + "sshConnectVerb": { + "connect": "接続", + "connecting": "接続中…", + "reconnect": "再接続", + "retry": "再試行" } } }, @@ -14601,25 +14612,25 @@ }, "browser": { "loadFailure": { - "connectionNotSecure": "Connection isn't secure", - "cantReachHost": "Can't reach {{value0}}", - "cantLoadPage": "Can't load this page", - "certificateNameMismatch": "The certificate doesn't match {{value0}}.", - "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", - "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", - "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", - "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", - "retry": "Retry", - "copyAddress": "Copy Address", - "addressCopied": "Copied the current page address.", - "openExternally": "Open Externally", - "tryHttps": "Try HTTPS", - "proceedUnsafe": "Proceed Anyway (Unsafe)", - "connecting": "Connecting…", - "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", - "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", - "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", - "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + "connectionNotSecure": "接続は安全ではありません", + "cantReachHost": "{{value0}} に接続できません", + "cantLoadPage": "このページを読み込めません", + "certificateNameMismatch": "証明書が {{value0}} と一致しません。", + "certificateDateInvalid": "{{value0}} の証明書は現在の日時では有効ではありません。", + "certificateAuthorityInvalid": "Orca は {{value0}} の証明書を発行した認証局を信頼していません。", + "certificateVerificationFailed": "Orca は {{value0}} の証明書を検証できませんでした。", + "trustedCertificateGuidance": "ローカル開発では、可能な限り信頼されたローカル証明書を使用してください。", + "retry": "再試行", + "copyAddress": "アドレスをコピー", + "addressCopied": "現在のページのアドレスをコピーしました。", + "openExternally": "外部で開く", + "tryHttps": "HTTPS を試す", + "proceedUnsafe": "続行(安全ではない)", + "connecting": "接続中…", + "certificateChallengeExpired": "この証明書の承認は期限切れです。ページを再試行して、承認をもう一度リクエストしてください。", + "certificateChallengeChanged": "証明書のリクエストが変更されました。ページを再試行して、警告の内容を改めて確認してください。", + "certificateChallengeUnavailable": "この証明書のリクエストは利用できなくなりました。ページを再試行して、もう一度リクエストしてください。", + "certificateProceedFailed": "Orca はこの証明書のリクエストを承認できませんでした。ページを再試行して、もう一度お試しください。" } }, "runtimeRpc": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 1241301c0a8..4d1ed1e286e 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1000,7 +1000,7 @@ "ba8e329d92": "본 것으로 표시 해제", "3f79ffc8b7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "5c1c973855": "리뷰어 삭제", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.", + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다.", "timeline": { "completed": "를 완료로", "notPlanned": "미계획으로", @@ -1082,7 +1082,7 @@ "3a051b8ade": "작업 항목", "32f8bef818": "로그 출력이 없습니다.", "4168eb2c51": "해결", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "JiraIssueWorkspace": { "b0b92666c9": "댓글", @@ -1117,7 +1117,7 @@ "2a829a2f00": "우선순위", "693be070d0": "전환", "ef21405c6d": "Jira 이슈", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "Landing": { "76a95f7f47": "생성", @@ -1240,7 +1240,7 @@ "39883467f4": "Linear 이슈", "fda549766e": "댓글을 달려면 {{value0}}", "d71cd3003e": "+ 담당자", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "NewWorkspaceComposerCard": { "cbb47ee0dc": "로컬 Git 프로젝트에만 사용할 수 있습니다.", @@ -1488,7 +1488,7 @@ "2b4fdb880c": "본 표시 해제", "56ec6eafb7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "7f964a365a": "리뷰어 삭제", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.", + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다.", "8ff5ae8866": "담당자", "82c87eceb9": "담당자 편집", "1ff5d979df": "할당된 사람이 없음", @@ -2139,7 +2139,7 @@ "463d030ae4": "삭제", "8564f58542": "편집", "5f104bf855": "아직 댓글이 없습니다.", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "LabelsEditor": { "34dd57d6c8": "로드 중…", @@ -2259,7 +2259,7 @@ "b1c2d3e4f5": "이슈 다시 열기", "0a73f59e85": "댓글 보내기", "bf43425540": "댓글", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "GitHubWorkItemAssigneePopoverContent": { "cddd9b04a7": "담당자 로드 중", @@ -2327,7 +2327,7 @@ "9ddb58edbd": "상태", "0a6a5a7dd6": "최신 업데이트", "c8db98b73b": "리소스", - "bb1405eff8": "이정표", + "bb1405eff8": "마일스톤", "5d99315fb8": "계획", "3ad562bdf4": "범위 내 이슈", "563501f191": "진행률", @@ -2408,7 +2408,7 @@ "WorkspaceSpacePage": { "8d0048e1cb": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간.", "e8d6ba11ab": "베타", - "45f6302dbc": "공간", + "45f6302dbc": "저장 공간", "ecf72fdc3b": "뒤로" } }, @@ -3235,7 +3235,7 @@ "f4d2651498": "스캔됨", "6a5dc3c61a": "리뷰", "c361440dc0": "베타", - "8ff597593d": "공간", + "8ff597593d": "저장 공간", "0582df6d2e": "스캔", "f5e1a84d79": "새로고침", "2af2174d6d": "취소", @@ -3750,13 +3750,13 @@ "8b12c0850a": "저장", "de8fce5854": "취소", "ddbcaef7be": "src/renderer packages/ui", - "0e9ad9c798": "디렉토리", + "0e9ad9c798": "디렉터리", "064c1e2d12": "렌더러 UI", "b3a500c623": "이름", "16223dde6a": "사전 설정 로드", "a683a4bc8e": "사전 설정 로드를 다시 시도하세요.", - "14952d451e": "{{value0}} 디렉토리", - "e9283eb171": "디렉토리 1개", + "14952d451e": "{{value0}} 디렉터리", + "e9283eb171": "디렉터리 1개", "69c020eddc": "사전 설정 편집", "bd6cec2056": "새 사전 설정" } @@ -4054,7 +4054,7 @@ "29aefb7e52": "다시 묻지 마세요" }, "DeleteWorktreeWarningPanels": { - "026738155a": "(원본 복제 디렉토리).", + "026738155a": "(원본 복제 디렉터리).", "c4f96a6e18": "메인 워크트리", "e3be9eba15": "이것은" }, @@ -4121,7 +4121,7 @@ "2300612806": "필터링하려면 입력하거나 경로를 입력하세요…", "9e060f5815": "폴더 선택", "f8b1deb1a4": "취소", - "51001182e3": "빈 디렉토리", + "51001182e3": "빈 디렉터리", "971d85cc84": "이 호스트에서 프로젝트로 열립니다 · {{value0}}", "00c4235c10": "'{{value0}}'과(와) 일치하는 항목이 없습니다.", "largeInputNoMatches": "이 긴 입력과 일치하는 항목 없음" @@ -4208,7 +4208,9 @@ "ee240a39eb": "필터 편집", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarHeader": { "92154beb7e": "새로운 워크스페이스", @@ -4221,7 +4223,7 @@ "SidebarNav": { "80611a8b10": "검색", "0c3395fd32": "작업 트리 및 브라우저 탭 검색", - "c86d83b5c3": "새로 만들기", + "c86d83b5c3": "신규", "1b5c41caee": "Orca 모바일", "9c95e1ce91": "에이전트", "f323383e9a": "자동화", @@ -4273,7 +4275,9 @@ "82594419ba": "필터", "automationCreated": "자동화로 생성된 워크스페이스 숨기기", "cliCreated": "Hide CLI-created", - "detachedHead": "분리된 HEAD 숨기기" + "detachedHead": "분리된 HEAD 숨기기", + "keepDefaultBranch": "기본 브랜치는 제외", + "keepDefaultBranchAria": "슬립 중인 워크스페이스를 숨겨도 기본 브랜치는 계속 표시" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "Agent 활동 레이아웃", @@ -4333,19 +4337,6 @@ "folderPathIdentity": "브랜치 / 폴더 경로", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "연결 중...", - "89385db176": "닫기", - "656368f3a2": "재연결 실패", - "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.", - "4afcca1d24": "다시 연결", - "11552bf786": "SSH 연결 끊김", - "cb5938ae79": "다시 연결하는 중...", - "disconnected": "이 SSH 호스트는 연결되어 있지 않습니다.", - "reconnecting": "원격 호스트에 다시 연결하는 중...", - "reconnectionFailed": "원격 호스트에 다시 연결하지 못했습니다.", - "authFailed": "원격 호스트 인증에 실패했습니다." - }, "SshTargetRow": { "4677394048": "연결 중…", "75ad429b5d": "연결" @@ -4654,7 +4645,7 @@ "1d0fa5c0a5": "워크스페이스 {{value0}}을(를) 삭제하지 못했습니다.", "ead7b8ee15": "파일이 변경되었습니다. 어쨌든 삭제하려면 강제 삭제를 사용하세요.", "905fc8efac": "Git은 이미 이 워크스페이스를 제거했습니다. 강제 삭제를 사용하여 Orca에서 삭제합니다.", - "0899ebdb28": "Git은 이미 이 워크스페이스를 잊어버렸지만 해당 디렉토리는 여전히 디스크에 있습니다. 강제 삭제를 사용하여 분리된 디렉터리를 제거합니다.", + "0899ebdb28": "Git은 이미 이 워크스페이스를 잊어버렸지만 해당 디렉터리는 여전히 디스크에 있습니다. 강제 삭제를 사용하여 분리된 디렉터리를 제거합니다.", "locked": "이 워크트리는 Git에 의해 잠겨 있습니다. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요.", "lockedReason": "이 워크트리는 Git에 의해 잠겨 있습니다. Git 보고: {{value0}}. 저장소에서 git worktree unlock 을 실행한 후 삭제를 다시 시도하세요." } @@ -5292,7 +5283,7 @@ "1c9a9679ec": "{{value0}} 사용 가능 여부", "0d9e293a02": "새로고침", "c9b33eb5c0": "새로고침 중…", - "13647f9f80": "쉘 PATH를 다시 ​​읽고 설치된 agents를 다시 검색하십시오.", + "13647f9f80": "셸 PATH를 다시 ​​읽고 설치된 agents를 다시 검색하십시오.", "dc4a2ffdc0": "확장 명령 재정의", "cea7d97be1": "축소 명령 재정의", "db9e9e5887": "명령 사용자 정의", @@ -5400,7 +5391,7 @@ "9241b59bf5": "사용", "a869d0edd8": "브랜치 이름 명령 템플릿", "e784ea62dc": "고급", - "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca 라는 이름의 브랜치만 이름이 변경되며 푸시된 후에는 이름이 변경되지 않습니다.", + "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다.", "12ea4a408d": "agent가 새 워크스페이스에서 작업을 시작하면 Orca는 자동 생성된 브랜치의 이름을 바꿉니다(예:", "ef787db0e3": "브랜치 자동 이름 바꾸기", "6a051586d2": "agent가 시작되면 작업을 기반으로 자동 생성된 브랜치의 이름을 바꿉니다.", @@ -5557,7 +5548,7 @@ "15eaad0d31": "명령 경로:", "5dae812f50": "새로고침", "52e640f3a0": "CLI 상태 새로 고침", - "38edbb5721": "쉘 명령", + "38edbb5721": "셸 명령", "6930feda9e": "terminals에서 Orca를 사용하여 앱을 열고, 작업 트리를 관리하고, Orca terminals과 상호 작용할 수 있습니다.", "c5c0f2641d": "Orca CLI", "d77352f2df": "PATH에서 `{{value0}}`을(를) 제거하지 못했습니다.", @@ -5589,7 +5580,7 @@ "0c9f3cf9da": "Orca가 글로벌 agent 스킬을 확인하고 설치하는 위치를 선택합니다.", "f00d6aa9b5": "이 컴퓨터에서는 WSL을 사용할 수 없습니다.", "7c776ff9d8": "wsl", - "fc0fcf72fd": "스킬 설정 전 WSL 쉘 명령어를 등록하세요.", + "fc0fcf72fd": "스킬 설정 전 WSL 셸 명령어를 등록하세요.", "windowsPathUnknown": "WSL shell command PATH could not be checked" }, "CommitMessageAiPane": { @@ -5659,11 +5650,11 @@ "DeveloperPermissionsPane": { "4c17304beb": "새로고침", "6326a4c5cc": "CLI, 로컬 앱 또는 자동화 도구에 macOS 개인 정보 보호 액세스가 필요한 경우 이러한 컨트롤을 사용하십시오. Orca는 시작 시 묻지 않습니다.", - "6f011b9bf6": "Terminal 도구는 Orca의 macOS 개인 정보 보호 봉투를 상속합니다.", + "6f011b9bf6": "Terminal 도구는 Orca의 macOS 개인정보 보호 환경 안에서 실행됩니다.", "bfa3402305": "권한을 요청할 수 없습니다.", "66e94d6cf3": "권한 요청이 전송되었습니다.", "fa809e8ada": "macOS 개인정보 보호 및 보안 오픈", - "48d87edcd2": "허가가 부여되었습니다", + "48d87edcd2": "권한이 허용되었습니다", "a552887288": "개발자 권한을 로드할 수 없습니다.", "4cfaa7e98a": "블루투스 장치 도구 및 로컬 하드웨어 실험.", "b2210b1b4f": "블루투스", @@ -5741,9 +5732,9 @@ "9fb225f2d7": "플로팅 버튼", "3c900e26e5": "키보드 단축키는 토글이 표시되는 위치에 관계없이 작동합니다.", "5e5a8da236": "토글 버튼 위치", - "505001823e": "플로팅 워크스페이스 디렉토리 선택", + "505001823e": "플로팅 워크스페이스 디렉터리 선택", "81afb79785": "새로운 플로팅 terminal 탭이 여기에서 시작됩니다. Markdown 메모는 Orca의 앱 소유 플로팅 워크스페이스에 저장됩니다.", - "12aa09f10c": "Terminal 디렉토리", + "12aa09f10c": "Terminal 디렉터리", "41eb95f7f0": "플로팅 워크스페이스 버튼과 패널을 표시합니다.", "5136813663": "플로팅 워크스페이스 활성화", "37df688d6f": "플로팅 워크스페이스를 활성화하고 새 탭이 시작되는 위치를 선택하세요.", @@ -5869,7 +5860,7 @@ "ba3480642f": "워크스페이스 중첩", "a246f5ce6f": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", "5567191a6e": "찾아보기", - "0e9fc0eadc": "워크스페이스 디렉토리", + "0e9fc0eadc": "워크스페이스 디렉터리", "e2955d9ccb": "새 워크스페이스가 생성되는 위치를 구성합니다.", "7511097c5d": "워크스페이스" }, @@ -6381,7 +6372,7 @@ "8567127a40": "작업 트리가 생성되거나 보관될 때 실행되는 스크립트입니다. 로컬 스크립트는 이 시스템에 저장됩니다. `orca.yaml` 스크립트는 팀과 공유됩니다.", "ff082fe7c6": "작업트리 후크", "5d940bde5c": "로컬 스크립트 추가", - "8c2893fae0": "단일 쉘 스크립트로 실행됩니다. 이 컴퓨터에 저장되었습니다.", + "8c2893fae0": "단일 셸 스크립트로 실행됩니다. 이 컴퓨터에 저장되었습니다.", "40a446ae16": "- 이 기기에서 나만 사용", "2d03a514db": "로컬", "7e4427b4a2": "변경합니다.", @@ -6464,7 +6455,10 @@ "f79972271a": "이 repo에 대한 GitHub 원격을 찾을 수 없습니다.", "4d039317f4": "웹사이트 파비콘", "acf31559a0": "유효한 웹사이트 URL을 입력하세요.", - "868c5c9b56": "repo 아이콘을 가져오지 못했습니다." + "868c5c9b56": "repo 아이콘을 가져오지 못했습니다.", + "emojiTooLongForRepoIcon": "이 이모티콘은 repo 아이콘으로 사용할 수 없습니다.", + "currentEmojiSelection": "현재: {{value0}}", + "searchEmojiPlaceholder": "이모티콘 검색" }, "RepositoryPane": { "15a99d9b9f": "상대 경로는 이 프로젝트 루트에서 확인됩니다.", @@ -6923,7 +6917,7 @@ "2d7d45e991": "취소", "c240a16f25": "packages/web 또는 apps/api와 같은 repo 상대 경로를 사용하세요.", "fde7ff2cc3": "packages/web shared/ui", - "caf33029cc": "디렉토리", + "caf33029cc": "디렉터리", "3b6f1abd3e": "예를 들어 웹 전용", "a6fcdd9e3c": "이름", "b9922ec194": "사전 설정 편집 취소", @@ -6933,12 +6927,12 @@ "a7bcf206b1": "삭제 중", "ba9ad2d4cd": "업데이트 날짜 알 수 없음", "568d7e1e49": "{{value0}} 업데이트됨", - "d7b3f0bdc3": "{{value0}} 디렉토리", - "9d3c087fc0": "디렉토리 1개", + "d7b3f0bdc3": "{{value0}} 디렉터리", + "9d3c087fc0": "디렉터리 1개", "8deb7024ab": "스파스 사전 설정 로드 중...", "92c08ccae3": "스파스 사전 설정을 로드할 수 없습니다.", - "3dfa765ca7": "{{value0}} 디렉토리가 저장됩니다.", - "b532b9c17d": "1개의 디렉토리가 저장됩니다.", + "3dfa765ca7": "{{value0}} 디렉터리가 저장됩니다.", + "b532b9c17d": "1개의 디렉터리가 저장됩니다.", "623b4cf910": "사전 설정 편집", "68bbcd864a": "새로운", "2ef2b2674b": "{{value0}} 삭제" @@ -7200,7 +7194,7 @@ "f61ac77f16": "Git Bash", "0f1b8669e6": "명령 프롬프트", "eb7fc4d98a": "PowerShell", - "27e301f22c": "기본 쉘", + "27e301f22c": "기본 셸", "09bf02de9a": "새 terminals 패널을 열 때 사용할 셸입니다. 새 terminals에 적용됩니다.", "bd68f3170d": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.", "a55eee649f": "Windows의 새 terminal 패널에 대한 기본 셸입니다.", @@ -7322,7 +7316,7 @@ "7f4063076c": "커서 아래의 텍스트 색상(블록 커서)", "a2d9f095a7": "Cursor 텍스트", "cd0700762b": "Cursor 색상", - "c9e1fdf42f": "Cursor", + "c9e1fdf42f": "커서", "da64e8f4c1": "Terminal 배경색", "cc1b2ffeb2": "배경", "026a0b8013": "본문 색상", @@ -7995,7 +7989,7 @@ "search": { "94f4d013c8": "상태 표시줄", "a452146574": "토글 버튼", - "6765b85e48": "실행 디렉토리", + "6765b85e48": "실행 디렉터리", "a38bfc3f77": "퀵 패널", "52db6e3baf": "메모", "156ffeee08": "메모", @@ -8146,7 +8140,7 @@ "7887a2c262": "폴더", "7baf524b04": "워크스페이스", "d0bc793689": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", - "4c95d08fa2": "워크스페이스 디렉토리", + "4c95d08fa2": "워크스페이스 디렉터리", "161a86a9da": "고정된 탭을 닫기 전에 확인", "8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.", "defaultProjectRuntime": "기본 프로젝트 런타임", @@ -8606,7 +8600,7 @@ "c86478c3d8": "Orca에서 이 프로젝트를 제거하세요.", "c5266c2c9d": "프로젝트 제거", "4b9a18a56d": "모노 repo", - "4e2529722c": "디렉토리", + "4e2529722c": "디렉터리", "1ff4f12c0c": "디렉터리", "9f5ae26ccd": "사전 설정", "095fca94fe": "사전 설정", @@ -8987,7 +8981,7 @@ "6cd20b9e64": "cmd", "7c7056940a": "셸", "713c4a2f92": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.", - "13715f9d23": "기본 쉘" + "13715f9d23": "기본 셸" } } }, @@ -9025,7 +9019,7 @@ "action": { "recipe": { "options": { - "commitMessage": "단계적 변경에서 commit 메시지를 생성합니다.", + "commitMessage": "스테이징된 변경 사항에서 commit 메시지를 생성합니다.", "pullRequest": "호스팅된 리뷰 제목 및 설명을 생성합니다.", "branchName": "초기 agent 작업에서 Orca가 생성한 브랜치의 이름을 바꿉니다.", "fixCommitFailure": "commit 후크 또는 git commit이 실패하면 agent를 시작합니다.", @@ -9414,7 +9408,7 @@ "notificationPlaygroundDescription": "토스트 레이아웃, 복구 액션, 긴 텍스트 줄바꿈을 확인하기 위한 개발 전용 트리거입니다.", "devOnly": "Dev only", "orcaCloud": "Orca Cloud", - "orcaCloudDescription": "1方클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다.", + "orcaCloudDescription": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다.", "orcaCloudStatus": "상태", "orcaCloudSignOut": "로그아웃", "orcaCloudConnect": "프로필 연결", @@ -9464,14 +9458,14 @@ "loadError": "레시피를 로드할 수 없습니다.", "copyError": "프롬프트를 복사할 수 없습니다.", "skillDescription": "repo 소유 환경 레시피를 설정, 빌드, 인증 및 검증합니다.", - "whatTitle": "당신과 함께 기술이 하는 일", + "whatTitle": "이 스킬로 함께 하는 작업", "whatScaffold": "Orca 서버 또는 SSH를 통해 연결된 공급자를 위한 레시피 및 스크립트를 작성합니다.", "whatBuild": "재사용 가능한 기본 이미지를 구축하고 agent에 로그인합니다.", "whatValidate": "워크스페이스를 생성할 수 있도록 유효성을 검사합니다.", "promptHint": "어떤 워크스페이스에서든 agent에게 다음과 같이 질문하세요.", "copy": "복사", "copied": "복사됨", - "recipes": "조리법", + "recipes": "레시피", "recipesHelp": "orca.yaml과 활성화된 플러그인의 레시피가 여기에 표시되어 워크스페이스를 시작할 수 있습니다.", "checking": "레시피 확인 중...", "none": "아직 레시피를 찾을 수 없습니다.", @@ -9672,6 +9666,10 @@ "unpackedHelp": "개발 빌드는 소스 체크아웃에서 업데이트해야 합니다.", "legacyHelp": "원격 업데이트를 활성화하려면 이 서버를 수동으로 한 번 업데이트하세요." }, + "BrowserLinkRoutingSetting": { + "description": "터미널, Markdown, 편집기의 http(s) 링크를 Orca 내장 브라우저에서 엽니다. {{shortcut}}을(를) 누르면 항상 시스템 브라우저를 사용합니다.", + "descriptionBase": "터미널, Markdown, 편집기의 http(s) 링크를 Orca 내장 브라우저에서 엽니다." + }, "BrowserLinkRoutingModifierSetting": { "titleSystem": "Hold Shift to open in your web browser", "titleOrca": "Hold Shift to open in Orca", @@ -10395,7 +10393,7 @@ "c07b236287": "변경사항을 보려면 워크스페이스를 선택하세요.", "dc5a6465fc": "{{value0}}(예: {{value1}}{{value2}})", "8eb3782a0c": "{{value0}} 파일{{value1}}을(를) 삭제하지 못했습니다.", - "a5e5a11090": "모두 삭제 실패 - 삭제하기 전에 파일을 스테이지 해제할 수 없습니다.", + "a5e5a11090": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다.", "8a5ba6a988": "commit 차이점을 로드하지 못했습니다.", "fe5bd1a610": "{{value0}} 생성 중...", "812cb992ee": "{{value0}}에서 열기", @@ -10413,7 +10411,7 @@ "d62bc0c7d8": "추적되지 않은", "ab31221779": "언스테이지 폴더", "bfe9011a0e": "스테이지 폴더", - "6d7f2a47e5": "폴더 삭제", + "6d7f2a47e5": "폴더의 변경 사항 취소", "9b367363b6": "추적되지 않은 폴더 삭제", "540ca8f78c": "병합 중단", "425f138269": "리베이스 중단", @@ -10636,7 +10634,6 @@ "fd46a70f1a": "시작됨", "a54ae21c6f": "상태:", "e4e3af15ee": "전체 세부정보 보기", - "2524d1fb83": "자세한 내용을 로그 테일에서 확인할 수 있습니다.", "a2fb3f4408": "처음 100개 작업 표시", "df137989b3": "처음 20개의 주석 표시 중", "1f2b980522": "검사 세부 정보 로드 중…", @@ -10689,7 +10686,8 @@ "5e6e5a13fa": "보기", "actionRequiredHint": "이 검사는 병합 차단이 해제되기 전에 GitHub에서 수동 작업(예: 워크플로 실행 승인)이 필요합니다.", "b3195cba33": "작성자의 봇 지정 해제", - "f588b46a6c": "작성자를 봇으로 지정" + "f588b46a6c": "작성자를 봇으로 지정", + "checkDetailsLoadFailed": "체크 세부 정보를 불러오지 못했습니다." }, "empty": { "state": { @@ -10898,7 +10896,7 @@ "542bf6a7e2": "기울임", "256300f8ea": "굵게", "87aff03d63": "전송 중...", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." } } } @@ -10956,7 +10954,7 @@ "5ddd8cac7f": "스테이징된 변경 사항을 모두 삭제하시겠습니까?", "1426c2efff": "그러면 이 파일의 모든 변경 사항이 되돌려집니다. 이 작업은 취소할 수 없습니다.", "d4df3a61df": "'{{value0}}' 변경사항을 취소하시겠습니까?", - "40e9357b2a": "이렇게 하면 HEAD에서 파일이 복원되고 삭제 내용이 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "40e9357b2a": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다.", "5c0bdbc4cb": "'{{value0}}'을(를) 복원하시겠습니까?", "d97bf697c9": "이 파일이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", "96c772bee9": "'{{value0}}'을 삭제하시겠습니까?" @@ -10997,7 +10995,7 @@ "41d4bcf157": "PR 상태 확인 중…", "acce237921": "commit 할 것이 없습니다. 브랜치에 게시할 변경 사항이 없습니다.", "fa3bd4f40c": "commit 할 파일을 하나 이상 스테이지하세요", - "5a477d80cb": "모든 변경 사항을 준비합니다.", + "5a477d80cb": "모든 변경 사항 스테이징", "18a0fca877": "모두 스테이지", "f01f16d77f": "commit 하려면 commit 메시지를 입력하세요.", "ab41fb926b": "스테이지된 변경 사항 Commit", @@ -11278,7 +11276,7 @@ "fastForwardOnly": "fast-forward만 허용", "fastForwardOnlyDescription": "병합이나 리베이스가 필요 없을 때만 pull합니다.", "title": "Pull 정책 필요", - "diverged": "Diverged", + "diverged": "분기됨", "body": "이 브랜치에는 로컬 commits과 원격 commits이 모두 있습니다. 이 워크트리 또는 SSH 호스트에서 명령 하나를 실행한 다음 풀 또는 동기화를 다시 시도하세요.", "copyAria": "{{value0}} pull 정책 명령 복사", "copied": "복사됨", @@ -11546,7 +11544,7 @@ "86c0f1caa2": "패딩", "06a24f4f2d": "색상", "c021e9dddd": "테마 팔레트", - "ab2a583a97": "Cursor", + "ab2a583a97": "커서", "cc1858e19e": "글꼴", "248c812283": "가져오기", "7ee9234e54": "Ghostty 설정을 찾았습니다.", @@ -13198,7 +13196,7 @@ "2b3ce6d394": "취소", "e05063cfc1": "라인 {{value0}}", "c845170b3b": "라인 {{value0}}-{{value1}}", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "useDiffCommentDecorator": { "995fa28b50": "이 메모" @@ -13283,7 +13281,8 @@ "4a9568f773": "뒤로", "4f86e2a10b": "둘러보기 건너뛰기", "d974f32a83": "투어 닫기", - "ffa4412b66": "다음" + "ffa4412b66": "다음", + "complete": "완료" }, "ContextualTourProgressDots": { "7734cb8ad3": "중", @@ -13293,7 +13292,17 @@ "tour": { "overlay": { "measurement": { - "38b3155418": "다음" + "38b3155418": "다음", + "automations": { + "intro": { + "title": "자동화란 무엇인가요?", + "body": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "results": { + "title": "결과 확인", + "body": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + } + } } } } @@ -14348,6 +14357,19 @@ }, "webRuntimeSession": { "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + }, + "gitlabJobTraceClient": { + "loadFailed": "GitLab 작업 로그를 불러오지 못했습니다.", + "emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.", + "timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다." + } + }, + "ssh": { + "sshConnectVerb": { + "connect": "연결", + "connecting": "연결 중…", + "reconnect": "다시 연결", + "retry": "재시도" } } }, @@ -14601,25 +14623,25 @@ }, "browser": { "loadFailure": { - "connectionNotSecure": "Connection isn't secure", - "cantReachHost": "Can't reach {{value0}}", - "cantLoadPage": "Can't load this page", - "certificateNameMismatch": "The certificate doesn't match {{value0}}.", - "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", - "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", - "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", - "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", - "retry": "Retry", - "copyAddress": "Copy Address", - "addressCopied": "Copied the current page address.", - "openExternally": "Open Externally", - "tryHttps": "Try HTTPS", - "proceedUnsafe": "Proceed Anyway (Unsafe)", - "connecting": "Connecting…", - "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", - "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", - "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", - "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + "connectionNotSecure": "연결이 안전하지 않습니다", + "cantReachHost": "{{value0}}에 연결할 수 없습니다", + "cantLoadPage": "이 페이지를 불러올 수 없습니다", + "certificateNameMismatch": "인증서가 {{value0}}과(와) 일치하지 않습니다.", + "certificateDateInvalid": "{{value0}}의 인증서가 현재 날짜와 시간에는 유효하지 않습니다.", + "certificateAuthorityInvalid": "Orca는 {{value0}}의 인증서를 발급한 인증 기관을 신뢰하지 않습니다.", + "certificateVerificationFailed": "Orca가 {{value0}}의 인증서를 확인하지 못했습니다.", + "trustedCertificateGuidance": "로컬 개발에서는 가능하면 신뢰할 수 있는 로컬 인증서를 사용하세요.", + "retry": "다시 시도", + "copyAddress": "주소 복사", + "addressCopied": "현재 페이지 주소를 복사했습니다.", + "openExternally": "외부에서 열기", + "tryHttps": "HTTPS로 시도", + "proceedUnsafe": "계속(안전하지 않음)", + "connecting": "연결 중…", + "certificateChallengeExpired": "이 인증서 승인이 만료되었습니다. 페이지를 다시 시도해 새로 요청하세요.", + "certificateChallengeChanged": "인증서 요청이 변경되었습니다. 페이지를 다시 시도하고 새 경고를 확인하세요.", + "certificateChallengeUnavailable": "이 인증서 요청은 더 이상 사용할 수 없습니다. 페이지를 다시 시도해 새로 요청하세요.", + "certificateProceedFailed": "Orca가 이 인증서 요청을 승인하지 못했습니다. 페이지를 다시 시도한 후 다시 요청하세요." } }, "runtimeRpc": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 04117976acc..a76a2b01b06 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -187,7 +187,7 @@ "browser": { "d175274b6d": "新浏览器选项卡", "08fc23631d": "浏览器", - "remoteCookieImportUnavailable": "Manual cookie file import is unavailable while a remote runtime is active." + "remoteCookieImportUnavailable": "远程运行时激活期间无法手动导入 Cookie 文件。" }, "editor": { "dcb521ed29": "该文件处于冲突状态,但没有可编辑的工作树文件。", @@ -349,7 +349,7 @@ "8d6eedf97e": "无法在 PATH 中注册 Orca CLI。", "0f116999f1": "在安装之前重新启动 shell 或将 Orca CLI 目录添加到 PATH。", "15cbedc3e3": "在运行智能体技能设置之前安装 Orca CLI。", - "windowsPathUnknown": "Orca could not check your Windows user PATH" + "windowsPathUnknown": "Orca 无法检查您的 Windows 用户 PATH" } } } @@ -613,21 +613,21 @@ "linear": { "usage": { "examples": { - "readTicket": "Read the linked ticket", - "postUpdate": "Post a progress update", - "moveState": "Move the ticket forward", + "readTicket": "阅读关联的议题", + "postUpdate": "发布进度更新", + "moveState": "推进议题", "attachPr": "附加评审链接", - "triageFollowups": "Triage and create follow-ups", - "readTicketSummary": "Pull the linked Linear issue's full context before starting work.", - "readTicketPrompt": "Use {{value0}} to read the linked Linear issue for this worktree, then summarize the goal and acceptance criteria before you start.", - "postUpdateSummary": "Comment progress or a completion summary back to the Linear issue.", - "postUpdatePrompt": "Use {{value0}} to post a completion update on the linked Linear issue with what changed and how it was verified.", - "moveStateSummary": "Advance the Linear workflow state as the work progresses.", - "moveStatePrompt": "Use {{value0}} to move the linked Linear issue to In Review now that the change is ready.", + "triageFollowups": "分类处理并创建跟进项", + "readTicketSummary": "开始工作前,获取关联 Linear 议题的完整上下文。", + "readTicketPrompt": "使用 {{value0}} 读取此工作树关联的 Linear 议题,然后在开始前总结目标和验收标准。", + "postUpdateSummary": "将进度或完成情况总结评论回 Linear 议题。", + "postUpdatePrompt": "使用 {{value0}} 在关联的 Linear 议题上发布完成更新,说明变更内容及验证方式。", + "moveStateSummary": "随着工作进展推进 Linear 工作流状态。", + "moveStatePrompt": "变更已就绪后,使用 {{value0}} 将关联的 Linear 议题移至 In Review。", "attachPrSummary": "创建拉取或合并请求时,将其关联到 Linear 议题。", "attachPrPrompt": "使用 {{value0}} 将此拉取或合并请求附加到关联的 Linear 议题。", - "triageFollowupsSummary": "Set assignee, priority, or estimate, and file parented follow-up tickets.", - "triageFollowupsPrompt": "Use {{value0}} to triage the linked Linear issue — set priority and estimate — and create a parented follow-up ticket for the deferred cleanup." + "triageFollowupsSummary": "设置负责人、优先级或预估工时,并创建带父级关联的后续议题。", + "triageFollowupsPrompt": "使用 {{value0}} 对关联的 Linear 议题进行分类处理 — 设置优先级和预估工时 — 并为延后清理创建带父级关联的后续议题。" } } }, @@ -643,8 +643,8 @@ "cookie": { "import": { "toast": { - "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", - "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again." + "restartFallbackUnavailableNone": "{{value0}} 个 Cookie 均无法加载,且重启回退不可用。此配置文件的旧 Cookie 已被替换。请重试导入。", + "restartFallbackUnavailablePartial": "已导入 {{value1}} 个 Cookie 中的 {{value0}} 个。其余无法加载,且重启回退不可用。请重试导入。" } } } @@ -655,14 +655,14 @@ "feedback": { "image": { "attachments": { - "fallbackName": "Image attachment", - "unsupportedType": "{{fileName}} is not a supported image type.", - "empty": "{{fileName}} is empty.", - "tooLarge": "{{fileName}} is larger than {{maxSize}}.", - "tooMany": "You can attach up to {{maxCount}} images.", - "dimensionsTooLarge": "{{fileName}} has dimensions that are too large to preview safely.", - "invalidImage": "{{fileName}} is not a valid supported image.", - "additionalErrors": "{{count}} additional images could not be attached." + "fallbackName": "图片附件", + "unsupportedType": "{{fileName}} 不是受支持的图片类型。", + "empty": "{{fileName}} 为空。", + "tooLarge": "{{fileName}} 大于 {{maxSize}}。", + "tooMany": "最多可附加 {{maxCount}} 张图片。", + "dimensionsTooLarge": "{{fileName}} 的尺寸过大,无法安全预览。", + "invalidImage": "{{fileName}} 不是有效的受支持图片。", + "additionalErrors": "还有 {{count}} 张图片无法附加。" } } } @@ -694,7 +694,7 @@ "nativeDropTooManyPaths": "拖放包含过多文件。", "nativeDropPathsTooLargeDescription": "请减少文件数量或使用更短的路径列表。", "nativeDropPathsTooLarge": "拖放的路径列表过大。", - "ownerChanged": "Couldn't verify which host owns this workspace. Try again after it reconnects." + "ownerChanged": "无法确认哪个主机拥有此工作区。请在它重新连接后重试。" }, "useIpcEvents": { "0e3cf53060": "未找到浏览器选项卡 {{value0}}", @@ -713,10 +713,10 @@ "2ec42e1c52": "还没有远程工作区", "88214a785b": "工作区同步等待本地会话恢复(hydration)并超时", "4f78ba5885": "工作区已同步", - "ef223fbb6b": "A device tried to connect but is not paired", - "11992d0337": "If this was your phone or another Orca client, re-pair it from Settings → Mobile.", - "6573cfe955": "Open Mobile Settings", - "unresolvedTerminalWorktreeOwner": "Terminal creation is unavailable because the worktree owner could not be resolved" + "ef223fbb6b": "有设备尝试连接但未配对", + "11992d0337": "如果这是您的手机或其他 Orca 客户端,请从设置 → 移动端重新配对。", + "6573cfe955": "打开移动端设置", + "unresolvedTerminalWorktreeOwner": "由于无法解析工作树所有者,终端创建不可用" }, "useSettingsNavigationMetadata": { "4a728cd56b": "仍在完善中的新功能。尝试一下。", @@ -859,7 +859,7 @@ "c6f37a563d": "+ 负责人", "f41ec96c13": "+ 标签", "ab050dffec": "已关闭", - "dc1ca081a8": "进行中", + "dc1ca081a8": "开放", "2e4d806c92": "工作区", "886a64b081": "还没有", "4ba0132f37": "编辑标签", @@ -1311,15 +1311,15 @@ "perWorkspaceEnvHint": "通过环境模板按需创建环境", "branchName": "分支名称", "branchNamePlaceholder": "feature/my-branch", - "connectTimedOut": "Connection timed out. It may still be connecting in the background.", + "connectTimedOut": "连接超时,可能仍在后台连接中。", "connectingHost": "Connecting…", "connectHost": "Connect", "addHost": "Add host", - "addHostHint": "Register another machine or Orca server", - "addSshHost": "Add SSH host", - "addSshHostHint": "Use an existing machine over SSH", - "addRemoteOrcaServer": "Add Remote Orca Server", - "addRemoteOrcaServerHint": "Pair another Orca runtime", + "addHostHint": "注册另一台机器或 Orca 服务器", + "addSshHost": "添加 SSH 主机", + "addSshHostHint": "通过 SSH 使用现有机器", + "addRemoteOrcaServer": "添加远程 Orca 服务器", + "addRemoteOrcaServerHint": "配对另一个 Orca 运行时", "hostConnectionFailed": "Connection failed" }, "NewWorkspaceComposerModal": { @@ -1357,7 +1357,7 @@ "14c9fc70ed": "+ 负责人", "bc215fea4d": "+ 标签", "b936cc51a4": "已关闭", - "7b8f6bf6d8": "进行中", + "7b8f6bf6d8": "开放", "a18d01cda3": "尚未报告任何 CI 检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", @@ -1495,7 +1495,7 @@ "checkActionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如,批准工作流运行),然后才能解除合并阻止。", "dd5d9a4f17": "{{value0}}更新", "71a3c0f9d2": "启动工作区", - "filesUnavailable": "Couldn't load changed files.", + "filesUnavailable": "无法加载变更的文件。", "filesRetry": "Retry" }, "QuickOpen": { @@ -1517,7 +1517,7 @@ "b227d88520": "找到 {{value0}} 文件", "995be8ea22": "复制", "cf144856dc": "已复制", - "344f8a48dd": "on the host running the Quick Open scan to enable fast, gitignore-aware listing:" + "344f8a48dd": "在运行 Quick Open 扫描的主机上启用快速、遵循 gitignore 的文件列表:" }, "SelectedTextCopyMenu": { "9b40d7b018": "复制" @@ -1544,7 +1544,7 @@ "2abe22ef76": "您的token通过操作系统钥匙串加密并存储在本地。", "246c2b3dd3": "Atlassian 账户设置", "59c14d34a2": "创建一个token", - "b95623e93f": "Atlassian API token", + "b95623e93f": "Atlassian API 令牌", "68df347677": "你@example.com", "163df31e0e": "https://example.atlassian.net", "33fc2bcb30": "使用 Jira Cloud 站点 URL、Atlassian 电子邮件和 API token来浏览议题。", @@ -1853,10 +1853,10 @@ "jiraSortDescending": "降序", "jiraSortBy": "排序方式", "jiraToggleSortDirection": "按{{value0}}排序", - "75a38d7df8": "GitHub data is temporarily unavailable. Its API may be down, rate-limited, or unreachable. Please try again shortly.", - "noGithubSourceDetected": "No GitHub source detected for", - "noGithubSourceDetectedHint": "it may have no GitHub remote, or the source could not be resolved.", - "jiraLinkSourceUnavailable": "Couldn’t link this Jira issue. Reconnect Jira or pick the matching site, then try again.", + "75a38d7df8": "GitHub 数据暂时不可用,其 API 可能已宕机、被限流或无法访问。请稍后重试。", + "noGithubSourceDetected": "未检测到 GitHub 来源:", + "noGithubSourceDetectedHint": "它可能没有 GitHub 远程仓库,或来源无法解析。", + "jiraLinkSourceUnavailable": "无法关联此 Jira 议题。请重新连接 Jira 或选择匹配的站点,然后重试。", "loadPageUnreachable": "第 {{value0}} 页超出了 GitHub 搜索可返回的范围。", "loadPageFailed": "无法从 GitHub 加载第 {{value0}} 页。", "loadPageNoMoreResults": "第 {{value0}} 页没有更多结果。" @@ -2501,7 +2501,7 @@ "error": "工作区清理失败", "skippedAncestor": "由于无法删除嵌套工作区,已跳过。", "timedOut": "删除 {{value0}} 的时间比预期长。它将在后台继续运行。", - "stillRemoving": "Still removing workspaces: {{value0}}", + "stillRemoving": "仍在移除工作区:{{value0}}", "skippedPendingAncestor": "已跳过,因为嵌套的工作区尚未完成移除。" }, "candidateRow": { @@ -2799,7 +2799,7 @@ "pane": { "id": { "copy": { - "failed": "Unable to copy pane ID" + "failed": "无法复制窗格 ID" } } } @@ -3381,10 +3381,10 @@ "c06c1d215d": "由于网络请求失败,无法刷新 Claude 用量。", "cabdc2a9e0": "无法读取 Claude 登录凭据。", "a7517cccb6": "Claude 用量目前不可用。", - "e2c6a4f917": "Run Grok to refresh", - "d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.", - "f90b3d7a16": "Run Kimi to refresh", - "a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage." + "e2c6a4f917": "运行 Grok 以刷新", + "d1b7f509ac": "在运行 Orca 的电脑上的终端中运行 grok,并等待其启动。如有提示,请完成登录,然后重试用量检查。您无需发送聊天消息。", + "f90b3d7a16": "运行 Kimi 以刷新", + "a37e8c15d4": "在运行 Orca 的电脑上的终端中运行 kimi,等待其启动,然后重试用量检查。" }, "SshTargetStatusRow": { "sshHost": "SSH 主机" @@ -3394,8 +3394,8 @@ "remaining": "剩余 {{value0}}%" }, "UsagePercentageDisplayChangeNotice": { - "title": "Usage now shows % used", - "body": "Prefer remaining? Change it in Settings.", + "title": "用量现在显示已用百分比", + "body": "更喜欢剩余额度?请在设置中更改。", "dismiss": "Dismiss", "openSettings": "Open Settings", "gotIt": "Got it" @@ -3417,12 +3417,12 @@ }, "RemoteServerUpdateStatusSegment": { "updating": "Updating {{value0}}/{{value1}}", - "updatingTooltip": "Remote Orca Server updates are in progress", - "failed": "{{value0}} server updates failed", - "failedTooltip": "Open Remote Orca Server updates to review and retry", + "updatingTooltip": "远程 Orca 服务器更新正在进行中", + "failed": "{{value0}} 个服务器更新失败", + "failedTooltip": "打开远程 Orca 服务器更新以查看并重试", "updated": "{{value0}} servers updated", - "updatedTooltip": "Remote Orca Server updates completed", - "failedOne": "1 server update failed", + "updatedTooltip": "远程 Orca 服务器更新已完成", + "failedOne": "1 个服务器更新失败", "updatedOne": "1 server updated" }, "resource": { @@ -3437,17 +3437,17 @@ "runningLabel": "Updating skills", "runningOne": "Updating {{value0}}…", "runningMany": "Updating {{value0}} skills…", - "runningAria": "Skills updating. Click to open details.", + "runningAria": "技能正在更新。点击查看详情。", "successLabel": "Skills updated", "successOne": "Updated {{value0}}", "successMany": "Updated {{value0}} skills", - "successAria": "Skills updated. Click to open details.", + "successAria": "技能已更新。点击查看详情。", "errorLabel": "Update failed", - "errorTooltip": "Skill update failed — click to see details", - "errorAria": "Skill update failed. Click to open details.", + "errorTooltip": "技能更新失败 — 点击查看详情", + "errorAria": "技能更新失败。点击查看详情。", "stoppingLabel": "Stopping update", - "stoppingTooltip": "Stopping the skill update…", - "stoppingAria": "Stopping the skill update. Click to open details." + "stoppingTooltip": "正在停止技能更新…", + "stoppingAria": "正在停止技能更新。点击查看详情。" } } }, @@ -3841,18 +3841,18 @@ "tipReadOnly": "这里的技能位于只读位置,需更改其权限后才能更新。", "tipInRepo": "这里的技能位于某个项目内,而非全局技能;Orca 只更新全局技能。", "tipPluginCache": "这里的技能由插件管理;请改为更新插件。", - "skippedReasonUnrecognized": "The copy here doesn’t match the official version — it may be modified, or a different skill with the same name. Orca left it out of the update so it won’t overwrite it. Remove it if you want Orca to update this skill.", - "skippedReasonReadOnly": "This copy is in a read-only location, so Orca left it out of the update. Change its permissions to let Orca update it.", - "skippedReasonInaccessible": "Orca couldn’t read this copy, so it left the skill out of the update.", - "skippedReasonInRepo": "This is a project skill, not a global one — Orca only updates your global skills, so it left this out of the update.", - "skippedReasonPluginCache": "A plugin manages this skill, so Orca left it out of the update — update the plugin instead.", - "skippedReasonExternalLink": "This copy is a shortcut pointing outside Orca’s skill folders, so Orca left it out of the update.", - "skippedReasonBrokenLink": "This copy is a shortcut to something that no longer exists, so Orca left it out — you can safely delete it.", - "skippedReasonDuplicate": "This is a separate copy, so the update won’t reach it — the command only refreshes the main copy. Remove this copy, then reinstall the skill so this location follows the main one.", - "skippedReasonNewer": "This copy is a later version than the one this build of Orca ships, so Orca left it alone rather than roll it back. Updating Orca will bring the two back in line.", - "skippedReasonStaleRecord": "The skills updater has no usable record of this copy, so it reports the skill as already up to date and changes nothing. Reinstall it to bring the record back in line: {{value0}}", + "skippedReasonUnrecognized": "此处的副本与官方版本不一致 — 它可能被修改过,或是同名但内容不同的技能。Orca 将其排除在更新之外,以免覆盖它。如果您希望 Orca 更新此技能,请删除它。", + "skippedReasonReadOnly": "此副本位于只读位置,因此 Orca 未将其纳入更新。请更改其权限,让 Orca 更新它。", + "skippedReasonInaccessible": "Orca 无法读取此副本,因此未将该技能纳入更新。", + "skippedReasonInRepo": "这是项目技能而非全局技能 — Orca 只更新您的全局技能,因此将其排除在更新之外。", + "skippedReasonPluginCache": "此技能由插件管理,因此 Orca 未将其纳入更新 — 请改为更新插件。", + "skippedReasonExternalLink": "此副本是指向 Orca 技能文件夹之外的快捷方式,因此 Orca 未将其纳入更新。", + "skippedReasonBrokenLink": "此副本是指向已不存在内容的快捷方式,因此 Orca 将其排除 — 您可以放心删除。", + "skippedReasonDuplicate": "这是独立的副本,因此更新不会触及它 — 该命令只刷新主副本。请删除此副本并重新安装技能,使此位置跟随主副本。", + "skippedReasonNewer": "此副本的版本比当前 Orca 版本内置的更新,因此 Orca 未动它,以免回退。更新 Orca 后两者将恢复一致。", + "skippedReasonStaleRecord": "技能更新器没有此副本的有效记录,因此它报告技能已是最新且不做任何更改。请重新安装,使记录恢复一致:{{value0}}", "chipNewer": "Newer", - "tipNewer": "This copy is a later version than the one this build of Orca ships." + "tipNewer": "此副本的版本比当前 Orca 版本内置的更新。" }, "SkillFreshnessUpdateDialog": { "title": "更新技能", @@ -3864,16 +3864,16 @@ "attention": "部分已安装的 Orca 技能无法自动更新。", "checkNow": "立即检查", "close": "关闭", - "blockedOne": "1 skill can't be updated automatically.", - "blockedMany": "{{value0}} skills can't be updated automatically.", + "blockedOne": "有 1 个技能无法自动更新。", + "blockedMany": "有 {{value0}} 个技能无法自动更新。", "runningOne": "Updating 1 skill…", "runningMany": "Updating {{value0}} skills…", - "runningDescription": "You can close this window — it keeps running in the background.", + "runningDescription": "您可以关闭此窗口 — 更新会在后台继续运行。", "progressAria": "Updating skills", "updatedOne": "Updated 1 skill", "updatedMany": "Updated {{value0}} skills", - "updatedPartial": "Updated {{value0}} of {{value1}} skills", - "errorTitle": "The update didn't finish", + "updatedPartial": "已更新 {{value1}} 个技能中的 {{value0}} 个", + "errorTitle": "更新未完成", "retry": "Retry", "copyCommand": "Copy command", "copied": "Copied", @@ -3884,7 +3884,7 @@ "done": "Done", "stop": "Stop", "stopping": "Stopping…", - "stoppingHeadline": "Stopping the update…", + "stoppingHeadline": "正在停止更新…", "scanIncomplete": "Orca 无法完成对插件管理技能的检查。", "scanDepthLimit": "在检查此文件夹之前,Orca 已达到插件扫描深度上限。", "scanEntryLimit": "在检查此缓存的其余内容之前,Orca 已达到插件扫描条目数上限。", @@ -3903,7 +3903,7 @@ "needsAttention": "Review skill" }, "SkillUpdateResultRows": { - "stillOutdated": "Still out of date after the update ran." + "stillOutdated": "更新运行后仍为过期状态。" }, "SkillUpdateRow": { "oneLocation": "1 location", @@ -4200,9 +4200,9 @@ "a2fd890d9e": "请在提交前输入反馈。", "f2e42e1307": "发送", "69969ba364": "正在发送...", - "imageReadFailed": "Could not read the attached images. Try attaching them again.", - "attachWhileSending": "Wait for the current feedback to finish sending before attaching more images.", - "imagesNotDelivered": "Feedback sent, but image delivery could not be confirmed." + "imageReadFailed": "无法读取附加的图片。请重新附加。", + "attachWhileSending": "请等待当前反馈发送完成,再附加更多图片。", + "imagesNotDelivered": "反馈已发送,但无法确认图片已送达。" }, "SidebarFilter": { "e3b3898218": "添加项目", @@ -4220,7 +4220,9 @@ "ee240a39eb": "编辑筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarHeader": { "92154beb7e": "新工作区", @@ -4233,7 +4235,7 @@ "SidebarNav": { "80611a8b10": "搜索", "0c3395fd32": "搜索工作树和浏览器选项卡", - "c86d83b5c3": "新建", + "c86d83b5c3": "新功能", "1b5c41caee": "Orca 手机端", "9c95e1ce91": "智能体", "f323383e9a": "自动化", @@ -4285,7 +4287,9 @@ "82594419ba": "筛选条件", "automationCreated": "隐藏自动化创建的工作区", "cliCreated": "Hide CLI-created", - "detachedHead": "隐藏分离 HEAD" + "detachedHead": "隐藏分离 HEAD", + "keepDefaultBranch": "默认分支除外", + "keepDefaultBranchAria": "隐藏休眠工作区时仍显示默认分支" }, "SidebarWorkspaceOptionsMenu": { "95c9754653": "智能体活动布局", @@ -4345,19 +4349,6 @@ "folderPathIdentity": "分支 / 文件夹路径", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "正在连接...", - "89385db176": "关闭", - "656368f3a2": "重连失败", - "376bed88e5": "与远程主机的连接遇到错误。", - "4afcca1d24": "重新连接", - "11552bf786": "SSH 已断开", - "cb5938ae79": "正在重新连接...", - "disconnected": "此 SSH 主机未连接。", - "reconnecting": "正在重新连接远程主机...", - "reconnectionFailed": "重新连接到远程主机失败。", - "authFailed": "远程主机身份验证失败。" - }, "SshTargetRow": { "4677394048": "正在连接…", "75ad429b5d": "连接" @@ -4467,10 +4458,10 @@ "issueLinkLabel": "议题链接", "reviewLinkLabel": "{{value0}} 链接", "cliHeader": "Orca CLI", - "cliCreatedFromAgent": "Created by an agent via `orca worktree create`", - "cliCreatedFromShell": "Created via `orca worktree create`", + "cliCreatedFromAgent": "由智能体通过 `orca worktree create` 创建", + "cliCreatedFromShell": "通过 `orca worktree create` 创建", "cliStartupAgent": "Started with {{value0}}", - "cliCreated": "Created by Orca CLI", + "cliCreated": "由 Orca CLI 创建", "jiraIssue": "Jira {{value0}}", "viewOnJira": "在 Jira 中查看", "linkedJira": "已关联 Jira {{value0}}" @@ -4601,20 +4592,20 @@ "3ec372b664": "文件管理器", "localOnly": "Local only", "remoteSsh": "Remote SSH", - "remoteRuntimeUnsupported": "Opening this path in a local app is not available.", - "remoteRuntimeUnsupportedDetail": "Switch to a local or SSH workspace, then try again.", - "sshTargetNotFound": "SSH host is no longer available.", - "sshTargetNotFoundDetail": "Refresh workspaces or reconnect the host, then try again.", - "sshTargetInvalid": "SSH host configuration is incomplete.", - "sshTargetInvalidDetail": "Edit or reconnect the SSH host, then try again.", - "sshAliasRequired": "VS Code needs an SSH config alias for this host.", - "sshAliasRequiredDetail": "Add a Host alias for {{host}}:{{port}} to your local SSH config, reconnect the workspace, then try again.", - "remoteEditorUnsupported": "This app cannot open SSH workspaces.", - "remoteEditorUnsupportedDetail": "Choose VS Code or use the app locally.", - "remotePathInvalid": "Path is not valid for the SSH host.", - "remotePathInvalidDetail": "Refresh the workspace before trying again.", - "remoteLaunchFailed": "Could not open the path in VS Code.", - "remoteLaunchFailedDetail": "Check the VS Code command configured on this machine." + "remoteRuntimeUnsupported": "无法在本地应用中打开此路径。", + "remoteRuntimeUnsupportedDetail": "请切换到本地或 SSH 工作区,然后重试。", + "sshTargetNotFound": "SSH 主机已不可用。", + "sshTargetNotFoundDetail": "请刷新工作区或重新连接主机,然后重试。", + "sshTargetInvalid": "SSH 主机配置不完整。", + "sshTargetInvalidDetail": "请编辑或重新连接 SSH 主机,然后重试。", + "sshAliasRequired": "VS Code 需要此主机的 SSH 配置别名。", + "sshAliasRequiredDetail": "请在本地 SSH 配置中为 {{host}}:{{port}} 添加 Host 别名,重新连接工作区,然后重试。", + "remoteEditorUnsupported": "此应用无法打开 SSH 工作区。", + "remoteEditorUnsupportedDetail": "请选择 VS Code 或在本地使用该应用。", + "remotePathInvalid": "路径对此 SSH 主机无效。", + "remotePathInvalidDetail": "请先刷新工作区,再重试。", + "remoteLaunchFailed": "无法在 VS Code 中打开此路径。", + "remoteLaunchFailedDetail": "请检查此机器上配置的 VS Code 命令。" }, "WorktreeTitleInlineRename": { "2f42ae024f": "未读:", @@ -4712,12 +4703,12 @@ "c460fecc4a": "无法睡眠某些工作区", "8bc3fc0671": "无法睡眠工作区", "legacy": { - "unverified": "The older host runtime could not confirm terminal shutdown. The workspace was kept open; update the host and try again." + "unverified": "旧版主机运行时无法确认终端已关闭。工作区已保持打开;请更新主机后重试。" }, "host": { - "unverified": "The host could not confirm terminal shutdown. The workspace was kept open; check the connection and try again." + "unverified": "主机无法确认终端已关闭。工作区已保持打开;请检查连接后重试。" }, - "retry": "The workspace was kept open. Try again; if the problem continues, check the host connection." + "retry": "工作区已保持打开。请重试;如果问题仍然存在,请检查主机连接。" } } }, @@ -4762,12 +4753,12 @@ "1a9383112b": "Conductor 进度", "caebe3c10f": "Conductor 评审", "895f381714": "Conductor 完成", - "caabd5ca85": "锌", + "caabd5ca85": "锌灰", "7adb43ecf0": "玫瑰", "ddf25b6262": "翡翠", "7cebab6d4a": "琥珀", "1b81da243a": "紫罗兰", - "6437a8c253": "天空", + "6437a8c253": "天蓝", "fc3b92756c": "蓝色", "52e3c6e2a4": "中性" } @@ -5052,7 +5043,7 @@ "parkTerminal": "Park terminal" }, "SidebarFeedbackImageAttachments": { - "screenshotsHint": "Attach up to {count} screenshots", + "screenshotsHint": "最多附加 {count} 张截图", "attachImages": "Attach", "removeImage": "Remove {{fileName}}" }, @@ -5238,9 +5229,9 @@ "remoteEmptyCodexAccounts": "{{value0}} 上没有受管理的 Codex 账户。它使用其系统默认的 Codex 登录;请在该服务器上添加账户。", "codexSystemDefaultCustomProvider": "自定义提供商 — 不跟踪使用情况。", "codexSystemDefaultNeedsSignIn": "未找到 {{value0}} 的 Codex 登录信息。", - "codexConfigSyncMissingSource": "Codex is still using the settings it last synced because {{value0}} is missing. Restore that file to resume syncing.", - "codexConfigSyncBlankSource": "Codex is still using the settings it last synced because {{value0}} is empty. That is expected while a synced folder finishes downloading.", - "codexConfigSyncUnreadableSource": "Codex is still using the settings it last synced because {{value0}} could not be read. Check that file's permissions." + "codexConfigSyncMissingSource": "由于缺少 {{value0}},Codex 仍在使用上次同步的设置。请恢复该文件以恢复同步。", + "codexConfigSyncBlankSource": "由于 {{value0}} 为空,Codex 仍在使用上次同步的设置。同步文件夹完成下载前出现这种情况是正常的。", + "codexConfigSyncUnreadableSource": "由于无法读取 {{value0}},Codex 仍在使用上次同步的设置。请检查该文件的权限。" }, "AdvancedPane": { "40b29e0bf3": "重新启动", @@ -5327,8 +5318,8 @@ "codexSessionSourceInfo": "关于导入 Codex 历史记录", "codexSessionSourceTooltip": "Orca 在隔离的主目录中运行 Codex。将此项指向您现有的 Codex 主目录以导入会话历史记录。留空则使用 ~/.codex。", "03e1a5081a": "on {{value0}}", - "25a41a9aad": "Re-detect agents installed on the active server", - "remoteDetectionFailed": "Couldn’t detect installed agents. Check the host connection and try again.", + "25a41a9aad": "重新检测活动服务器上安装的智能体", + "remoteDetectionFailed": "无法检测已安装的智能体。请检查主机连接后重试。", "retryDetection": "Retry" }, "AppIconSelector": { @@ -5602,7 +5593,7 @@ "f00d6aa9b5": "WSL 在此计算机上不可用。", "7c776ff9d8": "wsl", "fc0fcf72fd": "在技​​能设置之前注册 WSL shell 命令。", - "windowsPathUnknown": "WSL shell command PATH could not be checked" + "windowsPathUnknown": "无法检查 WSL shell 命令 PATH" }, "CommitMessageAiPane": { "841ed9884a": "由未自定义源代码控制 AI 的存储库使用。", @@ -5800,7 +5791,7 @@ "4aa4d9fb73": "在差异编辑器中换行显示长行,而不是要求水平滚动。", "bf16ef0af2": "关闭", "3f6892f307": "开启", - "b82f86d7d2": "Rich Markdown Spellcheck", + "b82f86d7d2": "Markdown 拼写检查(富文本)", "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "7ddd66fede": "编辑器自动换行", "9b18de6eea": "在文件编辑器中自动换行长行,而无需水平滚动。" @@ -6093,7 +6084,7 @@ }, "McpConfigFileRow": { "b145eb6009": "环境:", - "e720c139cd": "开放", + "e720c139cd": "打开", "845ae248e8": "有效的" }, "McpConfigSection": { @@ -6476,7 +6467,10 @@ "f79972271a": "找不到此仓库的 GitHub 远程。", "4d039317f4": "网站图标", "acf31559a0": "输入有效的网站 URL。", - "868c5c9b56": "无法导入仓库图标" + "868c5c9b56": "无法导入仓库图标", + "emojiTooLongForRepoIcon": "此表情符号无法用作仓库图标。", + "currentEmojiSelection": "当前:{{value0}}", + "searchEmojiPlaceholder": "搜索表情符号" }, "RepositoryPane": { "15a99d9b9f": "相对路径从该项目根解析。", @@ -7342,7 +7336,7 @@ "cf37ff69f6": "根据", "8abdab9f7c": "立即重启", "907131d741": "正在重新启动...", - "605a35d600": "Not applied by the terminal renderer yet — xterm.js has no bold color slot. A saved value is preserved." + "605a35d600": "终端渲染器尚未应用此项 — xterm.js 没有粗体颜色槽位。已保存的值会被保留。" }, "UIZoomControl": { "c2c64b24d0": "重置" @@ -7369,7 +7363,7 @@ "8f4d2a51d7": "离线", "d504ab05f0": "流媒体", "fbe5990716": "选择型号", - "e24f7d43d2": "选择语音模型。本地模型离线运行;云模型需要 API 密钥。", + "e24f7d43d2": "选择语音模型,本地模型离线运行;云模型需要 API 密钥。", "174da92062": "抓住", "118b3c2dee": "切换", "901985625d": "切换", @@ -8163,12 +8157,12 @@ "8e593f04fc": "关闭固定标签页前显示确认对话框。", "defaultProjectRuntime": "默认项目运行时", "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", - "d2d2d929c0": "Rich Markdown Spellcheck", + "d2d2d929c0": "Markdown 拼写检查(富文本)", "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "e61157e926": "编辑器自动换行", "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。", "editorFontFamily": "编辑器字体", - "editorFontFamilyDesc": "文件编辑器和差异视图使用的字体。留空则跟随终端字体。" + "editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。" } }, "git": { @@ -8301,7 +8295,7 @@ "d914d7ab70": "正在验证...", "5936977fcd": "取消", "1666f8d562": "创建 Atlassian API token", - "1ab7f551f3": "Atlassian API token", + "1ab7f551f3": "Atlassian API 令牌", "09d310e42d": "你@example.com", "27dae4ab60": "https://example.atlassian.net", "a28f417220": "连接 Jira", @@ -8611,7 +8605,7 @@ "cfad7ce5f3": "AI", "a47f51127e": "源代码控制", "6cc5c65e64": "项目特定的 git 生成覆盖。", - "eec3995dc6": "Git AI Author", + "eec3995dc6": "Git AI 作者", "cc876ca5f2": "存储库", "6469de5368": "项目", "3067595d82": "删除", @@ -9626,21 +9620,21 @@ "manageConnectionLink": "集成" }, "MobileRelayBetaNotice": { - "notice": "Orca Relay is in beta." + "notice": "Orca Relay 目前处于测试阶段。" }, "EditorFontFamilySetting": { "title": "编辑器字体", - "description": "文件编辑器和差异视图使用的字体。留空则跟随终端字体。", + "description": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。", "placeholder": "与终端字体相同" }, "GeneralRemoteServerUpdates": { "serverCount": "{{value0}} paired servers", - "availableCount": "{{value0}} ready to update", - "currentCount": "{{value0}} up to date", + "availableCount": "{{value0}} 个可更新", + "currentCount": "{{value0}} 个已是最新", "manualCount": "{{value0}} manual", "offlineCount": "{{value0}} offline", - "title": "Remote Orca Servers", - "description": "Check and update paired Orca servers from this client.", + "title": "远程 Orca 服务器", + "description": "从此客户端检查和更新已配对的 Orca 服务器。", "updating": "Updating servers…", "reviewUpdates": "{{value0}} updates available", "reviewServers": "Server updates", @@ -9684,11 +9678,15 @@ "unpackedHelp": "开发版本必须从其源代码检出进行更新。", "legacyHelp": "手动更新此服务器一次以启用远程更新。" }, + "BrowserLinkRoutingSetting": { + "description": "在 Orca 内置浏览器中打开来自终端、Markdown 和编辑器的 http(s) 链接。{{shortcut}} 始终使用系统浏览器。", + "descriptionBase": "在 Orca 内置浏览器中打开来自终端、Markdown 和编辑器的 http(s) 链接。" + }, "BrowserLinkRoutingModifierSetting": { - "titleSystem": "Hold Shift to open in your web browser", - "titleOrca": "Hold Shift to open in Orca", - "descriptionSystem": "Links open in Orca, so {{chord}}+click sends one to your system browser instead.", - "descriptionOrca": "Links open in your system browser. When enabled, {{chord}}+click opens one in Orca's built-in browser instead." + "titleSystem": "按住 Shift 在系统浏览器中打开", + "titleOrca": "按住 Shift 在 Orca 中打开", + "descriptionSystem": "链接在 Orca 中打开,因此 {{chord}}+点击会将其发送到系统浏览器。", + "descriptionOrca": "链接在系统浏览器中打开。启用后,{{chord}}+点击会改在 Orca 内置浏览器中打开。" }, "PluginConsentDialog": { "workerTrust": "后台工作进程 — 运行自己的进程", @@ -9725,13 +9723,13 @@ }, "PluginConsentProvenance": { "official": "Official", - "bundled": "Bundled with Orca", + "bundled": "随 Orca 捆绑提供", "local": "Local folder", "community": "Community", "source": "Source", "sourceLabel": "Source", "commit": "Pinned commit", - "localCommit": "Local folder — no commit", + "localCommit": "本地文件夹 — 无提交", "indexCommit": "Index commit" }, "PluginDevelopmentSection": { @@ -9795,7 +9793,7 @@ "PluginMarketplaceListingRow": { "official": "官方", "installed": "已安装", - "noDescription": "No description provided.", + "noDescription": "未提供描述。", "blocked": "已被 Orca 安全列表阻止:{{value0}}", "blockedAction": "已阻止", "checkUpdate": "检查更新", @@ -10046,6 +10044,14 @@ "setupRecipe": "为你的云提供商设置环境模板。", "createWorkspace": "创建工作区,并在“运行于”中选择该模板。", "openSetup": "设置环境模板" + }, + "VoiceMicrophoneSetting": { + "systemDefault": "系统默认", + "unavailable": "不可用", + "label": "麦克风", + "description": "用于语音听写的输入设备,系统默认会跟随操作系统的麦克风设置。", + "accessHint": "允许麦克风访问以列出输入设备。", + "allowAccess": "允许访问" } }, "right": { @@ -10648,7 +10654,6 @@ "fd46a70f1a": "开始", "a54ae21c6f": "状态:", "e4e3af15ee": "查看完整详细信息", - "2524d1fb83": "日志尾部提供完整详细信息。", "a2fb3f4408": "显示前 100 个作业", "df137989b3": "显示前 20 个注释", "1f2b980522": "正在加载检查详细信息...", @@ -10701,7 +10706,8 @@ "5e6e5a13fa": "查看", "actionRequiredHint": "此检查需要在 GitHub 上执行手动操作(例如批准工作流运行)后才能解除合并阻止。", "b3195cba33": "取消将作者标记为机器人", - "f588b46a6c": "将作者标记为机器人" + "f588b46a6c": "将作者标记为机器人", + "checkDetailsLoadFailed": "加载检查详细信息失败。" }, "empty": { "state": { @@ -11121,7 +11127,7 @@ "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "无法为恢复准备此会话。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -11323,16 +11329,16 @@ "refresh": { "error": { "copy": { - "580025e7b7": "GitHub is unavailable", - "01c85b5770": "GitHub's API is temporarily unavailable. This panel reloads automatically once it recovers.", - "d1a9f2b165": "Can't reach GitHub", - "7d01d42a3a": "GitHub is unreachable right now. Check your connection, then try again shortly.", - "e9d681894a": "GitHub rate limit reached", - "8c77434d6f": "GitHub is rate-limiting requests. This panel refreshes once the limit resets.", - "79aa06bb2c": "Couldn't refresh. GitHub's API is temporarily unavailable. Showing the last known status.", - "6ec12cee0c": "Couldn't refresh. GitHub is unreachable right now. Showing the last known status.", - "de088015e8": "Couldn't refresh. GitHub is rate-limiting requests. Showing the last known status.", - "d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status." + "580025e7b7": "GitHub 不可用", + "01c85b5770": "GitHub 的 API 暂时不可用。恢复后此面板会自动重新加载。", + "d1a9f2b165": "无法访问 GitHub", + "7d01d42a3a": "现在无法访问 GitHub。请检查网络连接,稍后重试。", + "e9d681894a": "已达到 GitHub 速率限制", + "8c77434d6f": "GitHub 正在限制请求频率。限制重置后此面板会自动刷新。", + "79aa06bb2c": "刷新失败。GitHub 的 API 暂时不可用。正在显示上次已知状态。", + "6ec12cee0c": "刷新失败。现在无法访问 GitHub。正在显示上次已知状态。", + "de088015e8": "刷新失败。GitHub 正在限制请求频率。正在显示上次已知状态。", + "d9dd7c6687": "无法从 GitHub 刷新。正在显示上次已知状态。" } } } @@ -11558,7 +11564,7 @@ "86c0f1caa2": "内边距", "06a24f4f2d": "颜色", "c021e9dddd": "主题配色", - "ab2a583a97": "Cursor", + "ab2a583a97": "光标", "cc1858e19e": "字体", "248c812283": "导入", "7ee9234e54": "已检测 Ghostty 配置。", @@ -11683,7 +11689,7 @@ "retryJira": "重试", "searchJira": "搜索 Jira 议题或粘贴议题 URL", "jiraMode": "Jira", - "jiraSelectBindFailed": "Couldn’t link this Jira issue. Pick the matching site or reconnect Jira, then try again.", + "jiraSelectBindFailed": "无法关联此 Jira 议题。请选择匹配的站点或重新连接 Jira,然后重试。", "emoji": "Emoji" }, "ProjectHostSetupCombobox": { @@ -13189,7 +13195,7 @@ "save": { "failure": { "notice": { - "8c59ce5075": "Failed to save the file. Please try again." + "8c59ce5075": "保存文件失败。请重试。" } } } @@ -13904,7 +13910,7 @@ "79e7aaed39": "取消", "fdd26d81cc": "Atlassian 账户设置", "8090504a3e": "在此创建 token:", - "7b3967c12f": "Atlassian API token", + "7b3967c12f": "Atlassian API 令牌", "3d81bf3ab3": "API 令牌", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", @@ -13913,7 +13919,7 @@ "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点", "2e2b69e48e": "使用自托管 Jira 的基础 URL 和个人访问令牌来浏览议题。", - "b67e919bd5": "Jira instance type", + "b67e919bd5": "Jira 实例类型", "17787d6e4b": "Atlassian Cloud", "bc7a831773": "Self-hosted", "3489e186d6": "Jira 站点 URL", @@ -13922,12 +13928,12 @@ "8b9c7b9e7b": "Jira 个人访问令牌", "ccfb086d3e": "在 Jira 个人资料中的 Personal Access Tokens 下创建一个个人访问令牌。", "1d947a07ab": "使用自托管 Jira 的基础 URL、用户名和密码来浏览议题。", - "f49708c369": "Jira authentication method", + "f49708c369": "Jira 身份验证方式", "84a810dd0e": "Username & password", "8d1223fa5c": "Username", "be9eba0a1b": "username", "70035652d7": "Password", - "c50abbf340": "Jira account password", + "c50abbf340": "Jira 账户密码", "d8737db691": "使用你的 Jira Server 或 Data Center 账户用户名和密码。" } } @@ -14316,12 +14322,12 @@ "all": "All", "installed": "Installed", "searchLabel": "Search plugins", - "searchPlaceholder": "Search plugins, categories, or publishers" + "searchPlaceholder": "搜索插件、分类或发布者" } }, "terminalPane": { "useManualTerminalWorktreeParking": { - "cannotPark": "These terminals cannot be parked safely." + "cannotPark": "这些终端无法安全停放。" } }, "pr-check-counts": { @@ -14348,18 +14354,31 @@ }, "runtime": { "remoteServerUpdateErrors": { - "manualRequired": "This server must be updated manually through its service manager.", - "notAvailable": "The server no longer reports an available update. Check again.", - "notDownloaded": "The server update has not finished downloading.", - "legacyServer": "Update this server manually once to enable remote updates.", - "updaterTimeout": "Timed out waiting for the server updater.", - "requestedVersionUnavailable": "The server updater did not offer the requested Orca version.", - "updateUnavailable": "The server did not report an available update.", - "downloadIncomplete": "The server update did not finish downloading.", - "reconnectTimeout": "The server did not reconnect on the updated version." + "manualRequired": "此服务器必须通过其服务管理器手动更新。", + "notAvailable": "服务器不再报告有可用更新。请再次检查。", + "notDownloaded": "服务器更新尚未下载完成。", + "legacyServer": "请手动更新此服务器一次,以启用远程更新。", + "updaterTimeout": "等待服务器更新器超时。", + "requestedVersionUnavailable": "服务器更新器未提供所请求的 Orca 版本。", + "updateUnavailable": "服务器未报告有可用更新。", + "downloadIncomplete": "服务器更新未完成下载。", + "reconnectTimeout": "服务器未以更新后的版本重新连接。" }, "webRuntimeSession": { - "remoteHostDisconnected": "The workspace is not connected to a remote Orca host." + "remoteHostDisconnected": "工作区未连接到远程 Orca 主机。" + }, + "gitlabJobTraceClient": { + "loadFailed": "加载 GitLab 作业日志失败。", + "emptyTrace": "此 GitLab 作业没有可用的日志。", + "timedOut": "加载 GitLab 作业日志超时。" + } + }, + "ssh": { + "sshConnectVerb": { + "connect": "连接", + "connecting": "正在连接…", + "reconnect": "重新连接", + "retry": "重试" } } }, @@ -14537,11 +14556,11 @@ "dashboardPopout": { "placeholder": { "title": "Agent dashboard", - "description": "This is where all your agents will show up at a glance. The board is coming soon." + "description": "你的所有智能体都会在这里一览无余。看板即将上线。" }, "recoverableError": { - "title": "Orca dashboard hit an error.", - "description": "The dashboard could not finish rendering. Retry to remount it, or reopen it." + "title": "Orca 看板遇到错误。", + "description": "看板未能完成渲染。请重试以重新挂载,或重新打开。" }, "bucket": { "attention": "Needs You", @@ -14570,7 +14589,7 @@ "subagents_other": "{{count}} 个子代理" }, "terminal": { - "closed": "No live terminal — this agent's pane has closed.", + "closed": "没有活动的终端 — 该智能体的窗格已关闭。", "focusWorktree": "Focus worktree", "close": "Close" }, @@ -14613,40 +14632,40 @@ }, "browser": { "loadFailure": { - "connectionNotSecure": "Connection isn't secure", - "cantReachHost": "Can't reach {{value0}}", - "cantLoadPage": "Can't load this page", - "certificateNameMismatch": "The certificate doesn't match {{value0}}.", - "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", - "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", - "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", - "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", - "retry": "Retry", - "copyAddress": "Copy Address", - "addressCopied": "Copied the current page address.", - "openExternally": "Open Externally", - "tryHttps": "Try HTTPS", - "proceedUnsafe": "Proceed Anyway (Unsafe)", - "connecting": "Connecting…", - "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", - "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", - "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", - "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + "connectionNotSecure": "连接不安全", + "cantReachHost": "无法访问 {{value0}}", + "cantLoadPage": "无法加载此页面", + "certificateNameMismatch": "证书与 {{value0}} 不匹配。", + "certificateDateInvalid": "{{value0}} 的证书在当前日期和时间无效。", + "certificateAuthorityInvalid": "Orca 不信任签发 {{value0}} 证书的机构。", + "certificateVerificationFailed": "Orca 无法验证 {{value0}} 的证书。", + "trustedCertificateGuidance": "进行本地开发时,请尽可能使用受信任的本地证书。", + "retry": "重试", + "copyAddress": "复制地址", + "addressCopied": "已复制当前页面地址。", + "openExternally": "在外部打开", + "tryHttps": "尝试 HTTPS", + "proceedUnsafe": "仍然继续(不安全)", + "connecting": "正在连接…", + "certificateChallengeExpired": "此证书审批已过期。请重试该页面以请求新的审批。", + "certificateChallengeChanged": "证书请求已更改。请重试该页面并查看新的警告。", + "certificateChallengeUnavailable": "此证书请求已不可用。请重试该页面以重新请求。", + "certificateProceedFailed": "Orca 无法批准此证书请求。请重试该页面后再试。" } }, "runtimeRpc": { "startupFailure": { - "unknownCause": "No additional error details were available.", - "continueButton": "Continue without CLI", - "title": "Orca CLI unavailable", - "message": "Orca couldn't start its local command transport.", - "detail": "Orca will continue to work, but commands such as orca status, orca terminal, and orchestration are unavailable for this session.\n\n{{guidance}}\n\nCause: {{cause}}", + "unknownCause": "没有其他错误详情可用。", + "continueButton": "不使用 CLI 继续", + "title": "Orca CLI 不可用", + "message": "Orca 无法启动其本地命令传输通道。", + "detail": "Orca 仍可正常工作,但本会话中 orca status、orca terminal 和编排等命令不可用。\n\n{{guidance}}\n\n原因:{{cause}}", "guidance": { - "permissionDenied": "Orca couldn't write its runtime file. Check permissions on Orca's data folder, then restart.", - "storageUnavailable": "Your disk may be full or read-only. Free up space, then restart Orca.", - "invalidPath": "Orca's data folder may be missing, moved, or at a path that is too long. Restore it or use a shorter path, then restart Orca.", - "addressInUse": "Another process may be holding the port. Restart Orca to try again.", - "unknown": "Restart Orca to try again." + "permissionDenied": "Orca 无法写入其运行时文件。请检查 Orca 数据文件夹的权限,然后重启。", + "storageUnavailable": "磁盘可能已满或为只读。请释放空间,然后重启 Orca。", + "invalidPath": "Orca 的数据文件夹可能缺失、被移动或路径过长。请恢复它或使用更短的路径,然后重启 Orca。", + "addressInUse": "另一个进程可能占用了端口。请重启 Orca 以重试。", + "unknown": "请重启 Orca 以重试。" } } } diff --git a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts new file mode 100644 index 00000000000..19859369a78 --- /dev/null +++ b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts @@ -0,0 +1,56 @@ +/** + * The plugin-chrome exemption is a list of exact catalog paths, so it can rot in + * two directions: a renamed key leaves a dead entry behind, and a key that moves + * into a component the security tests name would widen the exemption silently. + * These assertions catch both. + */ +import { describe, expect, it } from 'vitest' +import { translatablePluginChromePaths } from '../../../shared/plugins/plugin-translatable-chrome' +import en from './locales/en.json' + +// Kept in sync with the components plugin-language-pack-artifact.test.ts pins as +// security copy: nothing from them may appear in the chrome exemption. +const SECURITY_COPY_COMPONENTS = [ + 'PluginConsentDialog', + 'PluginConsentProvenance', + 'pluginError', + 'PluginKeybindingConsentPreview', + 'PluginMarketplaceListingRow', + 'PluginMarketplacePreviewDialog', + 'PluginMarketplaceSourceDialog', + 'PluginVmRecipeConsentPreview' +] + +function lookup(key: string): string | undefined { + const value = key + .split('.') + .reduce( + (node, part) => + node && typeof node === 'object' ? (node as Record)[part] : undefined, + en as unknown + ) + return typeof value === 'string' ? value : undefined +} + +describe('translatable plugin chrome', () => { + it.each(translatablePluginChromePaths())('%s still exists in the English catalog', (path) => { + expect(lookup(path)).toBeTypeOf('string') + }) + + it('exempts nothing from a component the security tests pin', () => { + const leaked = translatablePluginChromePaths().filter((path) => + SECURITY_COPY_COMPONENTS.some((component) => + path.startsWith(`auto.components.settings.${component}.`) + ) + ) + + expect(leaked).toEqual([]) + }) + + // Why: `*Failed` copy reports what happened to a trust decision (a reviewed + // source that changed, a rollback target that is gone), so the boundary is the + // whole family rather than a per-message judgement. + it('exempts no failure copy', () => { + expect(translatablePluginChromePaths().filter((path) => path.endsWith('Failed'))).toEqual([]) + }) +}) diff --git a/src/renderer/src/i18n/relative-time-format.test.ts b/src/renderer/src/i18n/relative-time-format.test.ts new file mode 100644 index 00000000000..81cfc08bdaa --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIntlLocale } = vi.hoisted(() => ({ mockIntlLocale: { value: 'en' } })) + +vi.mock('./i18n', () => ({ getIntlLocale: () => mockIntlLocale.value })) + +import { + formatUiRelativeTime, + formatUiRelativeTimeFromDate, + getUiRelativeTimeFormatter +} from './relative-time-format' + +beforeEach(() => { + mockIntlLocale.value = 'en' +}) + +describe('getUiRelativeTimeFormatter', () => { + it('formats with the configured UI language instead of the OS locale', () => { + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('yesterday') + }) + + it('reuses the cached formatter while the language is unchanged', () => { + expect(getUiRelativeTimeFormatter()).toBe(getUiRelativeTimeFormatter()) + }) + + it('rebuilds the formatter after a runtime language switch', () => { + const english = getUiRelativeTimeFormatter() + mockIntlLocale.value = 'ko' + const korean = getUiRelativeTimeFormatter() + expect(korean).not.toBe(english) + expect(korean.format(-1, 'day')).toBe('어제') + }) + + // getIntlLocale() already maps the synthetic plugin resource language to a real BCP-47 tag, + // so the constructor never sees a value Intl would reject. + it('formats in the tag a plugin language pack resolves to', () => { + mockIntlLocale.value = 'ja' + expect(getUiRelativeTimeFormatter().format(-1, 'day')).toBe('昨日') + }) +}) + +describe('formatUiRelativeTime', () => { + it('picks the minute/hour/day unit from the signed millisecond delta', () => { + expect(formatUiRelativeTime(-5 * 60_000)).toBe('5 minutes ago') + expect(formatUiRelativeTime(-3 * 3_600_000)).toBe('3 hours ago') + expect(formatUiRelativeTime(-2 * 86_400_000)).toBe('2 days ago') + }) + + it('formats future deltas', () => { + expect(formatUiRelativeTime(2 * 86_400_000)).toBe('in 2 days') + }) +}) + +describe('formatUiRelativeTimeFromDate', () => { + it('returns the fallback for an invalid date', () => { + expect(formatUiRelativeTimeFromDate('not-a-date')).toBe('recently') + expect(formatUiRelativeTimeFromDate('not-a-date', 'unknown')).toBe('unknown') + }) +}) diff --git a/src/renderer/src/i18n/relative-time-format.ts b/src/renderer/src/i18n/relative-time-format.ts new file mode 100644 index 00000000000..95387b5daf1 --- /dev/null +++ b/src/renderer/src/i18n/relative-time-format.ts @@ -0,0 +1,39 @@ +import { getIntlLocale } from './i18n' + +// Why: relative times are language words ("2 days ago" / "그저께"), so they must follow the +// configured UI language rather than the OS locale — unlike Intl.DateTimeFormat, where numeric +// date shapes following the OS region is conventional. The language can change at runtime, and +// plugin packs register under a synthetic tag Intl rejects, so resolve through getIntlLocale() +// and cache per resolved locale rather than freezing one formatter at module scope. +let cached: { locale: string; formatter: Intl.RelativeTimeFormat } | null = null + +export function getUiRelativeTimeFormatter(): Intl.RelativeTimeFormat { + const locale = getIntlLocale() + if (!cached || cached.locale !== locale) { + cached = { locale, formatter: new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) } + } + return cached.formatter +} + +/** Format a signed millisecond delta (future positive, past negative) at minute/hour/day granularity. */ +export function formatUiRelativeTime(diffMs: number): string { + const formatter = getUiRelativeTimeFormatter() + const diffMinutes = Math.round(diffMs / 60_000) + if (Math.abs(diffMinutes) < 60) { + return formatter.format(diffMinutes, 'minute') + } + const diffHours = Math.round(diffMinutes / 60) + if (Math.abs(diffHours) < 24) { + return formatter.format(diffHours, 'hour') + } + return formatter.format(Math.round(diffHours / 24), 'day') +} + +/** Parse a date string and format it relative to now; returns `fallback` when the input is invalid. */ +export function formatUiRelativeTimeFromDate(input: string, fallback = 'recently'): string { + const date = new Date(input) + if (Number.isNaN(date.getTime())) { + return fallback + } + return formatUiRelativeTime(date.getTime() - Date.now()) +} diff --git a/src/renderer/src/lib/new-workspace-ssh-gate.ts b/src/renderer/src/lib/new-workspace-ssh-gate.ts index f597e18e245..ac1ae7afe55 100644 --- a/src/renderer/src/lib/new-workspace-ssh-gate.ts +++ b/src/renderer/src/lib/new-workspace-ssh-gate.ts @@ -1,3 +1,4 @@ +import { isConnectingSshStatus } from '@/ssh/ssh-connection-recoverability' import { isRuntimeOwnedSshTargetId } from '../../../shared/execution-host' import type { SshConnectionStatus } from '../../../shared/ssh-types' @@ -9,7 +10,7 @@ export type SelectedRepoSshGate = { } export function isSshConnectInProgress(status: SshConnectionStatus | null): boolean { - return status === 'connecting' || status === 'deploying-relay' || status === 'reconnecting' + return isConnectingSshStatus(status) } export function getSelectedRepoSshGate(input: { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts new file mode 100644 index 00000000000..1cfeea0116a --- /dev/null +++ b/src/renderer/src/lib/resume-sleeping-agent-session-slept-pane-recovery.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { resumeSleepingAgentSessionsForWorktree } from './resume-sleeping-agent-session' + +const initialAppStoreState = useAppStore.getState() +const LEAF_ID = '33333333-3333-4333-8333-333333333333' +const OTHER_LEAF_ID = '44444444-4444-4444-8444-444444444444' + +afterEach(() => { + vi.unstubAllGlobals() + useAppStore.setState(initialAppStoreState, true) +}) + +function makeRecord( + overrides: Partial = {} +): SleepingAgentSessionRecord { + return { + paneKey: makePaneKey('tab-1', LEAF_ID), + tabId: 'tab-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'sess-1' }, + prompt: 'finish the task', + state: 'working', + capturedAt: 1, + updatedAt: 1, + origin: 'worktree-sleep', + ...overrides + } +} + +function makeTerminalTab(id: string, worktreeId: string): Record { + return { + id, + ptyId: null, + worktreeId, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } +} + +function makeLayout(leafId: string, ptyId = 'pty-1'): Record { + return { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } +} + +describe('waking panes slept by a manual workspace sleep', () => { + it('keeps an interrupted worktree-sleep record owned by a preserved pane', () => { + const record = makeRecord({ interrupted: true }) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + activeTabId: 'tab-1', + activeTabIdByWorktree: { 'wt-1': 'tab-1' }, + tabsByWorktree: { 'wt-1': [makeTerminalTab('tab-1', 'wt-1')] }, + terminalLayoutsByTabId: { 'tab-1': makeLayout(LEAF_ID) }, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + const state = useAppStore.getState() + expect(launched).toBe(0) + expect(state.tabsByWorktree['wt-1']).toHaveLength(1) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) + + // Why: every slept tab except the active one is unowned, so an active-class record there would + // spawn a duplicate tab and strand the original as a bare shell. + it('keeps a slept done record on a non-active tab for in-place cold restore', () => { + const record = makeRecord({ state: 'done' }) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + activeTabId: 'tab-2', + activeTabIdByWorktree: { 'wt-1': 'tab-2' }, + tabsByWorktree: { + 'wt-1': [makeTerminalTab('tab-1', 'wt-1'), makeTerminalTab('tab-2', 'wt-1')] + }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout(LEAF_ID), + 'tab-2': makeLayout(OTHER_LEAF_ID, 'pty-2') + }, + ptyIdsByTabId: {}, + sleepingAgentSessionsByPaneKey: { [record.paneKey]: record } + } as never) + + const launched = resumeSleepingAgentSessionsForWorktree('wt-1') + + const state = useAppStore.getState() + expect(launched).toBe(0) + expect(state.tabsByWorktree['wt-1']).toHaveLength(2) + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBe(record) + }) +}) diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts index 1af3521559f..51f9217979d 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.test.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.test.ts @@ -781,7 +781,7 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) - it('clears interrupted manual records without launching a tab', () => { + it('resumes interrupted manual records that no preserved pane can own', () => { const record = makeRecord({ origin: 'worktree-sleep', interrupted: true }) useAppStore.setState({ tabsByWorktree: { 'wt-1': [] }, @@ -790,9 +790,10 @@ describe('resumeSleepingAgentSessionsForWorktree', () => { const launched = resumeSleepingAgentSessionsForWorktree('wt-1') - expect(launched).toBe(0) - expect(useAppStore.getState().tabsByWorktree['wt-1']).toEqual([]) - expect(useAppStore.getState().sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() + const state = useAppStore.getState() + expect(launched).toBe(1) + expect(state.tabsByWorktree['wt-1']?.[0]?.launchAgent).toBe('claude') + expect(state.sleepingAgentSessionsByPaneKey[record.paneKey]).toBeUndefined() }) it('clears hydrated interrupted worktree-sleep records without launching a tab', () => { diff --git a/src/renderer/src/lib/resume-sleeping-agent-session.ts b/src/renderer/src/lib/resume-sleeping-agent-session.ts index 198c5d71925..103c5c2261f 100644 --- a/src/renderer/src/lib/resume-sleeping-agent-session.ts +++ b/src/renderer/src/lib/resume-sleeping-agent-session.ts @@ -130,10 +130,9 @@ function activeOrQueuedResumeClaimsProviderSession( return false } +// Why: an interrupted turn is still resumable — `claude --resume` reopens the transcript at the +// prompt — so discarding those records only stranded the session across wake and restart. function isInvalidWorktreeActivationRecord(record: SleepingAgentSessionRecord): boolean { - if (record.interrupted === true) { - return true - } if (!record.origin && record.state === 'done') { return true } diff --git a/src/renderer/src/lib/runtime-workspace-file-route.test.ts b/src/renderer/src/lib/runtime-workspace-file-route.test.ts new file mode 100644 index 00000000000..74f36c81cbb --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + findRuntimeWorkspaceFileRoute, + findWorkspaceFileRoute +} from './runtime-workspace-file-route' + +function state(): AppState { + return { + settings: { activeRuntimeEnvironmentId: 'runtime-a' }, + repos: [], + detectedWorktreesByRepo: {}, + worktreesByRepo: { + repoA: [ + { + id: 'repo-a::/srv/repo-a', + repoId: 'repo-a', + path: '/srv/repo-a', + runtimeOwnerEnvironmentId: 'runtime-a' + } + ], + repoB: [ + { + id: 'repo-b::/srv/repo-b', + repoId: 'repo-b', + path: '/srv/repo-b', + runtimeOwnerEnvironmentId: 'runtime-a' + }, + { + id: 'repo-b::/srv/repo-b/docs', + repoId: 'repo-b', + path: '/srv/repo-b/docs', + runtimeOwnerEnvironmentId: 'runtime-b' + } + ] + }, + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-a', + folderPath: '/srv/notes', + connectionId: null + } + ], + projectGroups: [ + { + id: 'group-a', + connectionId: null, + executionHostId: 'runtime:runtime-a' + } + ] + } as unknown as AppState +} + +describe('findRuntimeWorkspaceFileRoute', () => { + it('routes a file through its sibling worktree on the same runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/src/index.ts')).toEqual( + { + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'src/index.ts', + executionHostId: 'runtime:runtime-a' + } + ) + }) + + it('does not use a more specific workspace owned by another runtime', () => { + expect( + findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b/docs/guide.md') + ).toEqual({ + worktreeId: 'repo-b::/srv/repo-b', + relativePath: 'docs/guide.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('includes folder workspaces owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes/todo.md')).toEqual({ + worktreeId: 'folder:notes', + relativePath: 'todo.md', + executionHostId: 'runtime:runtime-a' + }) + }) + + it('rejects paths outside every workspace owned by the runtime', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/etc/passwd')).toBeNull() + }) + + it('does not route workspace directories as editor files', () => { + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/repo-b')).toBeNull() + expect(findRuntimeWorkspaceFileRoute(state(), 'runtime-a', '/srv/notes')).toBeNull() + }) + + it('routes direct SSH siblings only on the exact SSH host', () => { + const sshState = state() + sshState.repos = [ + { id: 'repo-a', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-b', connectionId: 'ssh-1', executionHostId: 'ssh:ssh-1' }, + { id: 'repo-c', connectionId: 'ssh-2', executionHostId: 'ssh:ssh-2' } + ] as never + sshState.worktreesByRepo = { + repoA: [{ id: 'a', repoId: 'repo-a', path: '/srv/a', hostId: 'ssh:ssh-1' }], + repoB: [{ id: 'b', repoId: 'repo-b', path: '/srv/b', hostId: 'ssh:ssh-1' }], + repoC: [{ id: 'c', repoId: 'repo-c', path: '/srv/b/nested', hostId: 'ssh:ssh-2' }] + } as never + sshState.folderWorkspaces = [] + + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-1', '/srv/b/nested/file.ts')).toEqual({ + worktreeId: 'b', + relativePath: 'nested/file.ts', + executionHostId: 'ssh:ssh-1' + }) + expect(findWorkspaceFileRoute(sshState, 'ssh:ssh-2', '/srv/a/file.ts')).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/runtime-workspace-file-route.ts b/src/renderer/src/lib/runtime-workspace-file-route.ts new file mode 100644 index 00000000000..af30b70fefd --- /dev/null +++ b/src/renderer/src/lib/runtime-workspace-file-route.ts @@ -0,0 +1,76 @@ +import { + parseExecutionHostId, + toRuntimeExecutionHostId, + type ExecutionHostId +} from '../../../shared/execution-host' +import { findRuntimeWorkspaceFileOwner } from '../../../shared/runtime-workspace-file-owner' +import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import type { AppState } from '@/store/types' +import { getIndexedAllWorktrees } from '@/store/worktree-repo-index' +import { + getExecutionHostIdForWorktree, + getExplicitRuntimeEnvironmentIdForWorktree +} from './worktree-runtime-owner' +import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-route' + +export type RuntimeWorkspaceFileRoute = { + worktreeId: string + relativePath: string + executionHostId: ExecutionHostId +} + +function workspaceMatchesExecutionHost( + state: AppState, + workspaceId: string, + executionHostId: ExecutionHostId +): boolean { + const parsedHost = parseExecutionHostId(executionHostId) + if (parsedHost?.kind === 'runtime') { + return ( + getExplicitRuntimeEnvironmentIdForWorktree(state, workspaceId) === parsedHost.environmentId + ) + } + const explicit = resolveExplicitWorktreeOperationRouteResult(state, workspaceId) + if (explicit.kind === 'resolved') { + return explicit.route.executionHostId === executionHostId + } + return ( + executionHostId === 'local' && getExecutionHostIdForWorktree(state, workspaceId) === 'local' + ) +} + +export function findWorkspaceFileRoute( + state: AppState, + executionHostId: ExecutionHostId, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const roots = getIndexedAllWorktrees(state.worktreesByRepo).flatMap((worktree) => + workspaceMatchesExecutionHost(state, worktree.id, executionHostId) + ? [{ workspaceId: worktree.id, rootPath: worktree.path, executionHostId }] + : [] + ) + for (const workspace of state.folderWorkspaces) { + const workspaceId = folderWorkspaceKey(workspace.id) + if (workspaceMatchesExecutionHost(state, workspaceId, executionHostId)) { + roots.push({ workspaceId, rootPath: workspace.folderPath, executionHostId }) + } + } + + const owner = findRuntimeWorkspaceFileOwner(roots, absolutePath, executionHostId) + return owner && owner.relativePath !== '' + ? { worktreeId: owner.workspaceId, relativePath: owner.relativePath, executionHostId } + : null +} + +export function findRuntimeWorkspaceFileRoute( + state: AppState, + runtimeEnvironmentId: string, + absolutePath: string +): RuntimeWorkspaceFileRoute | null { + const ownerId = runtimeEnvironmentId.trim() + if (!ownerId) { + return null + } + const executionHostId = toRuntimeExecutionHostId(ownerId) + return findWorkspaceFileRoute(state, executionHostId, absolutePath) +} diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 6daa375c8ed..aeb14f9c73e 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -51,6 +51,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState hideDefaultBranchWorkspace: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, hideAutomationGeneratedWorkspaces: false, filterRepoIds: [], collapsedGroups: [], diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts index 67b82dbbb11..40281b4a244 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.test.ts @@ -250,6 +250,100 @@ describe('wakeSleepingAgentsForWorktreeInBackground', () => { expect(Object.keys(sleepingRecords)).toEqual(['tab-0:leaf-1']) }) + it('does not background-mount finished panes captured by an explicit workspace sleep', () => { + sleepingRecords = Object.fromEntries( + Array.from({ length: 12 }, (_, index) => { + const tabId = `tab-${index}` + return [ + `${tabId}:leaf-1`, + { + worktreeId: 'wt-1', + paneKey: `${tabId}:leaf-1`, + tabId, + capturedAt: index, + updatedAt: index, + providerSession: { key: 'session_id', id: `session-${index}` }, + restoreOnTabOpenOnly: true + } as never + ] + }) + ) + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: a phone opening a slept workspace must not respawn every agent the + // user just slept; each pane cold-restores when its own tab is opened. + expect(rec.events).toEqual(['wake:wt-1']) + expect(rec.mountDetails).toEqual([]) + expect(Object.keys(sleepingRecords)).toHaveLength(12) + }) + + it('still background-mounts hibernated panes alongside lazily restored slept panes', () => { + sleepingRecords = { + 'tab-slept:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-slept:leaf-1', + tabId: 'tab-slept', + capturedAt: 1, + updatedAt: 1, + providerSession: { key: 'session_id', id: 'session-slept' }, + restoreOnTabOpenOnly: true + } as never, + 'tab-hibernated:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-hibernated:leaf-1', + tabId: 'tab-hibernated', + capturedAt: 2, + updatedAt: 2, + providerSession: { key: 'session_id', id: 'session-hibernated' } + } as never + } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-hibernated'] }]) + }) + + it('mounts the hibernated record when a lazily restored slept pane shares its claim', () => { + sleepingRecords = { + 'tab-slept:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-slept:leaf-1', + tabId: 'tab-slept', + capturedAt: 1, + updatedAt: 1, + providerSession: { key: 'session_id', id: 'session-shared' }, + restoreOnTabOpenOnly: true + } as never, + 'tab-hibernated:leaf-1': { + worktreeId: 'wt-1', + paneKey: 'tab-hibernated:leaf-1', + tabId: 'tab-hibernated', + capturedAt: 2, + updatedAt: 2, + providerSession: { key: 'session_id', id: 'session-shared' } + } as never + } + isPassiveSpy.mockReturnValue(true) + const rec = recordEvents() + + wakeSleepingAgentsForWorktreeInBackground('wt-1') + + rec.stop() + // Why: canonicalization deletes same-claim duplicates, so a lazy record must be filtered + // out before it can win the claim and strand the hibernated pane it deletes. + expect(rec.mountDetails).toEqual([{ worktreeId: 'wt-1', tabIds: ['tab-hibernated'] }]) + expect(clearSleepingAgentSessionsByPaneKey).not.toHaveBeenCalledWith(['tab-hibernated:leaf-1']) + expect(sleepingRecords).toHaveProperty('tab-hibernated:leaf-1') + expect(sleepingRecords).toHaveProperty('tab-slept:leaf-1') + }) + it('background-mounts the tabs the suppressed resume launches for non-passive records', () => { sleepingRecords = { k1: { worktreeId: 'wt-1', paneKey: 'tab-a:leaf-1', tabId: 'tab-a' } } isPassiveSpy.mockReturnValue(false) diff --git a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts index d2543fc1a8e..eb3a59c4122 100644 --- a/src/renderer/src/lib/wake-sleeping-agents-in-background.ts +++ b/src/renderer/src/lib/wake-sleeping-agents-in-background.ts @@ -152,7 +152,9 @@ function getCanonicalPassiveWakeRecords( * (b) background-mount the tabs holding passive hibernated records that are * NOT currently mounted (post-restart / evicted) so they take the * fresh-connect cold-restore path. The mount is targeted by tabId so one - * sleeping pane does not permanently mount every saved tab; + * sleeping pane does not permanently mount every saved tab, and skips + * `restoreOnTabOpenOnly` records so an explicit workspace sleep is not + * undone wholesale by a phone opening the workspace; * (c) resume the non-passive record classes (manual sleep of a still-working * agent, `origin: 'quit'`) with navigation suppressed, skipping the * claims from (a); @@ -184,7 +186,14 @@ export function wakeSleepingAgentsForWorktreeInBackground(worktreeId: string): v // recovered by step (c) into a fresh tab, mounted in step (d). const passiveTabIds = new Set() let hasUntargetablePassiveRecord = false - for (const record of getCanonicalPassiveWakeRecords(worktreeRecords, wokenClaimKeys)) { + // Why: a workspace the user explicitly slept must not respawn every finished agent because a + // phone opened it. Those panes cold-restore `--resume` when their own tab is opened, which is + // also what the desktop does (#11598). Filtering before canonicalization keeps a lazy record + // from winning — and deleting — the claim of a hibernated record that does need mounting. + const backgroundWakeRecords = worktreeRecords.filter( + (record) => record.restoreOnTabOpenOnly !== true + ) + for (const record of getCanonicalPassiveWakeRecords(backgroundWakeRecords, wokenClaimKeys)) { const tabId = getSleepingRecordTabId(record) if (tabId) { passiveTabIds.add(tabId) diff --git a/src/renderer/src/lib/worktree-operation-route.ts b/src/renderer/src/lib/worktree-operation-route.ts index fadd7284172..75608bc0c5a 100644 --- a/src/renderer/src/lib/worktree-operation-route.ts +++ b/src/renderer/src/lib/worktree-operation-route.ts @@ -7,7 +7,11 @@ import { import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { addRoute, resolveExactWorktreeRoute, routeForOwner } from './worktree-owner-route' -import { resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' +import { + findIndexedDetectedWorktrees, + hasIndexedDetectedWorktree, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' import { findFolderWorkspaceOwner, getExecutionHostIdForFolderWorkspace, @@ -51,11 +55,7 @@ function ownerRecordsOnHost( executionHostId: ExecutionHostId ): WorktreeOperationOwnerRecord[] { const owners: WorktreeOperationOwnerRecord[] = [] - const catalogs = [ - ...Object.values(state.worktreesByRepo ?? {}), - ...Object.values(state.detectedWorktreesByRepo ?? {}).map((result) => result.worktrees) - ] - for (const worktrees of catalogs) { + for (const worktrees of Object.values(state.worktreesByRepo ?? {})) { for (const worktree of worktrees) { if ( worktree.id === worktreeId && @@ -65,6 +65,11 @@ function ownerRecordsOnHost( } } } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + if (parseExecutionHostId(worktree.hostId)?.id === executionHostId) { + owners.push(worktree) + } + } return owners } @@ -135,12 +140,8 @@ export function resolveWorktreeOperationRouteResult( } const hasKnownWorktree = - Object.values(state.worktreesByRepo ?? {}).some((worktrees) => - worktrees.some((worktree) => worktree.id === worktreeId) - ) || - Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + resolveIndexedWorktreeOwner(state.worktreesByRepo, worktreeId).kind !== 'missing' || + hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) const repoId = getRepoIdFromWorktreeId(worktreeId) const hasKnownRepo = state.repos?.some((repo) => repo.id === repoId) === true if (!hasKnownWorktree && !hasKnownRepo) { @@ -216,18 +217,14 @@ export function resolveExplicitWorktreeOperationRouteResult( addRoute(exactRoutes, resolution.route) } } - for (const result of Object.values(state.detectedWorktreesByRepo ?? {})) { - for (const worktree of result.worktrees) { - if (worktree.id === worktreeId) { - exactRepoIds.add(worktree.repoId) - const resolution = resolveExactWorktreeRoute(state, worktree) - if (resolution.kind === 'ambiguous') { - return resolution - } - if (resolution.kind === 'resolved') { - addRoute(exactRoutes, resolution.route) - } - } + for (const worktree of findIndexedDetectedWorktrees(state.detectedWorktreesByRepo, worktreeId)) { + exactRepoIds.add(worktree.repoId) + const resolution = resolveExactWorktreeRoute(state, worktree) + if (resolution.kind === 'ambiguous') { + return resolution + } + if (resolution.kind === 'resolved') { + addRoute(exactRoutes, resolution.route) } } if (exactRoutes.size > 0) { diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts b/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts new file mode 100644 index 00000000000..7b574cd27f5 --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.detected-perf.test.ts @@ -0,0 +1,200 @@ +import { writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { hasIndexedDetectedWorktree } from './worktree-runtime-owner-index' +import { getExplicitRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' + +const REPO_COUNT = 20 +const WORKTREES_PER_REPO = 100 +const LOOKUPS_PER_SWEEP = 200 +const SWEEPS = 40 + +type OwnerRecord = { + id: string + repoId: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +} +type DetectedByRepo = Record + +// The pre-index expression, kept as the "before" leg of the benchmark. +function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean { + return Object.values(detectedWorktreesByRepo ?? {}).some((result) => + result.worktrees.some((worktree) => worktree.id === id) + ) +} + +function buildDetectedCatalog(generation: number, counter?: { reads: number }): DetectedByRepo { + const catalog: DetectedByRepo = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + const worktrees: OwnerRecord[] = [] + for (let index = 0; index < WORKTREES_PER_REPO; index += 1) { + const id = `${repoId}::detected-${index}-gen-${generation}` + const record: OwnerRecord = { id, repoId, hostId: 'ssh:target-a' } + if (counter) { + Object.defineProperty(record, 'id', { + get: () => { + counter.reads += 1 + return id + }, + enumerable: true + }) + } + worktrees.push(record) + } + catalog[repoId] = { worktrees } + } + return catalog +} + +const publishedRepos = Array.from({ length: REPO_COUNT }, (_unused, index) => ({ + id: `repo-${index}`, + connectionId: `target-${index}` +})) +const publishedWorktreesByRepo: Record = Object.fromEntries( + publishedRepos.map((repo) => [ + repo.id, + Array.from({ length: WORKTREES_PER_REPO }, (_unused, index) => ({ + id: `${repo.id}::worktree-${index}`, + repoId: repo.id, + hostId: 'ssh:target-a' as const + })) + ]) +) + +// Only the detected catalog changes identity: that is what isolates the collection under test. +function buildOwnerState(detectedWorktreesByRepo: DetectedByRepo): WorktreeRuntimeOwnerState { + return { + repos: publishedRepos, + worktreesByRepo: publishedWorktreesByRepo, + detectedWorktreesByRepo, + activeWorktreeId: null, + activeWorkspaceExecutionHostId: null, + runtimeEnvironments: [] + } +} + +// Probe ids are published but never detected — the SSH-pane case, and the walk's worst case. +const PROBE_IDS = Array.from( + { length: LOOKUPS_PER_SWEEP }, + (_unused, index) => `repo-${index % REPO_COUNT}::worktree-${index % WORKTREES_PER_REPO}` +) + +const WARMUP_SWEEPS = 5 +const warmCatalog = buildDetectedCatalog(-1) + +// Each fresh leg needs its own never-indexed catalogs; a shared pool would leave the index warm +// for whichever leg runs second and silently erase the rebuild it is supposed to measure. +let catalogGeneration = 0 +function freshCatalogPool(): DetectedByRepo[] { + return Array.from({ length: SWEEPS + WARMUP_SWEEPS }, () => + buildDetectedCatalog((catalogGeneration += 1)) + ) +} + +function measureSweeps(run: (sweep: number) => void): number { + for (let warmup = 0; warmup < WARMUP_SWEEPS; warmup += 1) { + run(warmup) + } + const started = performance.now() + for (let sweep = 0; sweep < SWEEPS; sweep += 1) { + run(WARMUP_SWEEPS + sweep) + } + return (performance.now() - started) / SWEEPS +} + +describe('detected worktree index performance', () => { + it('answers repeated owner lookups without rescanning the detected catalog', () => { + const counter = { reads: 0 } + const catalog = buildDetectedCatalog(0, counter) + const state = buildOwnerState(catalog) + + getExplicitRuntimeEnvironmentIdForWorktree(state, 'repo-0::warm-the-index') + counter.reads = 0 + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(state, probeId) + } + const indexedReads = counter.reads + + counter.reads = 0 + for (const probeId of PROBE_IDS) { + walkHasDetected(catalog, probeId) + } + const walkedReads = counter.reads + + expect(indexedReads).toBe(0) + expect(walkedReads).toBeGreaterThan(100_000) + }) + + it('records lookup-path timing without making wall clock a CI gate', () => { + const walkPool = freshCatalogPool() + const walkMs = measureSweeps((sweep) => { + const catalog = walkPool[sweep]! + for (const probeId of PROBE_IDS) { + walkHasDetected(catalog, probeId) + } + }) + const walkWarmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + walkHasDetected(warmCatalog, probeId) + } + }) + // Unrelated store writes leave the detected catalog identical, so the index stays warm. + const warmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + hasIndexedDetectedWorktree(warmCatalog, probeId) + } + }) + // A landed worktree scan republishes the catalog: one rebuild amortized over the sweep. + const freshPool = freshCatalogPool() + const freshMs = measureSweeps((sweep) => { + const catalog = freshPool[sweep]! + for (const probeId of PROBE_IDS) { + hasIndexedDetectedWorktree(catalog, probeId) + } + }) + + const statePool = freshCatalogPool().map(buildOwnerState) + const warmState = buildOwnerState(warmCatalog) + const explicitOwnerFreshMs = measureSweeps((sweep) => { + const state = statePool[sweep]! + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(state, probeId) + } + }) + const explicitOwnerWarmMs = measureSweeps(() => { + for (const probeId of PROBE_IDS) { + getExplicitRuntimeEnvironmentIdForWorktree(warmState, probeId) + } + }) + + const round = (value: number): number => Number(value.toFixed(4)) + const report = { + entries: REPO_COUNT * WORKTREES_PER_REPO, + lookupsPerSweep: LOOKUPS_PER_SWEEP, + sweeps: SWEEPS, + lookupPath: { + walkFreshMsPerSweep: round(walkMs), + walkWarmMsPerSweep: round(walkWarmMs), + indexedWarmMsPerSweep: round(warmMs), + indexedFreshMsPerSweep: round(freshMs), + warmSpeedup: round(walkWarmMs / warmMs), + freshSpeedup: round(walkMs / freshMs) + }, + getExplicitRuntimeEnvironmentIdForWorktree: { + warmMsPerSweep: round(explicitOwnerWarmMs), + freshMsPerSweep: round(explicitOwnerFreshMs) + } + } + writeFileSync( + join(tmpdir(), 'orca-detected-worktree-index-bench.json'), + `${JSON.stringify(report, null, 2)}\n` + ) + + // Why: shared-runner timing is noisy; the read-count test above is the deterministic CI gate. + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts b/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts new file mode 100644 index 00000000000..2191a083e2a --- /dev/null +++ b/src/renderer/src/lib/worktree-runtime-owner-index.detected.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import { + findIndexedDetectedWorktrees, + hasIndexedDetectedWorktree, + resolveIndexedWorktreeOwner +} from './worktree-runtime-owner-index' + +type OwnerRecord = { + id: string + repoId: string + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string +} +type DetectedByRepo = Record + +// The pre-index expressions this module replaced, kept verbatim as parity oracles. +function walkHasDetected(detectedWorktreesByRepo: DetectedByRepo | undefined, id: string): boolean { + return Object.values(detectedWorktreesByRepo ?? {}).some((result) => + result.worktrees.some((worktree) => worktree.id === id) + ) +} + +function walkDetectedMatches( + detectedWorktreesByRepo: DetectedByRepo | undefined, + id: string +): OwnerRecord[] { + const matches: OwnerRecord[] = [] + for (const result of Object.values(detectedWorktreesByRepo ?? {})) { + for (const worktree of result.worktrees) { + if (worktree.id === id) { + matches.push(worktree) + } + } + } + return matches +} + +function walkHasKnown( + worktreesByRepo: Record | undefined, + id: string +): boolean { + return Object.values(worktreesByRepo ?? {}).some((worktrees) => + worktrees.some((worktree) => worktree.id === id) + ) +} + +// Deterministic LCG so a parity failure reproduces from the printed case index. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state * 1664525 + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const HOST_IDS: (ExecutionHostId | undefined)[] = [ + undefined, + 'local', + 'ssh:target-a', + 'ssh:target-b', + 'runtime:hub-a' +] + +function buildCase(random: () => number): { + detectedWorktreesByRepo: DetectedByRepo | undefined + worktreesByRepo: Record + probeIds: string[] +} { + const shape = random() + if (shape < 0.05) { + return { detectedWorktreesByRepo: undefined, worktreesByRepo: {}, probeIds: ['repo-0::absent'] } + } + if (shape < 0.1) { + return { detectedWorktreesByRepo: {}, worktreesByRepo: {}, probeIds: ['repo-0::absent'] } + } + const repoCount = 1 + Math.floor(random() * 6) + const detectedWorktreesByRepo: DetectedByRepo = {} + const worktreesByRepo: Record = {} + const knownIds: string[] = [] + for (let repoIndex = 0; repoIndex < repoCount; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + // Empty arrays are a real store shape: a scan that found nothing still publishes its bucket. + const worktreeCount = random() < 0.25 ? 0 : 1 + Math.floor(random() * 5) + const detected: OwnerRecord[] = [] + const published: OwnerRecord[] = [] + for (let index = 0; index < worktreeCount; index += 1) { + // Duplicate ids across repos are how rival publications collide, so allow a shared pool. + const id = random() < 0.3 ? `shared::worktree-${index}` : `${repoId}::worktree-${index}` + const record: OwnerRecord = { + id, + repoId, + hostId: HOST_IDS[Math.floor(random() * HOST_IDS.length)], + ...(random() < 0.3 ? { runtimeOwnerEnvironmentId: `hub-${Math.floor(random() * 3)}` } : {}) + } + knownIds.push(id) + if (random() < 0.8) { + detected.push(record) + } + if (random() < 0.5) { + published.push(record) + } + } + detectedWorktreesByRepo[repoId] = { worktrees: detected } + worktreesByRepo[repoId] = published + } + const probeIds = [ + knownIds[0] ?? 'repo-0::absent', + knownIds.at(-1) ?? 'repo-0::absent', + knownIds[Math.floor(random() * Math.max(knownIds.length, 1))] ?? 'repo-0::absent', + 'shared::worktree-0', + 'never-published::worktree' + ] + return { detectedWorktreesByRepo, worktreesByRepo, probeIds } +} + +describe('detected worktree index', () => { + it('matches the pre-index catalog walk across randomized store shapes', () => { + const random = makeRandom(0x5eed) + for (let caseIndex = 0; caseIndex < 240; caseIndex += 1) { + const { detectedWorktreesByRepo, worktreesByRepo, probeIds } = buildCase(random) + for (const probeId of probeIds) { + expect( + { + case: caseIndex, + probeId, + has: hasIndexedDetectedWorktree(detectedWorktreesByRepo, probeId) + }, + `case ${caseIndex} / ${probeId}` + ).toEqual({ + case: caseIndex, + probeId, + has: walkHasDetected(detectedWorktreesByRepo, probeId) + }) + expect( + findIndexedDetectedWorktrees(detectedWorktreesByRepo, probeId), + `case ${caseIndex} / ${probeId}` + ).toEqual(walkDetectedMatches(detectedWorktreesByRepo, probeId)) + expect( + resolveIndexedWorktreeOwner(worktreesByRepo, probeId).kind !== 'missing', + `case ${caseIndex} / ${probeId}` + ).toBe(walkHasKnown(worktreesByRepo, probeId)) + } + } + }) + + it('returns rival publications in catalog order with identity preserved', () => { + const first = { id: 'shared', repoId: 'repo-a', hostId: 'ssh:target-a' as const } + const second = { id: 'shared', repoId: 'repo-b', hostId: 'runtime:hub-a' as const } + const detectedWorktreesByRepo = { + 'repo-a': { worktrees: [first] }, + 'repo-b': { worktrees: [second] } + } + + const matches = findIndexedDetectedWorktrees(detectedWorktreesByRepo, 'shared') + expect(matches).toHaveLength(2) + expect(matches[0]).toBe(first) + expect(matches[1]).toBe(second) + }) + + it('treats undefined, empty, and empty-bucket catalogs as unpublished', () => { + expect(hasIndexedDetectedWorktree(undefined, 'repo::wt')).toBe(false) + expect(findIndexedDetectedWorktrees(undefined, 'repo::wt')).toEqual([]) + expect(hasIndexedDetectedWorktree({}, 'repo::wt')).toBe(false) + expect(hasIndexedDetectedWorktree({ repo: { worktrees: [] } }, 'repo::wt')).toBe(false) + }) + + it('re-indexes a new catalog identity instead of serving stale hits', () => { + const worktree = { id: 'repo::wt', repoId: 'repo' } + const before = { repo: { worktrees: [worktree] } } + expect(hasIndexedDetectedWorktree(before, 'repo::wt')).toBe(true) + + const afterRemoval = { repo: { worktrees: [] } } + expect(hasIndexedDetectedWorktree(afterRemoval, 'repo::wt')).toBe(false) + + const afterReadd = { repo: { worktrees: [worktree] } } + expect(findIndexedDetectedWorktrees(afterReadd, 'repo::wt')).toEqual([worktree]) + // The prior identity keeps its own cached answer; nothing bleeds between snapshots. + expect(hasIndexedDetectedWorktree(afterRemoval, 'repo::wt')).toBe(false) + expect(hasIndexedDetectedWorktree(before, 'repo::wt')).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/worktree-runtime-owner-index.ts b/src/renderer/src/lib/worktree-runtime-owner-index.ts index 25b1af47888..8fc642cfddb 100644 --- a/src/renderer/src/lib/worktree-runtime-owner-index.ts +++ b/src/renderer/src/lib/worktree-runtime-owner-index.ts @@ -8,6 +8,7 @@ import { } from '../../../shared/execution-host' type WorktreeOwnerRecord = Pick +type DetectedWorktreeListing = { worktrees: readonly WorktreeOwnerRecord[] } type RepoOwnerRecord = Pick type FolderWorkspaceOwnerRecord = Pick< FolderWorkspace, @@ -33,6 +34,12 @@ const projectGroupOwnerIndexCache = new WeakMap< readonly ProjectGroupOwnerRecord[], ReadonlyMap >() +const detectedWorktreeIndexCache = new WeakMap< + Record, + ReadonlyMap +>() + +const NO_DETECTED_WORKTREES: readonly WorktreeOwnerRecord[] = [] type IndexedFolderWorkspaceOwnerResolution = | { kind: 'resolved'; owner: FolderWorkspaceOwnerRecord } @@ -210,6 +217,43 @@ export function resolveIndexedWorktreeOwner( return index.get(worktreeId) ?? { kind: 'missing' } } +/** + * Every detected publication of `worktreeId`, in catalog order. Rival repos may publish the same + * id, so callers that fail closed on conflicts need all matches rather than one resolved owner. + */ +export function findIndexedDetectedWorktrees( + detectedWorktreesByRepo: Record | undefined, + worktreeId: string +): readonly WorktreeOwnerRecord[] { + if (!detectedWorktreesByRepo) { + return NO_DETECTED_WORKTREES + } + let index = detectedWorktreeIndexCache.get(detectedWorktreesByRepo) + if (!index) { + const next = new Map() + for (const listing of Object.values(detectedWorktreesByRepo)) { + for (const worktree of listing.worktrees) { + const matches = next.get(worktree.id) + if (matches) { + matches.push(worktree) + } else { + next.set(worktree.id, [worktree]) + } + } + } + index = next + detectedWorktreeIndexCache.set(detectedWorktreesByRepo, index) + } + return index.get(worktreeId) ?? NO_DETECTED_WORKTREES +} + +export function hasIndexedDetectedWorktree( + detectedWorktreesByRepo: Record | undefined, + worktreeId: string +): boolean { + return findIndexedDetectedWorktrees(detectedWorktreesByRepo, worktreeId).length > 0 +} + export function findIndexedRepoOwner( repos: readonly RepoOwnerRecord[] | undefined, repoId: string diff --git a/src/renderer/src/lib/worktree-runtime-owner.ts b/src/renderer/src/lib/worktree-runtime-owner.ts index e322e834d43..999ba347c6d 100644 --- a/src/renderer/src/lib/worktree-runtime-owner.ts +++ b/src/renderer/src/lib/worktree-runtime-owner.ts @@ -7,6 +7,7 @@ import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers' import { findIndexedRepoOwner as findRepoRecord, findIndexedWorktreeOwner as findWorktreeRecord, + hasIndexedDetectedWorktree, resolveIndexedRepoOwner, resolveIndexedWorktreeOwner } from './worktree-runtime-owner-index' @@ -79,9 +80,7 @@ export function getRuntimeEnvironmentIdForWorktree( const owner = indexedOwner.owner const projectedRuntimeOwner = getProjectedRuntimeOwnerEnvironmentId(owner) const parsedHost = parseExecutionHostId(owner.hostId) - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (!hasDetectedOwner && (projectedRuntimeOwner || parsedHost)) { return ( projectedRuntimeOwner || (parsedHost?.kind === 'runtime' ? parsedHost.environmentId : null) @@ -125,9 +124,7 @@ export function getExplicitRuntimeEnvironmentIdForWorktree( workspaceScope.folderWorkspaceId ) } - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (hasDetectedOwner) { // Why: detected-only rows are selectable before the primary catalog lands; use the same // ambiguity-aware explicit provenance as filesystem and terminal operations. @@ -177,9 +174,7 @@ export function getExecutionHostIdForWorktree( if (workspaceScope?.type === 'folder') { return getExecutionHostIdForFolderWorkspace(state, workspaceScope.folderWorkspaceId) } - const hasDetectedOwner = Object.values(state.detectedWorktreesByRepo ?? {}).some((result) => - result.worktrees.some((worktree) => worktree.id === worktreeId) - ) + const hasDetectedOwner = hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId) if (hasDetectedOwner) { const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId) if (resolution.kind === 'resolved') { diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.test.ts b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts new file mode 100644 index 00000000000..10ecd88884c --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.test.ts @@ -0,0 +1,184 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PRCheckDetail } from '../../../shared/types' +import { loadGitLabJobLogDetails } from './gitlab-job-trace-client' + +const callRuntimeRpc = vi.hoisted(() => vi.fn()) +vi.mock('./runtime-rpc-client', () => ({ callRuntimeRpc })) + +const jobTrace = vi.fn() + +const gitLabCheck: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: null, + gitlabJobId: 42 +} + +beforeEach(() => { + callRuntimeRpc.mockReset() + jobTrace.mockReset() + ;(globalThis as { window?: unknown }).window = { api: { gl: { jobTrace } } } +}) + +describe('loadGitLabJobLogDetails', () => { + it('returns null for a check that is not a GitLab job, touching no transport', async () => { + const githubCheck: PRCheckDetail = { + name: 'verify', + status: 'completed', + conclusion: 'failure', + url: 'https://github.com/acme/orca/runs/1', + checkRunId: 7 + } + + await expect( + loadGitLabJobLogDetails({ repoPath: '/repo', settings: null, check: githubCheck }) + ).resolves.toBeNull() + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + }) + + it('asks main for a bounded excerpt over local IPC', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'ERROR: Job failed: exit code 1' }) + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + + expect(jobTrace).toHaveBeenCalledWith({ + repoPath: '/repo', + repoId: 'repo-1', + jobId: 42, + projectRef: null, + logExcerpt: true + }) + expect(details?.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + }) + + it('targets the MR project so a fork pipeline trace is not looked up in the wrong project', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: 'boom' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck, + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + + expect(jobTrace).toHaveBeenCalledWith( + expect.objectContaining({ + projectRef: { host: 'gitlab.example.test', path: 'contributor/orca' } + }) + ) + }) + + it('does not request a trace for a manual job that cannot have one', async () => { + const manualCheck: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: manualCheck + }) + + // Why: GitLab 404s on a job with no trace, so skip the round trip entirely. + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).not.toHaveBeenCalled() + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + // #7732 round 1: a job canceled before it started is `completed`/`cancelled`, so it is + // fetched; main answers with an empty trace and the row must show that, not an error. + it('explains an empty trace for a job canceled before it produced output', async () => { + jobTrace.mockResolvedValue({ ok: true, trace: '' }) + const canceledCheck: PRCheckDetail = { + name: 'unit', + status: 'completed', + conclusion: 'cancelled', + url: null, + gitlabJobId: 5 + } + + const details = await loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: canceledCheck + }) + + expect(details?.jobs).toEqual([]) + expect(details?.summary).toBe('No log is available for this GitLab job.') + }) + + it('routes to the owning runtime environment and still requests the excerpt', async () => { + callRuntimeRpc.mockResolvedValue({ ok: true, trace: 'remote failure' }) + + await loadGitLabJobLogDetails({ + repoPath: '/repo', + repoId: 'repo-1', + settings: { activeRuntimeEnvironmentId: 'env-9' }, + check: gitLabCheck + }) + + expect(jobTrace).not.toHaveBeenCalled() + expect(callRuntimeRpc).toHaveBeenCalledWith( + { kind: 'environment', environmentId: 'env-9' }, + 'gitlab.jobTrace', + // Why: bounding in main keeps the response under the 1 MB transport frame cap. + { repo: 'repo-1', jobId: 42, projectRef: undefined, logExcerpt: true }, + { timeoutMs: 30_000 } + ) + }) + + it('surfaces the GitLab error verbatim so the panel does not claim "no details"', async () => { + jobTrace.mockResolvedValue({ ok: false, error: '403 Forbidden' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('403 Forbidden') + }) + + it('gives up on a local IPC call that never settles, matching the remote timeout', async () => { + vi.useFakeTimers() + try { + jobTrace.mockReturnValue(new Promise(() => {})) + + const pending = loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + const assertion = expect(pending).rejects.toThrow('Timed out loading the GitLab job log.') + await vi.advanceTimersByTimeAsync(30_000) + await assertion + } finally { + vi.useRealTimers() + } + }) + + it('falls back to a translated message when GitLab reports a blank error', async () => { + jobTrace.mockResolvedValue({ ok: false, error: ' ' }) + + await expect( + loadGitLabJobLogDetails({ + repoPath: '/repo', + settings: { activeRuntimeEnvironmentId: null }, + check: gitLabCheck + }) + ).rejects.toThrow('Failed to load the GitLab job log.') + }) +}) diff --git a/src/renderer/src/runtime/gitlab-job-trace-client.ts b/src/renderer/src/runtime/gitlab-job-trace-client.ts new file mode 100644 index 00000000000..929c8581c1f --- /dev/null +++ b/src/renderer/src/runtime/gitlab-job-trace-client.ts @@ -0,0 +1,105 @@ +import { translate } from '@/i18n/i18n' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from '../../../shared/gitlab-job-trace-check-details' +import type { GitLabJobTraceResult, GitLabProjectRef } from '../../../shared/gitlab-types' +import type { PRCheckDetail, PRCheckRunDetails } from '../../../shared/types' +import { getActiveRuntimeTarget } from './runtime-client-target' +import { callRuntimeRpc } from './runtime-rpc-client' + +const JOB_TRACE_TIMEOUT_MS = 30_000 + +/** + * Load a GitLab pipeline job log as provider-neutral check details. + * + * Returns null for checks that are not GitLab jobs so callers can fall through to + * their existing provider path. Throws on a GitLab-reported failure so the caller's + * error handling surfaces the message instead of showing "no details available". + */ +export async function loadGitLabJobLogDetails(args: { + repoPath: string + repoId?: string + settings: Parameters[0] + check: PRCheckDetail + /** Fork/cross-project MRs run their pipeline outside the repo's own project. */ + projectRef?: GitLabProjectRef | null +}): Promise { + const jobId = args.check.gitlabJobId + if (!jobId) { + return null + } + if (!gitLabJobCanHaveTrace(args.check)) { + return gitLabJobTraceToCheckRunDetails(args.check, '', emptyTraceStrings()) + } + const target = getActiveRuntimeTarget(args.settings) + const result = + target.kind === 'environment' + ? await callRuntimeRpc( + target, + 'gitlab.jobTrace', + { + repo: args.repoId ?? args.repoPath, + jobId, + projectRef: args.projectRef ?? undefined, + logExcerpt: true + }, + { timeoutMs: JOB_TRACE_TIMEOUT_MS } + ) + : await withJobTraceTimeout( + window.api.gl.jobTrace({ + repoPath: args.repoPath, + repoId: args.repoId, + jobId, + projectRef: args.projectRef ?? null, + logExcerpt: true + }) + ) + if (!result?.ok) { + throw new Error( + result?.error?.trim() || + translate( + 'auto.runtime.gitlabJobTraceClient.loadFailed', + 'Failed to load the GitLab job log.' + ) + ) + } + return gitLabJobTraceToCheckRunDetails(args.check, result.trace, emptyTraceStrings()) +} + +/** + * Bound the local IPC call the way `callRuntimeRpc` bounds the remote one. + * + * `glab` runs without a subprocess timeout in main, so an unreachable GitLab host + * would otherwise leave the expanded Checks row spinning forever. + */ +function withJobTraceTimeout(pending: Promise): Promise { + let timer: ReturnType | undefined + return Promise.race([ + pending, + new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + translate( + 'auto.runtime.gitlabJobTraceClient.timedOut', + 'Timed out loading the GitLab job log.' + ) + ) + ) + }, JOB_TRACE_TIMEOUT_MS) + }) + ]).finally(() => { + clearTimeout(timer) + }) +} + +// Called per-request, not at module scope, so the active locale applies. +function emptyTraceStrings(): { emptyTrace: string } { + return { + emptyTrace: translate( + 'auto.runtime.gitlabJobTraceClient.emptyTrace', + 'No log is available for this GitLab job.' + ) + } +} diff --git a/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts b/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts new file mode 100644 index 00000000000..1c840aecbac --- /dev/null +++ b/src/renderer/src/runtime/remote-server-install-failure-probe.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { readRemoteServerInstallFailure } from './remote-server-install-failure-probe' + +const support = { + installMode: 'supervised-headless-serve', + automatic: true, + reason: 'available' +} as const + +const install: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' +} + +function runtime(runtimeId = 'runtime-old'): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 2, + liveLeafCount: 1, + capabilities: ['updater.remote-control.v1'], + appVersion: '1.4.0', + remoteUpdateSupport: support + } +} + +function snapshot( + status: RemoteServerUpdaterSnapshot['status'], + runtimeId = 'runtime-old' +): RemoteServerUpdaterSnapshot { + return { appVersion: '1.4.0', runtimeId, support, status } +} + +describe('readRemoteServerInstallFailure', () => { + it('returns the message the original process is publishing', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBe('pkexec must be setuid root') + }) + + it('bounds the status call by the deadline the caller passes', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await readRemoteServerInstallFailure( + 'server-1', + { getUpdaterStatus }, + install, + runtime(), + 4_000 + ) + + // Without this the call inherits the RPC client's 15s default and can outlast the reconnect budget. + expect(getUpdaterStatus).toHaveBeenCalledWith('server-1', 4_000) + }) + + it('ignores an error published by a different process', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }, 'runtime-new') + ) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBeNull() + }) + + it('ignores a snapshot that is not an error', async () => { + const getUpdaterStatus = vi.fn(async () => snapshot({ state: 'downloaded', version: '1.5.0' })) + + await expect( + readRemoteServerInstallFailure('server-1', { getUpdaterStatus }, install, runtime()) + ).resolves.toBeNull() + }) + + it('costs no round trip once the replacement runtime answers', async () => { + const getUpdaterStatus = vi.fn(async () => + snapshot({ state: 'error', message: 'pkexec must be setuid root' }) + ) + + await expect( + readRemoteServerInstallFailure( + 'server-1', + { getUpdaterStatus }, + install, + runtime('runtime-new') + ) + ).resolves.toBeNull() + expect(getUpdaterStatus).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/remote-server-install-failure-probe.ts b/src/renderer/src/runtime/remote-server-install-failure-probe.ts new file mode 100644 index 00000000000..7f37fd38453 --- /dev/null +++ b/src/renderer/src/runtime/remote-server-install-failure-probe.ts @@ -0,0 +1,36 @@ +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' + +type InstallFailureProbeTransport = { + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise +} + +/** + * The install RPC is accepted before the installer runs, so a server that failed to restart keeps + * publishing the reason through `updater.getStatus`. Reading it turns a three-minute reconnect + * timeout into the actual cause. Both runtime-id guards matter: a snapshot from a process that is + * not the one we asked to install describes some other attempt. + */ +export async function readRemoteServerInstallFailure( + environmentId: string, + transport: InstallFailureProbeTransport, + install: RemoteServerUpdateInstallResult, + runtime: RuntimeStatus, + timeoutMs?: number +): Promise { + if (runtime.runtimeId !== install.runtimeId) { + return null + } + // Why: without an explicit deadline this inherits the RPC client's 15s default and can outlast the caller's own wait. + const snapshot = await transport.getUpdaterStatus(environmentId, timeoutMs) + if (snapshot.runtimeId !== install.runtimeId || snapshot.status.state !== 'error') { + return null + } + return snapshot.status.message +} diff --git a/src/renderer/src/runtime/remote-server-restart-wait.test.ts b/src/renderer/src/runtime/remote-server-restart-wait.test.ts new file mode 100644 index 00000000000..76567556d92 --- /dev/null +++ b/src/renderer/src/runtime/remote-server-restart-wait.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { waitForReplacementRuntime } from './remote-server-restart-wait' + +const support = { + installMode: 'supervised-headless-serve', + automatic: true, + reason: 'available' +} as const + +const install: RemoteServerUpdateInstallResult = { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' +} + +const idleSnapshot: RemoteServerUpdaterSnapshot = { + appVersion: '1.4.0', + runtimeId: 'runtime-old', + support, + status: { state: 'downloaded', version: '1.5.0' } +} + +function runtime(version: string, runtimeId: string): RuntimeStatus { + return { + runtimeId, + rendererGraphEpoch: 0, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 2, + liveLeafCount: 1, + capabilities: ['updater.remote-control.v1'], + appVersion: version, + remoteUpdateSupport: support + } +} + +describe('waitForReplacementRuntime', () => { + it('returns the replacement process once it answers on the target version', async () => { + let clock = 0 + let ticks = 0 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus: async () => idleSnapshot, + getRuntimeStatus: async () => { + ticks += 1 + return ticks > 1 ? runtime('1.5.0', 'runtime-new') : runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs: 1_000, pollIntervalMs: 10 } + ) + ).resolves.toMatchObject({ runtimeId: 'runtime-new', appVersion: '1.5.0' }) + }) + + it('never lets a poll RPC outlive the reconnect deadline', async () => { + let clock = 0 + const reconnectTimeoutMs = 25_000 + const pollIntervalMs = 500 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus: async () => idleSnapshot, + // A wedged SSH link burns the whole allowance it is given before rejecting. + getRuntimeStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + throw new Error('runtime status timed out') + } + }, + install, + { reconnectTimeoutMs, pollIntervalMs } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // A tick allowed its full 10s past the deadline would push this well beyond the budget. + expect(clock).toBeLessThanOrEqual(reconnectTimeoutMs) + }) + + it('keeps the total wait inside the budget when the status RPC eats it before the probe', async () => { + let clock = 0 + const reconnectTimeoutMs = 20_000 + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + // Both RPCs burn everything they are handed, so a stale budget doubles the tick. + getUpdaterStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + return idleSnapshot + }, + getRuntimeStatus: async (_environmentId, timeoutMs) => { + clock += timeoutMs ?? 0 + return runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs, pollIntervalMs: 500 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + expect(clock).toBeLessThanOrEqual(reconnectTimeoutMs) + }) + + it('charges the failure probe only the budget left after the status RPC', async () => { + let clock = 0 + const getUpdaterStatus = vi.fn(async () => idleSnapshot) + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus, + getRuntimeStatus: async () => { + clock += 2_000 + return runtime('1.4.0', 'runtime-old') + } + }, + install, + { reconnectTimeoutMs: 9_000, pollIntervalMs: 1_000 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // First probe runs on the second tick: it starts at 3s, the status RPC spends 2s more, + // so 4s of the 9s budget is left — not the 6s the tick began with. + expect(getUpdaterStatus).toHaveBeenNthCalledWith(1, 'server-1', 4_000) + }) + + it('bounds the failure probe by the same remaining budget', async () => { + let clock = 0 + const getUpdaterStatus = vi.fn(async () => idleSnapshot) + + await expect( + waitForReplacementRuntime( + 'server-1', + { + now: () => clock, + wait: async (milliseconds) => { + clock += milliseconds + }, + getUpdaterStatus, + getRuntimeStatus: async () => runtime('1.4.0', 'runtime-old') + }, + install, + { reconnectTimeoutMs: 4_000, pollIntervalMs: 1_000 } + ) + ).rejects.toThrow('remote_update_reconnect_timeout') + + // First tick is the probe's warm-up; the second runs with 3s of the 4s budget left. + expect(getUpdaterStatus).toHaveBeenCalledWith('server-1', 3_000) + }) +}) diff --git a/src/renderer/src/runtime/remote-server-restart-wait.ts b/src/renderer/src/runtime/remote-server-restart-wait.ts new file mode 100644 index 00000000000..d78b2170f1d --- /dev/null +++ b/src/renderer/src/runtime/remote-server-restart-wait.ts @@ -0,0 +1,81 @@ +import { hasReachedAppVersion } from '../../../shared/app-version' +import type { + RemoteServerUpdateInstallResult, + RemoteServerUpdaterSnapshot +} from '../../../shared/remote-server-update' +import type { RuntimeStatus } from '../../../shared/runtime-types' +import { readRemoteServerInstallFailure } from './remote-server-install-failure-probe' + +type RestartWaitTransport = { + getRuntimeStatus: (environmentId: string, timeoutMs?: number) => Promise + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise + wait: (milliseconds: number) => Promise + now?: () => number +} + +type RestartWaitTiming = { + reconnectTimeoutMs: number + pollIntervalMs: number +} + +/** Per-call ceiling for the two poll RPCs; each tick also clamps to the time left. */ +const RESTART_WAIT_RPC_TIMEOUT_MS = 10_000 + +/** + * Waits for the replacement process to answer on the target version and returns its runtime status. + * Throws the server's own install-failure text as soon as the *original* process publishes one — + * without that, a server that could not restart is indistinguishable from a slow one until the + * reconnect budget runs out, and the user is told the wrong thing. + */ +export async function waitForReplacementRuntime( + environmentId: string, + transport: RestartWaitTransport, + install: RemoteServerUpdateInstallResult, + timing: RestartWaitTiming +): Promise { + const now = transport.now ?? Date.now + const deadline = now() + timing.reconnectTimeoutMs + // Why: the install RPC returns before the installer fires, so an error on the first tick belongs to an earlier attempt. + let probed = false + while (now() < deadline) { + // Why: an RPC allowed to outlive the deadline drags the whole wait past the budget the caller set. + const rpcTimeoutMs = Math.min(RESTART_WAIT_RPC_TIMEOUT_MS, deadline - now()) + let installFailure: string | null = null + try { + const status = await transport.getRuntimeStatus(environmentId, rpcTimeoutMs) + const version = status.appVersion?.trim() ?? '' + const reachedTarget = hasReachedAppVersion(version, install.targetVersion) + if (status.runtimeId !== install.runtimeId && reachedTarget) { + return status + } + // Why: the status RPC already spent part of rpcTimeoutMs, so the probe re-reads what is left + // rather than starting a second full budget of its own. + const probeTimeoutMs = Math.min(RESTART_WAIT_RPC_TIMEOUT_MS, deadline - now()) + installFailure = + probed && probeTimeoutMs > 0 + ? await readRemoteServerInstallFailure( + environmentId, + transport, + install, + status, + probeTimeoutMs + ) + : null + probed = true + } catch { + // A refused connection is expected while the server process is being replaced. + } + // Why: thrown outside the try so the loop's own catch cannot swallow the reason we came for. + if (installFailure !== null) { + throw new Error(installFailure) + } + const waitMs = Math.min(timing.pollIntervalMs, deadline - now()) + if (waitMs > 0) { + await transport.wait(waitMs) + } + } + throw new Error('remote_update_reconnect_timeout') +} diff --git a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts index 824fdf6fbdd..66cb646f711 100644 --- a/src/renderer/src/runtime/remote-server-update-coordinator.test.ts +++ b/src/renderer/src/runtime/remote-server-update-coordinator.test.ts @@ -44,6 +44,10 @@ function status(version: string, runtimeId = 'runtime-old', automatic = true): R } } +// The main process's own copy once quitAndInstall fails; it must survive to the client verbatim. +const INSTALL_FAILURE = + "Could not start the update installer. Orca remains open. (Command failed: pkexec dpkg -i '/home/u/.cache/orca-updater/pending/orca-ide_1.5.0_amd64.deb' pkexec must be setuid root)" + const availableSnapshot: RemoteServerUpdaterSnapshot = { appVersion: '1.4.0', runtimeId: 'runtime-old', @@ -275,6 +279,91 @@ describe('remote server update execution', () => { }) }) + it('repeats the install failure a still-running server is publishing', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + let installed = false + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + getUpdaterStatus: async () => + installed + ? { ...availableSnapshot, status: { state: 'error', message: INSTALL_FAILURE } } + : (snapshots.shift() ?? availableSnapshot), + install: async (): Promise => { + installed = true + return { + accepted: true, + fromVersion: '1.4.0', + targetVersion: '1.5.0', + runtimeId: 'runtime-old' + } + }, + // The server never restarts: same runtimeId, same version, still perfectly reachable. + getRuntimeStatus: async () => status('1.4.0', 'runtime-old') + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 60, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ phase: 'failed', error: INSTALL_FAILURE }) + }) + + it('ignores an updater error that predates the install request', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + let reconnectTicks = 0 + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + // A background check that errored inside the 100ms before the installer fires. + getUpdaterStatus: async () => + snapshots.shift() ?? { + ...availableSnapshot, + status: { state: 'error', message: 'a background check failed' } + }, + getRuntimeStatus: async () => { + reconnectTicks += 1 + return reconnectTicks > 1 + ? status('1.5.0', 'runtime-new') + : status('1.4.0', 'runtime-old') + } + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 60, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ phase: 'updated', currentVersion: '1.5.0' }) + }) + + it('keeps waiting when the failure probe itself cannot be reached', async () => { + const snapshots = [ + availableSnapshot, + { ...availableSnapshot, status: { state: 'downloaded', version: '1.5.0' } } + ] satisfies RemoteServerUpdaterSnapshot[] + const result = await runRemoteServerUpdate( + availableEntry(), + transport({ + getUpdaterStatus: async () => { + const next = snapshots.shift() + if (!next) { + throw new Error('connection refused') + } + return next + }, + getRuntimeStatus: async () => status('1.4.0', 'runtime-old') + }), + () => undefined, + { timing: { operationTimeoutMs: 10, reconnectTimeoutMs: 6, pollIntervalMs: 1 } } + ) + expect(result).toMatchObject({ + phase: 'failed', + error: 'The server did not reconnect on the updated version.' + }) + }) + it('turns a capability race into manual update guidance', async () => { const result = await runRemoteServerUpdate( availableEntry(), diff --git a/src/renderer/src/runtime/remote-server-update-coordinator.ts b/src/renderer/src/runtime/remote-server-update-coordinator.ts index 77b1b6f2cbd..196699b6eed 100644 --- a/src/renderer/src/runtime/remote-server-update-coordinator.ts +++ b/src/renderer/src/runtime/remote-server-update-coordinator.ts @@ -1,5 +1,6 @@ import { compareAppVersions, + hasReachedAppVersion, isPerfPrereleaseAppVersion, isPrereleaseAppVersion, isValidAppVersion @@ -13,6 +14,7 @@ import type { import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import type { RuntimeStatus } from '../../../shared/runtime-types' import type { UpdateCheckOptions } from '../../../shared/types' +import { waitForReplacementRuntime } from './remote-server-restart-wait' import { remoteServerUpdateErrorMessage } from './remote-server-update-errors' import { pollRemoteServerUpdater } from './remote-server-updater-polling' @@ -45,7 +47,10 @@ export type RemoteServerUpdateEntry = { export type RemoteServerUpdateTransport = { getRuntimeStatus: (environmentId: string, timeoutMs?: number) => Promise - getUpdaterStatus: (environmentId: string) => Promise + getUpdaterStatus: ( + environmentId: string, + timeoutMs?: number + ) => Promise check: ( environmentId: string, options: UpdateCheckOptions @@ -213,12 +218,7 @@ export async function runRemoteServerUpdate( if (available.status.state === 'not-available') { const status = await transport.getRuntimeStatus(entry.environmentId, 10_000) const currentVersion = status.appVersion?.trim() ?? '' - const reachedTarget = - entry.targetVersion !== null && - isValidAppVersion(currentVersion) && - isValidAppVersion(entry.targetVersion) && - compareAppVersions(currentVersion, entry.targetVersion) >= 0 - if (!reachedTarget) { + if (!hasReachedAppVersion(currentVersion, entry.targetVersion)) { throw new Error('remote_update_requested_version_unavailable') } next = { @@ -267,34 +267,22 @@ export async function runRemoteServerUpdate( } onProgress(next) - const now = transport.now ?? Date.now - const reconnectDeadline = now() + timing.reconnectTimeoutMs - while (now() < reconnectDeadline) { - try { - const status = await transport.getRuntimeStatus(entry.environmentId, 10_000) - const version = status.appVersion?.trim() ?? '' - const reachedTarget = - isValidAppVersion(version) && - isValidAppVersion(install.targetVersion) && - compareAppVersions(version, install.targetVersion) >= 0 - if (status.runtimeId !== install.runtimeId && reachedTarget) { - next = { - ...next, - phase: 'updated', - currentVersion: version, - runtimeId: status.runtimeId, - liveTabCount: status.liveTabCount, - liveLeafCount: status.liveLeafCount - } - onProgress(next) - return next - } - } catch { - // A refused connection is expected while the server process is being replaced. - } - await transport.wait(timing.pollIntervalMs) + const replacement = await waitForReplacementRuntime( + entry.environmentId, + transport, + install, + timing + ) + next = { + ...next, + phase: 'updated', + currentVersion: replacement.appVersion?.trim() ?? '', + runtimeId: replacement.runtimeId, + liveTabCount: replacement.liveTabCount, + liveLeafCount: replacement.liveLeafCount } - throw new Error('remote_update_reconnect_timeout') + onProgress(next) + return next } catch (error) { next = { ...next, diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.test.ts b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts new file mode 100644 index 00000000000..4225c0d9f06 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-in-flight.test.ts @@ -0,0 +1,150 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + beginSshConnect, + endSshConnect, + isSshConnectInFlight, + resetSshConnectInFlightForTests, + subscribeSshConnectInFlight, + trackSshConnect +} from './ssh-connect-in-flight' +import { SSH_RECONNECT_UI_TIMEOUT_MS, withUiConnectTimeout } from './ssh-connect-ui-timeout' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('ssh connect in-flight registry', () => { + beforeEach(() => { + resetSshConnectInFlightForTests() + }) + + it('tracks connects per target, so one host dialing does not disable another', () => { + beginSshConnect('ssh-a') + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + expect(isSshConnectInFlight('ssh-b')).toBe(false) + }) + + it('clears the target when the connect settles', () => { + beginSshConnect('ssh-a') + endSshConnect('ssh-a') + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + it('notifies subscribers on both edges', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + beginSshConnect('ssh-a') + endSshConnect('ssh-a') + + expect(listener).toHaveBeenCalledTimes(2) + }) + + // Why: every surface renders from one registry entry, so a duplicate begin must not + // emit again — and the paired end must not clear the flag while a connect is still live. + it('ignores a duplicate begin without re-notifying', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + beginSshConnect('ssh-a') + beginSshConnect('ssh-a') + + expect(listener).toHaveBeenCalledTimes(1) + expect(isSshConnectInFlight('ssh-a')).toBe(true) + }) + + // Why: a connect handler's finally can run for a target it never began (early return + // paths), and a spurious notify would re-render every subscribed card. + it('ignores an end for a target that was never in flight', () => { + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + + endSshConnect('ssh-a') + + expect(listener).not.toHaveBeenCalled() + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + describe('trackSshConnect', () => { + it('holds the lock while the request is pending and clears it on resolve', async () => { + let settle: (value: string) => void = () => {} + const request = trackSshConnect( + 'ssh-a', + new Promise((resolve) => { + settle = resolve + }) + ) + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + + settle('connected') + await request + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + it('clears the lock when the request rejects', async () => { + const request = trackSshConnect('ssh-a', Promise.reject(new Error('Passphrase rejected'))) + + await expect(request).rejects.toThrow('Passphrase rejected') + await Promise.resolve() + + expect(isSshConnectInFlight('ssh-a')).toBe(false) + }) + + // The regression this guards: the UI wait is a race that rejects at the timeout while the + // backend keeps dialing. Releasing the lock there would let the next click on any surface + // for this host fire a second connect — a second credential prompt on a gated target. + it('keeps the lock after a UI timeout abandons the wait, so a second dial is suppressed', async () => { + vi.useFakeTimers() + try { + const request = trackSshConnect('ssh-a', new Promise(() => {})) + const uiWait = withUiConnectTimeout(request, SSH_RECONNECT_UI_TIMEOUT_MS) + const settled = uiWait.catch((error: Error) => error.message) + + await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS) + + expect(await settled).toContain('timed out') + expect(isSshConnectInFlight('ssh-a')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // A tracked request outlives whatever dropped its lock — a reset between specs, or an + // explicit end. Its release must not unlock a later connect on the same target. + it('ignores a settle whose lock was already cleared, so a later connect keeps its lock', async () => { + let settleAbandoned: (value: string) => void = () => {} + const abandoned = trackSshConnect( + 'ssh-a', + new Promise((resolve) => { + settleAbandoned = resolve + }) + ) + resetSshConnectInFlightForTests() + + const listener = vi.fn() + subscribeSshConnectInFlight(listener) + beginSshConnect('ssh-a') + + settleAbandoned('connected') + await abandoned + await Promise.resolve() + + expect(isSshConnectInFlight('ssh-a')).toBe(true) + expect(listener).toHaveBeenCalledTimes(1) + }) + }) + + it('stops notifying after unsubscribe, so unmounted sidebar rows do not leak', () => { + const listener = vi.fn() + const unsubscribe = subscribeSshConnectInFlight(listener) + + unsubscribe() + beginSshConnect('ssh-a') + + expect(listener).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-in-flight.ts b/src/renderer/src/ssh/ssh-connect-in-flight.ts new file mode 100644 index 00000000000..4a06f078c1f --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-in-flight.ts @@ -0,0 +1,90 @@ +import { useCallback, useSyncExternalStore } from 'react' + +// Why: the store status lags a user click by one IPC hop (main broadcasts 'connecting' +// after ssh.connect starts), and every workspace card on a host shares one connection. +// Component-local state would let two surfaces — or N cards on the same host — each fire +// a connect, which on a passphrase-gated target means N credential prompts. +// Keyed by target, valued by the id of the acquisition holding the lock: a release can then +// clear only its own acquisition, so a settle that lands after the lock was dropped (a test +// reset, an explicit end) cannot take down a newer connect's lock. +const inFlightLockIds = new Map() +let lastLockId = 0 +const listeners = new Set<() => void>() + +function emit(): void { + for (const listener of listeners) { + listener() + } +} + +export function subscribeSshConnectInFlight(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +function acquire(targetId: string): number { + const held = inFlightLockIds.get(targetId) + if (held !== undefined) { + return held + } + lastLockId += 1 + inFlightLockIds.set(targetId, lastLockId) + emit() + return lastLockId +} + +function releaseOwned(targetId: string, lockId: number): void { + if (inFlightLockIds.get(targetId) !== lockId) { + return + } + inFlightLockIds.delete(targetId) + emit() +} + +export function beginSshConnect(targetId: string): void { + acquire(targetId) +} + +export function endSshConnect(targetId: string): void { + if (!inFlightLockIds.delete(targetId)) { + return + } + emit() +} + +export function isSshConnectInFlight(targetId: string): boolean { + return inFlightLockIds.has(targetId) +} + +/** + * Holds the lock for `request`'s whole life and returns it unchanged. + * Why: UI callers race the request against a display timeout, but the backend keeps dialing + * past it — releasing when the caller stops waiting would let the next click raise a second + * credential prompt. Also survives unmount, unlike a `finally` in a component handler. + */ +export function trackSshConnect(targetId: string, request: Promise): Promise { + const lockId = acquire(targetId) + const release = (): void => { + releaseOwned(targetId, lockId) + } + // Two-arg then, not finally: a derived rejected promise here would go unhandled. + void request.then(release, release) + return request +} + +export function useSshConnectInFlight(targetId: string): boolean { + const getSnapshot = useCallback(() => inFlightLockIds.has(targetId), [targetId]) + return useSyncExternalStore(subscribeSshConnectInFlight, getSnapshot, getSnapshot) +} + +/** + * Test-only: the registry is module state, so specs must reset it between cases. + * A request tracked before the reset stays pending; its release is scoped to the cleared + * lock id, so it settles into a no-op rather than unlocking the next case's target. + */ +export function resetSshConnectInFlightForTests(): void { + inFlightLockIds.clear() + emit() +} diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts new file mode 100644 index 00000000000..0d984e94d6c --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SSH_CONNECT_UI_TIMEOUT_MS, + SSH_RECONNECT_UI_TIMEOUT_MS, + withUiConnectTimeout +} from './ssh-connect-ui-timeout' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +describe('withUiConnectTimeout', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('passes a resolved connect state straight through', async () => { + await expect(withUiConnectTimeout(Promise.resolve('connected'))).resolves.toBe('connected') + }) + + it('propagates the connect rejection rather than the timeout message', async () => { + const failing = withUiConnectTimeout(Promise.reject(new Error('Passphrase rejected'))) + + await expect(failing).rejects.toThrow('Passphrase rejected') + }) + + // Why: ssh.connect has no timeout of its own, so a stalled backend would otherwise leave + // the control disabled with a spinner forever. + it('rejects a stalled connect once the UI budget elapses', async () => { + const stalled = withUiConnectTimeout(new Promise(() => {})) + const assertion = expect(stalled).rejects.toThrow(/Connection timed out/) + + await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS) + + await assertion + }) + + // Why: an interactive passphrase alone allows 120s in main, so the reconnect surfaces pass + // a longer budget; the default composer budget must not fence them. + it('honours a caller-supplied budget instead of the composer default', async () => { + const stalled = withUiConnectTimeout(new Promise(() => {}), SSH_RECONNECT_UI_TIMEOUT_MS) + const assertion = expect(stalled).rejects.toThrow(/Connection timed out/) + + await vi.advanceTimersByTimeAsync(SSH_CONNECT_UI_TIMEOUT_MS) + expect(vi.getTimerCount()).toBe(1) + + await vi.advanceTimersByTimeAsync(SSH_RECONNECT_UI_TIMEOUT_MS - SSH_CONNECT_UI_TIMEOUT_MS) + await assertion + }) + + it('gives a reconnect more time than main allows for an interactive passphrase', () => { + expect(SSH_RECONNECT_UI_TIMEOUT_MS).toBeGreaterThan(120_000) + }) + + it('clears the timer when the connect settles first, leaving no pending work', async () => { + await withUiConnectTimeout(Promise.resolve('connected')) + + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-ui-timeout.ts b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts new file mode 100644 index 00000000000..ab917ff35aa --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-ui-timeout.ts @@ -0,0 +1,40 @@ +import { translate } from '@/i18n/i18n' + +// Why: ssh.connect has no built-in timeout, so bound how long a UI control waits on it — +// a stalled backend connect must not leave a disabled spinner stuck forever. The backend +// keeps going regardless. +export const SSH_CONNECT_UI_TIMEOUT_MS = 20_000 + +// Why: a reconnect can legitimately outlast the composer budget — an interactive passphrase +// prompt alone allows 120s (main's CREDENTIAL_TIMEOUT_MS) before the 30s connect even starts, +// and a first relay deploy uploads a binary. A shorter fence would toast "timed out" and run +// the stale-metadata resync against a host that is about to connect fine. +export const SSH_RECONNECT_UI_TIMEOUT_MS = 180_000 + +export async function withUiConnectTimeout( + promise: Promise, + timeoutMs: number = SSH_CONNECT_UI_TIMEOUT_MS +): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + // Key kept from the original NewWorkspaceComposerCard home so existing + // translations survive the move. + translate( + 'auto.components.NewWorkspaceComposerCard.connectTimedOut', + 'Connection timed out. It may still be connecting in the background.' + ) + ) + ) + }, timeoutMs) + }) + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer) { + clearTimeout(timer) + } + } +} diff --git a/src/renderer/src/ssh/ssh-connect-verb.test.ts b/src/renderer/src/ssh/ssh-connect-verb.test.ts new file mode 100644 index 00000000000..b3fcdcdcdc3 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-verb.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from 'vitest' +import { sshConnectingLabel, sshConnectVerb } from './ssh-connect-verb' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +// Why: the sidebar card control, terminal overlay, host-header menu, and status-bar row all +// read this table. It is the only thing keeping them from naming one click three ways. +describe('sshConnectVerb', () => { + it('names an authentication failure a reconnect', () => { + expect(sshConnectVerb('auth-failed')).toBe('Reconnect') + }) + + it.each(['error', 'reconnection-failed'] as const)('names %s a retry', (status) => { + expect(sshConnectVerb(status)).toBe('Retry') + }) + + it.each(['disconnected', 'connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)( + 'falls back to Connect for %s', + (status) => { + expect(sshConnectVerb(status)).toBe('Connect') + } + ) + + it.each([null, undefined])('falls back to Connect for %s', (status) => { + expect(sshConnectVerb(status)).toBe('Connect') + }) +}) + +describe('sshConnectingLabel', () => { + it('uses the single-character ellipsis, matching the rest of the catalog', () => { + expect(sshConnectingLabel()).toBe('Connecting…') + }) +}) diff --git a/src/renderer/src/ssh/ssh-connect-verb.ts b/src/renderer/src/ssh/ssh-connect-verb.ts new file mode 100644 index 00000000000..d5dd98a06e8 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connect-verb.ts @@ -0,0 +1,27 @@ +import { translate } from '@/i18n/i18n' +import type { SshConnectionStatus } from '../../../shared/ssh-types' + +// Why: the sidebar card, the terminal overlay, the host-header menu, and the status-bar row +// can all be on screen at once. One vocabulary here stops them describing the same click +// three different ways. +export function sshConnectVerb(status: SshConnectionStatus | null | undefined): string { + switch (status) { + case 'auth-failed': + return translate('auto.ssh.sshConnectVerb.reconnect', 'Reconnect') + case 'error': + case 'reconnection-failed': + return translate('auto.ssh.sshConnectVerb.retry', 'Retry') + case null: + case undefined: + case 'connected': + case 'connecting': + case 'deploying-relay': + case 'disconnected': + case 'reconnecting': + return translate('auto.ssh.sshConnectVerb.connect', 'Connect') + } +} + +export function sshConnectingLabel(): string { + return translate('auto.ssh.sshConnectVerb.connecting', 'Connecting…') +} diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.test.ts b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts new file mode 100644 index 00000000000..05aa06eb26d --- /dev/null +++ b/src/renderer/src/ssh/ssh-connection-recoverability.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { SshConnectionStatus } from '../../../shared/ssh-types' +import { canConnectSshStatus, isConnectingSshStatus } from './ssh-connection-recoverability' + +// Union growth is caught by the typechecker (the modules use total Records), not here. +// These cases pin the classification itself, which four call sites now depend on. +const ALL_STATUSES: SshConnectionStatus[] = [ + 'disconnected', + 'connecting', + 'auth-failed', + 'deploying-relay', + 'connected', + 'reconnecting', + 'reconnection-failed', + 'error' +] + +describe('isConnectingSshStatus', () => { + it.each(['connecting', 'deploying-relay', 'reconnecting'] as const)( + 'treats %s as an attempt already under way', + (status) => { + expect(isConnectingSshStatus(status)).toBe(true) + } + ) + + it.each(['disconnected', 'auth-failed', 'connected', 'reconnection-failed', 'error'] as const)( + 'does not treat %s as connecting', + (status) => { + expect(isConnectingSshStatus(status)).toBe(false) + } + ) +}) + +describe('canConnectSshStatus', () => { + it.each(['disconnected', 'auth-failed', 'reconnection-failed', 'error'] as const)( + 'offers a user-driven connect for %s', + (status) => { + expect(canConnectSshStatus(status)).toBe(true) + } + ) + + it.each(['connecting', 'deploying-relay', 'reconnecting', 'connected'] as const)( + 'withholds connect for %s', + (status) => { + expect(canConnectSshStatus(status)).toBe(false) + } + ) +}) + +// Why: runtime-owned targets deliberately yield a null status. Both predicates must read +// that as "nothing to offer" so the card falls through to the passive host glyph. +describe('absent status', () => { + it.each([null, undefined])('classifies %s as neither connecting nor connectable', (status) => { + expect(isConnectingSshStatus(status)).toBe(false) + expect(canConnectSshStatus(status)).toBe(false) + }) +}) + +describe('the two predicates together', () => { + it('never claims a status is both connecting and connectable', () => { + for (const status of ALL_STATUSES) { + expect(isConnectingSshStatus(status) && canConnectSshStatus(status)).toBe(false) + } + }) + + it('leaves only connected outside both sets, so no state renders a dead control', () => { + const unclassified = ALL_STATUSES.filter( + (status) => !isConnectingSshStatus(status) && !canConnectSshStatus(status) + ) + expect(unclassified).toEqual(['connected']) + }) +}) diff --git a/src/renderer/src/ssh/ssh-connection-recoverability.ts b/src/renderer/src/ssh/ssh-connection-recoverability.ts new file mode 100644 index 00000000000..83e87b75216 --- /dev/null +++ b/src/renderer/src/ssh/ssh-connection-recoverability.ts @@ -0,0 +1,36 @@ +import type { SshConnectionStatus } from '../../../shared/ssh-types' + +// Why: a total Record makes a new SshConnectionStatus member a typecheck failure. An +// array + .includes() would silently classify it as "not recoverable", leaving its cards +// with a dead glyph and no way to reconnect. +const CONNECTING_BY_STATUS: Record = { + disconnected: false, + connecting: true, + 'auth-failed': false, + 'deploying-relay': true, + connected: false, + reconnecting: true, + 'reconnection-failed': false, + error: false +} + +const CAN_CONNECT_BY_STATUS: Record = { + disconnected: true, + connecting: false, + 'auth-failed': true, + 'deploying-relay': false, + connected: false, + reconnecting: false, + 'reconnection-failed': true, + error: true +} + +/** Relay deployment and reconnect are host-driven transients: no user action helps yet. */ +export function isConnectingSshStatus(status: SshConnectionStatus | null | undefined): boolean { + return status ? CONNECTING_BY_STATUS[status] : false +} + +/** Failure states a user-initiated connect can recover from. */ +export function canConnectSshStatus(status: SshConnectionStatus | null | undefined): boolean { + return status ? CAN_CONNECT_BY_STATUS[status] : false +} diff --git a/src/renderer/src/store/pinned-tab-close-guard.ts b/src/renderer/src/store/pinned-tab-close-guard.ts index 9603abd60f1..75a7db4fcc8 100644 --- a/src/renderer/src/store/pinned-tab-close-guard.ts +++ b/src/renderer/src/store/pinned-tab-close-guard.ts @@ -23,6 +23,13 @@ export function isUnifiedTabPinned(state: AppState, worktreeId: string, tabId: s ) } +/** Whether a pinned close will actually raise the pin dialog. Callers that let the pin + * prompt supersede another confirmation must know this: with the setting off the pin + * has nothing to say, so it must not swallow the other prompt (#10142). */ +export function shouldConfirmPinnedTabClose(state: AppState): boolean { + return state.settings?.confirmClosePinnedTab ?? true +} + /** Routes a pinned-tab close attempt through the confirmation dialog when the * setting is on. Non-pinned tabs (and pinned tabs when the setting is off) * close immediately. Keeping every close path behind this single helper is why @@ -40,8 +47,7 @@ export function guardPinnedTabClose(params: { } const state = useAppStore.getState() - const shouldConfirm = state.settings?.confirmClosePinnedTab ?? true - if (!shouldConfirm) { + if (!shouldConfirmPinnedTabClose(state)) { onClose() return } diff --git a/src/renderer/src/store/running-terminal-close-confirm.test.ts b/src/renderer/src/store/running-terminal-close-confirm.test.ts new file mode 100644 index 00000000000..b597923a148 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.test.ts @@ -0,0 +1,226 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useRunningTerminalCloseConfirmStore } from './running-terminal-close-confirm' + +// The store rejects a second action within 350 ms of revealing a queued request, so these +// tests drive a clock rather than letting wall time decide whether an action lands. +let clock = 1_000 + +function advancePastGuard(): void { + clock += 400 +} + +function drainRequests(): void { + const store = useRunningTerminalCloseConfirmStore.getState() + while (useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm) { + advancePastGuard() + store.dismissRunningTerminalClose() + } +} + +function request(terminalTabId: string, onConfirm: () => void = vi.fn(), onCancel?: () => void) { + return { + terminalTabId, + tabLabel: terminalTabId, + copyKind: 'command' as const, + onConfirm, + ...(onCancel ? { onCancel } : {}) + } +} + +describe('running terminal close confirmation store', () => { + beforeEach(() => { + // Monotonic across tests: the store is a singleton, so winding the clock back would + // leave a previous test's guard deadline in the future and block every action. + clock += 10_000 + vi.spyOn(Date, 'now').mockImplementation(() => clock) + }) + + afterEach(() => { + drainRequests() + vi.mocked(Date.now).mockRestore() + }) + + it('shows the first request and queues the next one', () => { + const first = vi.fn() + const second = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-1') + + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + it('shows one prompt for a repeat request but still resolves both closes', () => { + const first = vi.fn() + const duplicate = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-1', duplicate)) + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('cancels both callers when a folded repeat request is dismissed', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const firstCancel = vi.fn() + const duplicateCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), firstCancel)) + store.requestRunningTerminalCloseConfirm(request('tab-1', vi.fn(), duplicateCancel)) + store.dismissRunningTerminalClose() + + expect(firstCancel).toHaveBeenCalledTimes(1) + expect(duplicateCancel).toHaveBeenCalledTimes(1) + }) + + it('folds a repeat request into the one already waiting in the queue', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const queued = vi.fn() + const duplicate = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.requestRunningTerminalCloseConfirm(request('tab-2', duplicate)) + + store.confirmRunningTerminalClose() + advancePastGuard() + store.confirmRunningTerminalClose() + + expect(queued).toHaveBeenCalledTimes(1) + expect(duplicate).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('confirms every pending request once the user opts out of the prompt', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const visible = vi.fn() + const queued = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', visible)) + store.requestRunningTerminalCloseConfirm(request('tab-2', queued)) + store.confirmAllRunningTerminalCloses() + + expect(visible).toHaveBeenCalledTimes(1) + expect(queued).toHaveBeenCalledTimes(1) + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('runs onCancel on dismiss and never the close', () => { + const onConfirm = vi.fn() + const onCancel = vi.fn() + const store = useRunningTerminalCloseConfirmStore.getState() + + store.requestRunningTerminalCloseConfirm(request('tab-1', onConfirm, onCancel)) + store.dismissRunningTerminalClose() + + expect(onCancel).toHaveBeenCalledTimes(1) + expect(onConfirm).not.toHaveBeenCalled() + expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull() + }) + + it('is inert when there is nothing to confirm', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + + expect(() => store.confirmRunningTerminalClose()).not.toThrow() + expect(() => store.dismissRunningTerminalClose()).not.toThrow() + }) + + it('lets a re-entrant close from onConfirm queue behind the next request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const reentrant = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm( + request('tab-1', () => { + // Why: the real onConfirm re-enters closeTerminalTab, which may request again. + store.requestRunningTerminalCloseConfirm(request('tab-3', reentrant)) + }) + ) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + store.confirmRunningTerminalClose() + + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-3') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(reentrant).toHaveBeenCalledTimes(1) + }) + + // Why: a queued request replaces the visible one in place. Without this window, the second + // click of a double-click aimed at one tab lands on the next tab's prompt and kills a + // running process the user never saw asked about — the bug class this PR exists to close. + it('ignores a second action that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const first = vi.fn() + const second = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1', first)) + store.requestRunningTerminalCloseConfirm(request('tab-2', second)) + + store.confirmRunningTerminalClose() + store.confirmRunningTerminalClose() + + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + + advancePastGuard() + store.confirmRunningTerminalClose() + expect(second).toHaveBeenCalledTimes(1) + }) + + it('also holds off a dismiss that lands on the freshly revealed request', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const secondCancel = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.requestRunningTerminalCloseConfirm(request('tab-2', vi.fn(), secondCancel)) + + store.confirmRunningTerminalClose() + store.dismissRunningTerminalClose() + + expect(secondCancel).not.toHaveBeenCalled() + expect( + useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm?.terminalTabId + ).toBe('tab-2') + }) + + // Why: emptying the queue leaves nothing to mis-click, so the opt-out must not be delayed. + it('arms no guard when the last request leaves the queue empty', () => { + const store = useRunningTerminalCloseConfirmStore.getState() + const onConfirm = vi.fn() + + store.requestRunningTerminalCloseConfirm(request('tab-1')) + store.confirmRunningTerminalClose() + store.requestRunningTerminalCloseConfirm(request('tab-2', onConfirm)) + store.confirmRunningTerminalClose() + + expect(onConfirm).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/store/running-terminal-close-confirm.ts b/src/renderer/src/store/running-terminal-close-confirm.ts new file mode 100644 index 00000000000..618d30c98c5 --- /dev/null +++ b/src/renderer/src/store/running-terminal-close-confirm.ts @@ -0,0 +1,132 @@ +import { create } from 'zustand' +import type { CloseTerminalDialogCopyKind } from '@/components/terminal-pane/CloseTerminalDialog' + +/** A pending confirmation for closing a terminal tab whose shell still has a + * running child process. `onConfirm` performs the original close. */ +export type RunningTerminalCloseConfirmRequest = { + terminalTabId: string + tabLabel: string + copyKind: CloseTerminalDialogCopyKind + onConfirm: () => void + onCancel?: () => void +} + +export type RunningTerminalCloseConfirmState = { + runningTerminalCloseConfirm: RunningTerminalCloseConfirmRequest | null + requestRunningTerminalCloseConfirm: (request: RunningTerminalCloseConfirmRequest) => void + confirmRunningTerminalClose: () => void + /** Accepts the visible request and every queued one. Used when the user ticks "don't ask + * again": a prompt they just opted out of must not still be waiting behind this one. */ + confirmAllRunningTerminalCloses: () => void + dismissRunningTerminalClose: () => void +} + +/** Folds a duplicate request for the same tab into the pending one instead of dropping it, + * so two surfaces closing the same tab both get their callback. */ +function mergeRequests( + pending: RunningTerminalCloseConfirmRequest, + duplicate: RunningTerminalCloseConfirmRequest +): RunningTerminalCloseConfirmRequest { + return { + ...pending, + onConfirm: () => { + pending.onConfirm() + duplicate.onConfirm() + }, + onCancel: () => { + pending.onCancel?.() + duplicate.onCancel?.() + } + } +} + +// Why a standalone store instead of an AppState slice (which is what the sibling +// pinned-tab confirmation uses): the request is raised from closeTerminalTab, a plain +// module whose unit fixtures build partial app-state objects, so dispatching through +// useAppStore.getState() would throw there. Nothing outside the dialog reads this state, +// so the AppState coupling would buy nothing. +export const useRunningTerminalCloseConfirmStore = create()(( + set, + get +) => { + const queuedRequests: RunningTerminalCloseConfirmRequest[] = [] + // Why: a queued request replaces the visible one in place, so a double-click or held + // Enter meant for the tab the user was looking at would land on the next tab's prompt and + // kill a second running process unseen. Matches the sibling pinned-tab confirmation. + const INTER_REQUEST_ACTION_GUARD_MS = 350 + let nextRequestActionAllowedAt = 0 + + /** Reveals the next queued request, and reports whether one took the visible slot. */ + const advanceRequest = (): boolean => { + const next = queuedRequests.shift() ?? null + set({ runningTerminalCloseConfirm: next }) + return next !== null + } + + const guardNextAction = (revealedNextRequest: boolean): void => { + if (revealedNextRequest) { + nextRequestActionAllowedAt = Date.now() + INTER_REQUEST_ACTION_GUARD_MS + } + } + + return { + runningTerminalCloseConfirm: null, + + requestRunningTerminalCloseConfirm: (request) => { + const visible = get().runningTerminalCloseConfirm + // Why: the probe is async, so a second click on the same tab arrives before the + // dialog opens. One prompt, but both closes still resolve. + if (visible?.terminalTabId === request.terminalTabId) { + set({ runningTerminalCloseConfirm: mergeRequests(visible, request) }) + return + } + const queuedIndex = queuedRequests.findIndex( + (queued) => queued.terminalTabId === request.terminalTabId + ) + if (queuedIndex >= 0) { + queuedRequests[queuedIndex] = mergeRequests(queuedRequests[queuedIndex]!, request) + return + } + if (visible) { + // Why: closing two busy tabs in quick succession must not strand the second + // tab's close callback behind a replaced request. + queuedRequests.push(request) + return + } + set({ runningTerminalCloseConfirm: request }) + }, + + confirmRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + // Why: advance before running onConfirm so a re-entrant close queues behind the + // next real request instead of seeing the stale one. + guardNextAction(advanceRequest()) + request.onConfirm() + }, + + confirmAllRunningTerminalCloses: () => { + if (Date.now() < nextRequestActionAllowedAt) { + return + } + const pending = [get().runningTerminalCloseConfirm, ...queuedRequests.splice(0)] + set({ runningTerminalCloseConfirm: null }) + // No guard to arm: the queue is empty, so there is no next prompt to mis-click. + for (const request of pending) { + request?.onConfirm() + } + }, + + dismissRunningTerminalClose: () => { + const request = get().runningTerminalCloseConfirm + if (!request || Date.now() < nextRequestActionAllowedAt) { + return + } + guardNextAction(advanceRequest()) + // Why: callers such as the tab-group model resume their own cleanup on cancel. + request.onCancel?.() + } + } +}) diff --git a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts index 75b37f039fc..f1deb30370f 100644 --- a/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-manual-sleep-capture.test.ts @@ -58,7 +58,7 @@ function makeSleepingRecord( } describe('manual sleep agent session capture', () => { - it('captures only fresh active live rows as worktree-sleep records', () => { + it('captures every resumable live row as a worktree-sleep record keeping its own state', () => { vi.useFakeTimers() vi.setSystemTime(NOW) const store = createTestStore() @@ -87,12 +87,88 @@ describe('manual sleep agent session capture', () => { store.getState().captureSleepingAgentSessionsByWorktree('wt-1') const records = store.getState().sleepingAgentSessionsByPaneKey - expect(Object.keys(records).sort()).toEqual(['tab-1:fresh']) + expect(Object.keys(records).sort()).toEqual([ + 'tab-1:done', + 'tab-1:fresh', + 'tab-1:interrupted', + 'tab-1:post-input', + 'tab-1:stale' + ]) expect(records['tab-1:fresh']).toMatchObject({ origin: 'worktree-sleep', state: 'working', providerSession: { key: 'session_id', id: 'session-tab-1:fresh' } }) + expect(records['tab-1:done']).toMatchObject({ origin: 'worktree-sleep', state: 'done' }) + expect(records['tab-1:done'].interrupted).toBeUndefined() + expect(records['tab-1:interrupted']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done' + }) + expect(records['tab-1:interrupted'].interrupted).toBeUndefined() + expect(records['tab-1:post-input']).toMatchObject({ state: 'working', updatedAt: NOW }) + expect(records['tab-1:stale']).toMatchObject({ state: 'working', updatedAt: NOW }) + }) + + it('marks finished panes for tab-open-only restore so a mobile wake cannot respawn them all', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const retainedEntry = makeAgentEntry({ paneKey: 'tab-1:retained', state: 'done' }) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }), + 'tab-1:working': makeAgentEntry({ paneKey: 'tab-1:working' }) + }, + retainedAgentsByPaneKey: { + 'tab-1:retained': { + entry: retainedEntry, + tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: retainedEntry.stateStartedAt + } + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(records['tab-1:done'].restoreOnTabOpenOnly).toBe(true) + expect(records['tab-1:retained'].restoreOnTabOpenOnly).toBe(true) + // Why: a still-working pane is resumed by wake, not by opening its tab. + expect(records['tab-1:working'].restoreOnTabOpenOnly).toBeUndefined() + }) + + it('carries a blocked legacy-orchestration-worker flag onto the replacement record', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + agentStatusByPaneKey: { + 'tab-1:leaf-1': makeAgentEntry(), + 'tab-1:leaf-2': makeAgentEntry({ paneKey: 'tab-1:leaf-2' }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': makeSleepingRecord({ + providerSession: { key: 'session_id', id: 'session-tab-1:leaf-1' }, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + }), + 'tab-1:leaf-2': makeSleepingRecord({ + paneKey: 'tab-1:leaf-2', + automaticResumeBlockedBy: 'legacy-orchestration-worker' + }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(records['tab-1:leaf-1'].automaticResumeBlockedBy).toBe('legacy-orchestration-worker') + // Different provider session: the block belonged to a session that is no longer running here. + expect(records['tab-1:leaf-2'].automaticResumeBlockedBy).toBeUndefined() }) it('preserves retained completed sessions as intentional sleep records', () => { @@ -123,7 +199,106 @@ describe('manual sleep agent session capture', () => { }) }) - it('clears pre-existing records for rows skipped by manual capture', () => { + // Why: a retained row is the pane's last status after its pty died, so it is stale by + // construction — capturing it verbatim rebuilt a record wake discards as stale (#11598). + it('refreshes a stale retained working row so wake cannot discard it', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const entry = makeAgentEntry({ + paneKey: 'tab-1:retained', + updatedAt: NOW - AGENT_STATUS_STALE_AFTER_MS - 1, + interrupted: true + }) + store.setState({ + retainedAgentsByPaneKey: { + 'tab-1:retained': { + entry, + tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: entry.stateStartedAt + } + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const record = store.getState().sleepingAgentSessionsByPaneKey['tab-1:retained'] + expect(record).toMatchObject({ origin: 'worktree-sleep', state: 'working', updatedAt: NOW }) + expect(record.capturedAt - record.updatedAt).toBeLessThanOrEqual(AGENT_STATUS_STALE_AFTER_MS) + expect(record.interrupted).toBeUndefined() + }) + + it('carries a blocked legacy-orchestration-worker flag onto a retained replacement record', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const entry = makeAgentEntry({ paneKey: 'tab-1:retained', state: 'done' }) + store.setState({ + retainedAgentsByPaneKey: { + 'tab-1:retained': { + entry, + tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: entry.stateStartedAt + } + }, + sleepingAgentSessionsByPaneKey: { + 'tab-1:retained': makeSleepingRecord({ + paneKey: 'tab-1:retained', + providerSession: { key: 'session_id', id: 'session-tab-1:retained' }, + automaticResumeBlockedBy: 'legacy-orchestration-worker' + }) + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect( + store.getState().sleepingAgentSessionsByPaneKey['tab-1:retained'].automaticResumeBlockedBy + ).toBe('legacy-orchestration-worker') + }) + + // Why: the promoted checkpoint owns the pane's recovery identity (connection, transcript); the + // retained pass must not re-derive over it any more than the live pass may. + it('keeps a promoted live checkpoint that also has a retained row', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + const providerSession = { key: 'session_id', id: 'codex-shared' } as const + const entry = makeAgentEntry({ paneKey: 'tab-1:leaf-1', state: 'done', providerSession }) + store.setState({ + sleepingAgentSessionsByPaneKey: { + 'tab-1:leaf-1': makeSleepingRecord({ providerSession, connectionId: 'conn-1' }) + }, + retainedAgentsByPaneKey: { + 'tab-1:leaf-1': { + entry, + tab: makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + worktreeId: 'wt-1', + agentType: 'codex', + startedAt: entry.stateStartedAt + } + } + } as Partial) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + const record = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(record).toMatchObject({ + origin: 'worktree-sleep', + state: 'working', + connectionId: 'conn-1' + }) + expect(record.restoreOnTabOpenOnly).toBeUndefined() + }) + + it('replaces pre-existing records for stale rows instead of dropping them', () => { vi.useFakeTimers() vi.setSystemTime(NOW) const store = createTestStore() @@ -142,9 +317,81 @@ describe('manual sleep agent session capture', () => { store.getState().captureSleepingAgentSessionsByWorktree('wt-1') - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toMatchObject({ + origin: 'worktree-sleep', + updatedAt: NOW, + providerSession: { key: 'session_id', id: 'session-tab-1:stale' } + }) }) + it('keeps the live checkpoint of an interrupted pane as a worktree-sleep record', () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'done', prompt: 'do the thing', agentType: 'claude', interrupted: true }, + 'Claude', + { updatedAt: NOW, stateStartedAt: NOW }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession: { key: 'session_id', id: 'claude-session-1' } } + ) + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + origin: 'live' + }) + + store.getState().captureSleepingAgentSessionsByWorktree('wt-1') + + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + agent: 'claude', + providerSession: { key: 'session_id', id: 'claude-session-1' } + }) + }) + + // Why: a slept `done` pane stays passive until its tab is opened, so a second sleep finds no + // live row to re-derive its record from — the wipe must leave it alone (#11598). + it('keeps a durable slept record whose pane was never woken across a second sleep', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [ + makeTab({ id: 'tab-1', worktreeId: 'wt-1' }), + makeTab({ id: 'tab-2', worktreeId: 'wt-1' }) + ] + }, + agentStatusByPaneKey: { + 'tab-1:leaf-1': makeAgentEntry({ paneKey: 'tab-1:leaf-1', state: 'done' }) + }, + sleepingAgentSessionsByPaneKey: { + 'tab-2:leaf-1': makeSleepingRecord({ + paneKey: 'tab-2:leaf-1', + agent: 'claude', + state: 'done', + providerSession: { key: 'session_id', id: 'claude-never-woken' }, + origin: 'worktree-sleep' + }) + } + } as Partial) + + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(records['tab-2:leaf-1']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + providerSession: { key: 'session_id', id: 'claude-never-woken' } + }) + expect(records['tab-1:leaf-1']).toMatchObject({ origin: 'worktree-sleep', state: 'done' }) + }) + + // Why: an unresumable `live` checkpoint is provisional, so the wipe must still clear it. it('does not promote Pi identity without an authoritative transcript', () => { vi.useFakeTimers() vi.setSystemTime(NOW) @@ -164,7 +411,7 @@ describe('manual sleep agent session capture', () => { expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBeUndefined() }) - it('uses manual sleep filtering when terminal shutdown captures sleeping records', async () => { + it('captures resumable rows when terminal shutdown captures sleeping records', async () => { vi.useFakeTimers() vi.setSystemTime(NOW) const store = createTestStore() @@ -179,14 +426,48 @@ describe('manual sleep agent session capture', () => { await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) const records = store.getState().sleepingAgentSessionsByPaneKey - expect(Object.keys(records)).toEqual(['tab-1:fresh']) + expect(Object.keys(records).sort()).toEqual(['tab-1:done', 'tab-1:fresh']) expect(records['tab-1:fresh']).toMatchObject({ origin: 'worktree-sleep', state: 'working' }) + expect(records['tab-1:done']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done' + }) }) - it('clears pre-existing records for rows skipped during terminal shutdown capture', async () => { + // Why: runSleepWorktrees deactivates the workspace before terminal shutdown, so capture must + // still see the live rows it is about to kill. + it('captures done and interrupted rows after the slept worktree is deactivated', async () => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + const store = createTestStore() + seedTabs(store) + store.setState({ + activeWorktreeId: 'wt-1', + agentStatusByPaneKey: { + 'tab-1:done': makeAgentEntry({ paneKey: 'tab-1:done', state: 'done' }), + 'tab-1:interrupted': makeAgentEntry({ + paneKey: 'tab-1:interrupted', + state: 'done', + interrupted: true + }) + } + } as Partial) + + store.getState().setActiveWorktree(null) + await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) + + const records = store.getState().sleepingAgentSessionsByPaneKey + expect(Object.keys(records).sort()).toEqual(['tab-1:done', 'tab-1:interrupted']) + expect(records['tab-1:interrupted']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done' + }) + }) + + it('replaces pre-existing records for stale rows during terminal shutdown capture', async () => { vi.useFakeTimers() vi.setSystemTime(NOW) const store = createTestStore() @@ -206,6 +487,10 @@ describe('manual sleep agent session capture', () => { await store.getState().shutdownWorktreeTerminals('wt-1', { keepIdentifiers: true }) - expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toBeUndefined() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:stale']).toMatchObject({ + origin: 'worktree-sleep', + updatedAt: NOW, + providerSession: { key: 'session_id', id: 'session-tab-1:stale' } + }) }) }) diff --git a/src/renderer/src/store/slices/agent-status.test.ts b/src/renderer/src/store/slices/agent-status.test.ts index 70c334eae1a..b6b2976e26b 100644 --- a/src/renderer/src/store/slices/agent-status.test.ts +++ b/src/renderer/src/store/slices/agent-status.test.ts @@ -265,6 +265,42 @@ describe('agent status runtime orchestration metadata', () => { }) }) + it('clears stale lineage when the authoritative runtime snapshot loses its Run binding', () => { + vi.useFakeTimers() + const store = createTestStore() + const childPaneKey = 'tab-child:11111111-1111-4111-8111-111111111111' + + store.getState().setAgentStatus(childPaneKey, { + state: 'working', + prompt: 'child agent', + agentType: 'codex', + orchestration: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + parentTerminalHandle: 'term-old-coordinator', + parentPaneKey: 'tab-parent:22222222-2222-4222-8222-222222222222', + coordinatorHandle: 'term-old-coordinator', + orchestrationRunId: 'run-1' + } + }) + store.getState().setRuntimeAgentOrchestrationByPaneKey({ + [childPaneKey]: { + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + } + }) + + expect(store.getState().agentStatusByPaneKey[childPaneKey].orchestration).toEqual({ + taskId: 'task-1', + dispatchId: 'ctx-1', + dispatchStatus: 'dispatched', + orchestrationRunId: 'run-1' + }) + }) + it('updates runtime status for the same dispatch', () => { vi.useFakeTimers() const store = createTestStore() diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index d8c87362edc..fcf39188136 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -32,7 +32,6 @@ import { getWorktreeExecutionHostId } from '../../../../shared/execution-host' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -import { readLastTerminalInputAt } from '@/lib/terminal-input-activity-coalescing' import { getAgentRowGeneratedTitleText, getOrcaDispatchTaskId, @@ -580,33 +579,53 @@ function normalizeSleepingAgentSessionCollectOptions( : (options as CollectSleepingAgentSessionRecordsOptions) } -function isValidManualSleepLiveAgentEntry( - state: AppState, - entry: AgentStatusEntry, - capturedAt: number -): boolean { - if (entry.interrupted === true || entry.state === 'done') { - return false - } - const lastInputAt = readLastTerminalInputAt(state.lastTerminalInputAtByPaneKey, entry.paneKey) - if ( - typeof lastInputAt === 'number' && - Number.isFinite(lastInputAt) && - lastInputAt > entry.updatedAt - ) { - return false - } - return isExplicitAgentStatusFresh(entry, capturedAt, AGENT_STATUS_STALE_AFTER_MS) -} - function isValidCompletedAgentHibernationEntry(entry: AgentStatusEntry): boolean { return entry.state === 'done' && entry.interrupted !== true } +// Why: a finished pane is passive wake evidence, and a mobile wake background-mounts every passive +// record's tab. Sleeping a workspace must not become "one phone tap respawns all of it" — the pane +// issues its own `--resume` cold restore when its tab is opened instead (#11598). +function markManualSleepLazyRestore(record: SleepingAgentSessionRecord): void { + if (record.state === 'done') { + record.restoreOnTabOpenOnly = true + } +} + +// Why: `live`/legacy rows are provisional checkpoints a fresh capture supersedes; an explicit +// sleep or quit capture is the pane's only resume handle once its live row is gone. +function isDurableSleepingCapture(record: SleepingAgentSessionRecord): boolean { + return record.origin === 'worktree-sleep' || record.origin === 'quit' +} + +// Why: manual sleep kills the pty either way, so the record carries resume identity, not the dead +// turn's interrupt flag — and an explicitly slept workspace is never stale at wake, so a row the +// user is deliberately sleeping must not trip the wake-side staleness discard. `state` is preserved +// so a done pane wakes lazily in place instead of spawning a new tab. +function manualSleepCaptureEntry(entry: AgentStatusEntry, capturedAt: number): AgentStatusEntry { + return { ...entry, updatedAt: capturedAt, interrupted: false } +} + +// Why: capture recreates a record the manual-sleep wipe would otherwise remove, so a deliberately +// blocked worker must not become auto-resumable at wake. +function carryOverAutomaticResumeBlock( + record: SleepingAgentSessionRecord, + previous: SleepingAgentSessionRecord | undefined +): void { + if ( + previous?.automaticResumeBlockedBy === 'legacy-orchestration-worker' && + previous.agent === record.agent && + agentProviderSessionsEqual(record.agent, previous.providerSession, record.providerSession) + ) { + record.automaticResumeBlockedBy = previous.automaticResumeBlockedBy + } +} + export function removeSleepingRecordsReplacedByManualWorktreeSleep( records: Record, worktreeId: string, - paneKeys?: readonly string[] + paneKeys?: readonly string[], + replacements?: Readonly> ): { records: Record; changed: boolean } { const allowedPaneKeys = paneKeys ? new Set(paneKeys) : null let next = records @@ -615,6 +634,11 @@ export function removeSleepingRecordsReplacedByManualWorktreeSleep( if (record.worktreeId !== worktreeId || (allowedPaneKeys && !allowedPaneKeys.has(paneKey))) { continue } + // Why: a repeat sleep must not delete a durable record this capture cannot re-derive — the + // pane was never woken, so it has no live status row to rebuild it from (#11598). + if (!replacements?.[paneKey] && isDurableSleepingCapture(record)) { + continue + } if (next === records) { next = { ...records } } @@ -642,6 +666,7 @@ export function collectSleepingAgentSessionRecordsForWorktree( : undefined const tabPrefixes = (state.tabsByWorktree[worktreeId] ?? []).map((tab) => `${tab.id}:`) const records: Record = {} + const promotedLiveRecoveryPaneKeys = new Set() if (isManualWorktreeSleep) { for (const existing of Object.values(state.sleepingAgentSessionsByPaneKey)) { @@ -665,6 +690,7 @@ export function collectSleepingAgentSessionRecordsForWorktree( updatedAt: capturedAt, origin: 'worktree-sleep' } + promotedLiveRecoveryPaneKeys.add(existing.paneKey) } } @@ -678,9 +704,16 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (retained.worktreeId !== worktreeId) { continue } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) a retained + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(retained.entry.paneKey)) { + continue + } const record = sleepingRecordFromEntry({ state, - entry: retained.entry, + entry: isManualWorktreeSleep + ? manualSleepCaptureEntry(retained.entry, capturedAt) + : retained.entry, worktreeId, tab: retained.tab, capturedAt, @@ -688,6 +721,13 @@ export function collectSleepingAgentSessionRecordsForWorktree( origin }) if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock( + record, + state.sleepingAgentSessionsByPaneKey[retained.entry.paneKey] + ) + } records[record.paneKey] = record } } @@ -696,26 +736,32 @@ export function collectSleepingAgentSessionRecordsForWorktree( if (allowedPaneKeys && !allowedPaneKeys.has(paneKey)) { continue } + // Why: the promoted checkpoint carries recovery identity (transcript, connection) the live + // turn row lacks, so it must not be overwritten by a re-derived record. + if (promotedLiveRecoveryPaneKeys.has(paneKey)) { + continue + } const belongsToWorktree = entry.worktreeId === worktreeId || paneKeyMatchesAnyTabPrefix(paneKey, tabPrefixes) if (!belongsToWorktree) { continue } - if (isManualWorktreeSleep && !isValidManualSleepLiveAgentEntry(state, entry, capturedAt)) { - continue - } if (isCompletedAgentHibernation && !isValidCompletedAgentHibernationEntry(entry)) { continue } const record = sleepingRecordFromEntry({ state, - entry, + entry: isManualWorktreeSleep ? manualSleepCaptureEntry(entry, capturedAt) : entry, worktreeId, capturedAt, launchConfig: getLaunchConfigForEntry(state, entry), origin }) if (record) { + if (isManualWorktreeSleep) { + markManualSleepLazyRestore(record) + carryOverAutomaticResumeBlock(record, state.sleepingAgentSessionsByPaneKey[paneKey]) + } records[record.paneKey] = record } } @@ -1125,6 +1171,9 @@ function mergeCurrentOrchestrationContext( if (!sameDispatch) { return current } + if (current.dispatchStatus !== undefined) { + return orchestrationContextsEqual(existing, current) ? existing : current + } const merged = { ...existing, ...current } return orchestrationContextsEqual(existing, merged) ? existing : merged } @@ -2742,7 +2791,8 @@ export const createAgentStatusSlice: StateCreator = { ...replaced.records } let changed = replaced.changed diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d8a554a5197..8b87ee976c7 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -39,6 +39,10 @@ vi.mock('@/runtime/close-mirrored-editor-tab', () => ({ notifyHostOfMirroredEditorClose: (...args: unknown[]) => notifyHostOfMirroredEditorCloseMock(...args) })) +const loadGitLabJobLogDetailsMock = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/gitlab-job-trace-client', () => ({ + loadGitLabJobLogDetails: loadGitLabJobLogDetailsMock +})) function createEditorStore(): StoreApi { // Only the editor slice + activeWorktreeId are needed for these tests. @@ -97,6 +101,22 @@ function ownedEditorFileId( return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` } +/** Counts how often store code reads `entityId` off a worktree's unified tabs — + * a scan-count proxy that pins complexity without timing the wall clock. */ +function withCountedEntityIdReads(tabs: readonly Tab[], onRead: () => void): Tab[] { + return tabs.map((tab) => { + const { entityId, ...rest } = tab + return Object.defineProperty(rest, 'entityId', { + get: () => { + onRead() + return entityId + }, + enumerable: true, + configurable: true + }) as Tab + }) +} + function mirroredEditorUnifiedTab(id: string, entityId: string, worktreeId: string): Tab { return { id, @@ -1712,6 +1732,93 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles[0]?.id).toBe(firstId) expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) }) + + it('captures close-all snapshot positions without rescanning tab state per closed tab', () => { + const countEntityIdReads = (fileCount: number): number => { + const store = createEditorTabsStore() + for (let index = 0; index < fileCount; index += 1) { + store.getState().openFile( + { + filePath: `/repo/file-${index}.ts`, + relativePath: `file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { preview: false } + ) + } + let reads = 0 + store.setState({ + unifiedTabsByWorktree: { + 'wt-1': withCountedEntityIdReads( + store.getState().unifiedTabsByWorktree['wt-1'] ?? [], + () => { + reads += 1 + } + ) + } + } as Partial) + reads = 0 + + store.getState().closeAllFiles() + + expect(store.getState().openFiles).toHaveLength(0) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']?.[0]?.position).toEqual({ + tabBarIndex: 0, + groupId: expect.any(String), + groupIndex: 0 + }) + return reads + } + + // Why: resolving each closed tab's position by rescanning tab order and group membership + // made close-all cubic — 4x the tabs cost ~64x the scans and froze the renderer for seconds. + expect(countEntityIdReads(80)).toBeLessThanOrEqual(countEntityIdReads(20) * 8) + }) + + it('reactivates the live tab when a stale reopen id targets an already-open file', () => { + const store = createEditorTabsStore() + store.setState({ + worktreesByRepo: { + 'repo-1': [ + { id: 'wt-1', repoId: 'repo-1', path: '/repo' }, + { id: 'wt-2', repoId: 'repo-1', path: '/repo-2' } + ] + } + } as unknown as Partial) + const sharedPath = '/home/me/.zshrc' + const openShared = (worktreeId: string): string => + store.getState().openFile({ + filePath: sharedPath, + relativePath: '.zshrc', + worktreeId, + language: 'shell', + mode: 'edit' + }) + + // Bare path id: nothing else owns this path yet. + const staleWt1Id = openShared('wt-1') + expect(staleWt1Id).toBe(sharedPath) + store.getState().closeFile(staleWt1Id) + + // wt-2 claims the bare id, so wt-1's reopen gets a namespaced id instead. + const wt2Id = openShared('wt-2') + expect(wt2Id).toBe(sharedPath) + const liveWt1Id = openShared('wt-1') + expect(liveWt1Id).toBe(ownedEditorFileId(sharedPath, 'wt-1', null)) + store.getState().closeFile(wt2Id) + + expect(store.getState().reopenClosedEditorTab('wt-1')).toBe(true) + + expect(store.getState().openFiles.map((file) => file.id)).toEqual([liveWt1Id]) + expect(store.getState().activeFileId).toBe(liveWt1Id) + expect(store.getState().activeFileIdByWorktree['wt-1']).toBe(liveWt1Id) + expect( + (store.getState().unifiedTabsByWorktree['wt-1'] ?? []).map((tab) => tab.entityId) + ).toEqual([liveWt1Id]) + expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([liveWt1Id]) + }) }) describe('createEditorSlice markdown view state', () => { @@ -2998,6 +3105,151 @@ describe('createEditorSlice conflict status reconciliation', () => { ) }) + // Regression for #7732: refreshing a GitLab job tab through the GitHub check-runs + // API returns null and blanks the tab the user just asked to reload. + it('reloads an open GitLab job tab through the job trace client', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue({ + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + url: null, + detailsUrl: null, + startedAt: null, + completedAt: null, + title: null, + summary: null, + text: null, + annotations: [], + jobs: [ + { + id: 42, + name: 'test: unit', + status: 'completed', + conclusion: 'failure', + startedAt: null, + completedAt: null, + url: null, + logTail: 'ERROR: Job failed: exit code 1', + steps: [] + } + ] + }) + const fetchPRCheckDetails = vi.fn().mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails, + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 42 + } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:42') + + expect(fetchPRCheckDetails).not.toHaveBeenCalled() + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ repoPath: '/repo', repoId: 'repo-1', check }) + ) + expect(store.getState().openFiles).toContainEqual( + expect.objectContaining({ + id: 'wt-1::check-details::gitlab-job:42', + checkRunDetails: expect.objectContaining({ + loading: false, + error: null, + details: expect.objectContaining({ + jobs: [expect.objectContaining({ logTail: 'ERROR: Job failed: exit code 1' })] + }) + }) + }) + ) + }) + + // Regression for #7732: a fork MR's job lives in the source project, so reloading + // without the stored project ref requests the trace from the wrong project. + it('reloads a fork MR job tab with the stored GitLab project ref', async () => { + loadGitLabJobLogDetailsMock.mockReset() + loadGitLabJobLogDetailsMock.mockResolvedValue(null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + repos: [{ id: 'repo-1', path: '/repo' }], + worktreesByRepo: { 'repo-1': [{ id: 'wt-1', repoId: 'repo-1', path: '/repo' }] }, + settings: { activeRuntimeEnvironmentId: null }, + fetchPRCheckDetails: vi.fn().mockResolvedValue(null), + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + + await store.getState().reloadOpenCheckRunDetailsTab('wt-1::check-details::gitlab-job:77') + + expect(loadGitLabJobLogDetailsMock).toHaveBeenCalledWith( + expect.objectContaining({ check, projectRef }) + ) + }) + + it('keeps a stored GitLab project ref when a patch omits it', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const store = createStore()((...args: any[]) => ({ + activeWorktreeId: 'wt-1', + ...createEditorSlice(...(args as Parameters)) + })) as unknown as StoreApi + const check = { + name: 'test: unit', + status: 'completed' as const, + conclusion: 'failure' as const, + url: null, + gitlabJobId: 77 + } + const projectRef = { host: 'gitlab.com', path: 'contributor/fork' } + + store.getState().openCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: false, + error: null, + gitlabProjectRef: projectRef + }) + store.getState().patchOpenCheckRunDetails('wt-1', 'repo:99', check, { + details: null, + loading: true, + error: null, + gitlabProjectRef: null + }) + + expect( + store.getState().openFiles.find((file) => file.id === 'wt-1::check-details::gitlab-job:77') + ?.checkRunDetails?.gitlabProjectRef + ).toEqual(projectRef) + }) + it('patches an open check-details tab without changing the active file', () => { const store = createEditorTabsStore() const check = { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c3c2d630710..bf17d61a3ea 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { + createRecentlyClosedTabPositionIndex, getRecentlyClosedTabPosition, restoreRecentlyClosedTabPosition, pushRecentlyClosedTabKind @@ -14,6 +15,8 @@ import { resolveMarkdownLinkTarget } from '@/components/editor/markdown-internal import { buildCheckRunDetailsTabId, getCheckRunDetailsTabLabel, + isSameGitLabProjectRef, + type CheckRunDetailsTabPatch, type OpenCheckRunDetailsState } from '@/components/editor/check-run-details-tab' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' @@ -49,6 +52,7 @@ import { COMBINED_DIFF_FILE_TREE_DEFAULT_WIDTH } from '../../../../shared/combined-diff-file-tree-width' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { invalidateAutomaticPushTargetUpstreamStatusCache } from '@/components/right-sidebar/push-target-upstream-refresh-cache' import { @@ -72,8 +76,16 @@ import { } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { notifyHostOfMirroredEditorClose } from '@/runtime/close-mirrored-editor-tab' -import { findWorktreeById, getRepoIdFromWorktreeId } from './worktree-helpers' -import { getExplicitRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner' +import { + findWorktreeById, + getRepoIdFromWorktreeId, + type ActiveWorktreeStateTransition +} from './worktree-helpers' +import { + getExplicitRuntimeEnvironmentIdForWorktree, + getSettingsForWorktreeRuntimeOwner +} from '@/lib/worktree-runtime-owner' +import { loadGitLabJobLogDetails } from '@/runtime/gitlab-job-trace-client' import { addAdditionalValidWorkspaceKeys, type WorkspaceSessionHydrationOptions @@ -89,6 +101,9 @@ import { captureEditorFileOperationProvenance, getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' +import { createBrowserUuid } from '@/lib/browser-uuid' +import { pruneTabGroupLayoutForGroups } from './tabs-hydration' +import { sanitizeRecentTabIds } from './tab-group-state' export type { ActiveRightSidebarTab, @@ -257,6 +272,8 @@ export type OpenFile = { pendingDiskBaselineVerification?: boolean /** Why: gates autosave during a live self-move echo's disk verification; separate flag from the restored scan's so the two can't clear each other's gate. Not persisted. */ pendingLiveDiskVerification?: boolean + /** Blocks saves while a restored tab's filesystem authority is being replaced. */ + pendingOwnerMigration?: boolean /** Why: routes an Orca-owned move's destination-watcher echo into content verification. On the tab so it survives the atomic rekey; operationId supersedes a stale verification on re-move. Not persisted. */ pendingSelfMoveEcho?: { operationId: string; targetPath: string } /** Why: diff bodies are cached in EditorPanel; bump this on re-select so the panel refetches instead of reusing a stale snapshot. */ @@ -521,6 +538,8 @@ export type EditorSlice = { /** When set, dirty autosave-capable destinations get move-echo provenance + a synchronous autosave gate so the watcher can content-verify the echo. */ moveOperationId?: string }) => RekeyOpenFilesResult + setRestoredEditorOwnerMigrationPending: (fileId: string, pending: boolean) => boolean + reparentRestoredEditorFileOwner: (args: RestoredEditorOwnerMigration) => RestoredEditorOwnerResult clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -577,13 +596,13 @@ export type EditorSlice = { worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void patchOpenCheckRunDetails: ( worktreeId: string, contextKey: string, check: OpenCheckRunDetailsState['check'], - state: Pick + state: CheckRunDetailsTabPatch ) => void reloadOpenCheckRunDetailsTab: (fileId: string) => Promise openBranchAllDiffs: ( @@ -1306,6 +1325,19 @@ export type OpenFilePathRekey = { export type RekeyOpenFilesResult = { ok: true } | { ok: false; reason: 'collision' | 'stale' } +export type RestoredEditorOwnerMigration = { + fileId: string + targetWorktreeId: string + targetRelativePath: string + targetExecutionHostId: ExecutionHostId + targetRuntimeEnvironmentId: string | null + targetOperationProvenance: EditorFileOperationProvenance +} + +export type RestoredEditorOwnerResult = + | { ok: true; fileId: string } + | { ok: false; reason: 'collision' | 'owner-changed' | 'stale' } + function rekeyFileIdRecord( record: Record, migrations: ReadonlyMap @@ -1324,6 +1356,41 @@ function rekeyFileIdRecord( return changed ? next : record } +function nextActiveIdAfterRemoval( + ids: readonly string[], + recentIds: readonly string[] | undefined, + removedIds: ReadonlySet +): string | null { + const recent = (recentIds ?? []).toReversed().find((id) => !removedIds.has(id)) + return recent ?? ids.find((id) => !removedIds.has(id)) ?? null +} + +function removeEmptyEditorGroups( + previousGroups: TabGroup[], + groups: TabGroup[], + movedTabIds: ReadonlySet, + layout: AppState['layoutByWorktree'][string] | undefined +): { groups: TabGroup[]; layout: AppState['layoutByWorktree'][string] | undefined } { + const emptiedGroupIds = new Set( + previousGroups + .filter( + (group) => + group.tabOrder.some((id) => movedTabIds.has(id)) && + groups.find((candidate) => candidate.id === group.id)?.tabOrder.length === 0 + ) + .map((group) => group.id) + ) + const remaining = groups.filter((group) => !emptiedGroupIds.has(group.id)) + if (remaining.length === 0) { + return { groups: [], layout: undefined } + } + const validIds = new Set(remaining.map((group) => group.id)) + return { + groups: remaining, + layout: layout ? (pruneTabGroupLayoutForGroups(layout, validIds) ?? undefined) : undefined + } +} + function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) const owningRuntimeEnvironmentId = file.runtimeEnvironmentId?.trim() @@ -1677,8 +1744,10 @@ export const createEditorSlice: StateCreator = (s matchesEditorMode(f, reusableOpenFileModes) && isSameEditorOwner(f, worktreeId, runtimeEnvironmentId) ) - const id = - options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) + // Why: a snapshot's reopenId can be a stale shape — the same path is bare in whichever worktree opened it first and namespaced elsewhere — so honoring it while this owner's tab is already open would strand activeFileId and the unified tab on an id no OpenFile has. + const id = existing + ? existing.id + : options?.reopenId && !s.openFiles.some((candidate) => candidate.id === options.reopenId) ? options.reopenId : resolveEditorFileIdForOwner( s, @@ -2394,6 +2463,8 @@ export const createEditorSlice: StateCreator = (s const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] let capturedCloseCount = 0 + // Why: one shared index — a per-file position lookup rescans tab order and group membership, making close-all cubic. + const positionIndex = createRecentlyClosedTabPositionIndex(s, activeWorktreeId) for (const f of [...closingFiles].toReversed()) { // Why: skip untitled non-dirty files (deleted from disk after close) and ephemeral preview tabs so the reopen stack has no vanished/junk paths. if ( @@ -2403,7 +2474,7 @@ export const createEditorSlice: StateCreator = (s continue } const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f - const position = getRecentlyClosedTabPosition(s, activeWorktreeId, f.id) + const position = positionIndex.positionFor(f.id) nextRecentClosed = [ { ...(snap as ClosedEditorTabSnapshot), @@ -2635,6 +2706,327 @@ export const createEditorSlice: StateCreator = (s } }), + setRestoredEditorOwnerMigrationPending: (fileId, pending) => { + let changed = false + set((s) => { + const file = s.openFiles.find((candidate) => candidate.id === fileId) + const next = pending || undefined + if (!file || file.pendingOwnerMigration === next) { + return s + } + changed = true + return { + openFiles: s.openFiles.map((candidate) => + candidate.id === fileId ? { ...candidate, pendingOwnerMigration: next } : candidate + ) + } + }) + return changed + }, + + reparentRestoredEditorFileOwner: (args) => { + let result: RestoredEditorOwnerResult = { ok: false, reason: 'stale' } + const projectReparent: ActiveWorktreeStateTransition = (s) => { + const source = s.openFiles.find((file) => file.id === args.fileId) + if (!source) { + return { patch: {}, activate: false } + } + + try { + const currentRoute = assertEditorFileOperationCurrent( + s, + args.targetWorktreeId, + args.targetOperationProvenance + ) + if ( + currentRoute.executionHostId !== args.targetExecutionHostId || + currentRoute.runtimeEnvironmentId !== args.targetRuntimeEnvironmentId + ) { + throw new Error('owner changed') + } + } catch { + result = { ok: false, reason: 'owner-changed' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + const operationProvenance = args.targetOperationProvenance + + const destinationCollision = s.openFiles.some( + (file) => + file.id !== source.id && + file.filePath === source.filePath && + file.worktreeId === args.targetWorktreeId && + (file.runtimeEnvironmentId?.trim() || null) === args.targetRuntimeEnvironmentId + ) + const newFileId = buildOwnedEditorFileId( + source.filePath, + args.targetWorktreeId, + args.targetRuntimeEnvironmentId + ) + const dependentPreviews = s.openFiles.filter( + (file) => file.markdownPreviewSourceFileId === source.id + ) + const previewIdMigrations = new Map( + dependentPreviews.map((preview) => [preview.id, `markdown-preview::${newFileId}`]) + ) + const destinationIds = new Set([newFileId, ...previewIdMigrations.values()]) + if ( + destinationCollision || + s.openFiles.some( + (file) => + destinationIds.has(file.id) && + file.id !== source.id && + !previewIdMigrations.has(file.id) + ) + ) { + result = { ok: false, reason: 'collision' } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === args.fileId ? { ...file, pendingOwnerMigration: undefined } : file + ) + }, + activate: false + } + } + + const parsedHost = parseExecutionHostId(args.targetExecutionHostId) + const externalSshTargetId = + parsedHost?.kind === 'ssh' && args.targetRuntimeEnvironmentId === null + ? parsedHost.targetId + : undefined + const migrations = new Map([[source.id, newFileId], ...previewIdMigrations]) + const movedFileIds = new Set(migrations.keys()) + const sourceWorktreeId = source.worktreeId + const targetWorktreeId = args.targetWorktreeId + const movedTabs = (s.unifiedTabsByWorktree[sourceWorktreeId] ?? []).filter((tab) => + movedFileIds.has(tab.entityId) + ) + const movedTabIds = new Set(movedTabs.map((tab) => tab.id)) + const tabIdMigration = new Map( + movedTabs.map((tab) => [tab.id, migrations.get(tab.id) ?? tab.id]) + ) + const mappedMovedTabIds = movedTabs.map((tab) => tabIdMigration.get(tab.id) ?? tab.id) + const mappedMovedTabBarIds = movedTabs.map( + (tab) => migrations.get(tab.entityId) ?? tab.entityId + ) + const targetGroups = s.groupsByWorktree[targetWorktreeId] ?? [] + const targetGroupId = + s.activeGroupIdByWorktree[targetWorktreeId] ?? targetGroups[0]?.id ?? createBrowserUuid() + const targetGroup = targetGroups.find((group) => group.id === targetGroupId) ?? { + id: targetGroupId, + worktreeId: targetWorktreeId, + activeTabId: null, + tabOrder: [] + } + + const previousSourceGroups = s.groupsByWorktree[sourceWorktreeId] ?? [] + const updatedSourceGroups = previousSourceGroups.map((group) => { + const tabOrder = group.tabOrder.filter((id) => !movedTabIds.has(id)) + const activeTabId = + group.activeTabId && movedTabIds.has(group.activeTabId) + ? nextActiveIdAfterRemoval(group.tabOrder, group.recentTabIds, movedTabIds) + : group.activeTabId + return { + ...group, + activeTabId, + tabOrder, + recentTabIds: sanitizeRecentTabIds( + (group.recentTabIds ?? []).filter((id) => !movedTabIds.has(id)), + tabOrder + ) + } + }) + const sourceGroupState = removeEmptyEditorGroups( + previousSourceGroups, + updatedSourceGroups, + movedTabIds, + s.layoutByWorktree[sourceWorktreeId] + ) + const destinationOrder = [ + ...targetGroup.tabOrder.filter((id) => !mappedMovedTabIds.includes(id)), + ...mappedMovedTabIds + ] + const updatedTargetGroup: TabGroup = { + ...targetGroup, + activeTabId: mappedMovedTabIds.at(-1) ?? targetGroup.activeTabId, + tabOrder: destinationOrder, + recentTabIds: sanitizeRecentTabIds( + [...(targetGroup.recentTabIds ?? []), ...mappedMovedTabIds], + destinationOrder + ) + } + const nextTargetGroups = targetGroups.some((group) => group.id === targetGroupId) + ? targetGroups.map((group) => (group.id === targetGroupId ? updatedTargetGroup : group)) + : [...targetGroups, updatedTargetGroup] + + const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } + nextUnifiedTabsByWorktree[sourceWorktreeId] = ( + nextUnifiedTabsByWorktree[sourceWorktreeId] ?? [] + ).filter((tab) => !movedTabIds.has(tab.id)) + nextUnifiedTabsByWorktree[targetWorktreeId] = [ + ...(nextUnifiedTabsByWorktree[targetWorktreeId] ?? []), + ...movedTabs.map((tab) => ({ + ...tab, + id: tabIdMigration.get(tab.id) ?? tab.id, + entityId: migrations.get(tab.entityId) ?? tab.entityId, + groupId: targetGroupId + })) + ] + + const nextGroupsByWorktree = { + ...s.groupsByWorktree, + [sourceWorktreeId]: sourceGroupState.groups, + [targetWorktreeId]: nextTargetGroups + } + const nextLayoutByWorktree = { ...s.layoutByWorktree } + if (sourceGroupState.layout) { + nextLayoutByWorktree[sourceWorktreeId] = sourceGroupState.layout + } else { + delete nextLayoutByWorktree[sourceWorktreeId] + } + if (targetGroups.length === 0 || !nextLayoutByWorktree[targetWorktreeId]) { + nextLayoutByWorktree[targetWorktreeId] = { type: 'leaf', groupId: targetGroupId } + } + + const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } + const sourceActiveFileId = nextActiveFileIdByWorktree[sourceWorktreeId] + if (sourceActiveFileId && movedFileIds.has(sourceActiveFileId)) { + nextActiveFileIdByWorktree[sourceWorktreeId] = + s.openFiles.find( + (file) => !movedFileIds.has(file.id) && file.worktreeId === sourceWorktreeId + )?.id ?? null + } + nextActiveFileIdByWorktree[targetWorktreeId] = newFileId + const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + nextTabBarOrderByWorktree[sourceWorktreeId] = ( + nextTabBarOrderByWorktree[sourceWorktreeId] ?? [] + ).filter((id) => !movedFileIds.has(id) && !movedTabIds.has(id)) + nextTabBarOrderByWorktree[targetWorktreeId] = [ + ...(nextTabBarOrderByWorktree[targetWorktreeId] ?? []).filter( + (id) => !mappedMovedTabBarIds.includes(id) + ), + ...mappedMovedTabBarIds + ] + const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } + if (sourceGroupState.groups.length > 0) { + const previousActiveGroupId = nextActiveGroupIdByWorktree[sourceWorktreeId] + nextActiveGroupIdByWorktree[sourceWorktreeId] = sourceGroupState.groups.some( + (group) => group.id === previousActiveGroupId + ) + ? previousActiveGroupId + : sourceGroupState.groups[0].id + } else { + delete nextActiveGroupIdByWorktree[sourceWorktreeId] + } + nextActiveGroupIdByWorktree[targetWorktreeId] = targetGroupId + + result = { ok: true, fileId: newFileId } + return { + patch: { + openFiles: s.openFiles.map((file) => + file.id === source.id + ? { + ...file, + id: newFileId, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file.markdownPreviewSourceFileId === source.id + ? { + ...file, + id: previewIdMigrations.get(file.id) ?? file.id, + worktreeId: targetWorktreeId, + relativePath: args.targetRelativePath, + runtimeEnvironmentId: args.targetRuntimeEnvironmentId, + externalSshTargetId, + operationProvenance, + markdownPreviewSourceFileId: newFileId, + pendingOwnerMigration: undefined, + mirroredFromRuntimeSession: undefined + } + : file + ), + editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), + editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), + markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), + editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations), + markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations), + markdownTableOfContentsVisible: rekeyFileIdRecord( + s.markdownTableOfContentsVisible, + migrations + ), + activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null, + activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeTabTypeByWorktree: { + ...s.activeTabTypeByWorktree, + [targetWorktreeId]: 'editor' + }, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + ...(s.pendingEditorReveal?.fileId && migrations.has(s.pendingEditorReveal.fileId) + ? { + pendingEditorReveal: { + ...s.pendingEditorReveal, + fileId: migrations.get(s.pendingEditorReveal.fileId)!, + filePath: source.filePath + } + } + : {}), + ...(s.pendingEditorFocusRequest?.fileId && + migrations.has(s.pendingEditorFocusRequest.fileId) + ? { + pendingEditorFocusRequest: { + ...s.pendingEditorFocusRequest, + fileId: migrations.get(s.pendingEditorFocusRequest.fileId)!, + worktreeId: targetWorktreeId + } + } + : {}), + ...(s.pendingExplorerReveal?.filePath === source.filePath && + s.pendingExplorerReveal.worktreeId === sourceWorktreeId + ? { + pendingExplorerReveal: { + ...s.pendingExplorerReveal, + worktreeId: targetWorktreeId + } + } + : {}) + }, + activate: true, + preferredActiveUnifiedTabId: mappedMovedTabIds.at(-1) + } + } + const initialSource = get().openFiles.find((file) => file.id === args.fileId) + const activatesTargetWorkspace = Boolean( + initialSource && + get().activeWorktreeId === initialSource.worktreeId && + get().activeFileId === initialSource.id + ) + if (activatesTargetWorkspace) { + get().setActiveWorktree(args.targetWorktreeId, args.targetExecutionHostId, { + stateTransition: projectReparent + }) + } else { + set((state) => projectReparent(state).patch) + } + return result + }, + rekeyOpenFilesForPathChange: ({ rekeys, moveOperationId }) => { if (rekeys.length === 0) { return { ok: true } @@ -3409,7 +3801,8 @@ export const createEditorSlice: StateCreator = (s check, details: state.details, loading: state.loading, - error: state.error + error: state.error, + gitlabProjectRef: state.gitlabProjectRef ?? null } set((s) => { const existing = s.openFiles.find((f) => f.id === id) @@ -3458,26 +3851,31 @@ export const createEditorSlice: StateCreator = (s // Why: sidebar detail fetches can finish after the full-details tab is open; update the snapshot without stealing focus. patchOpenCheckRunDetails: (worktreeId, contextKey, check, state) => { const id = buildCheckRunDetailsTabId(worktreeId, check) - const nextCheckRunDetails: OpenCheckRunDetailsState = { - contextKey, - check, - details: state.details, - loading: state.loading, - error: state.error - } set((s) => { const existing = s.openFiles.find((f) => f.id === id) if (!existing?.checkRunDetails) { return s } const current = existing.checkRunDetails + // Why: the sidebar resolves the MR's project asynchronously, so an early patch + // must not blank a ref we already know. + const gitlabProjectRef = state.gitlabProjectRef ?? current.gitlabProjectRef ?? null + const nextCheckRunDetails: OpenCheckRunDetailsState = { + contextKey, + check, + details: state.details, + loading: state.loading, + error: state.error, + gitlabProjectRef + } if ( current.contextKey === nextCheckRunDetails.contextKey && current.check.status === nextCheckRunDetails.check.status && current.check.conclusion === nextCheckRunDetails.check.conclusion && current.loading === nextCheckRunDetails.loading && current.error === nextCheckRunDetails.error && - current.details === nextCheckRunDetails.details + current.details === nextCheckRunDetails.details && + isSameGitLabProjectRef(current.gitlabProjectRef ?? null, gitlabProjectRef) ) { return s } @@ -3503,22 +3901,33 @@ export const createEditorSlice: StateCreator = (s return } const { contextKey, check } = checkRunDetails - const patch = (next: Pick): void => { + const patch = (next: CheckRunDetailsTabPatch): void => { get().patchOpenCheckRunDetails(file.worktreeId, contextKey, check, next) } patch({ details: checkRunDetails.details, loading: true, error: null }) try { - const details = await get().fetchPRCheckDetails( - repo.path, - { - checkRunId: check.checkRunId, - workflowRunId: check.workflowRunId, - checkName: check.name, - url: check.url, - prRepo: null - }, - { repoId: repo.id } - ) + // Why: refreshing a GitLab job tab through the GitHub check-runs API returns + // null and would blank the tab the user just asked to reload. + const details = check.gitlabJobId + ? await loadGitLabJobLogDetails({ + repoPath: repo.path, + repoId: repo.id, + settings: getSettingsForWorktreeRuntimeOwner(state, file.worktreeId), + check, + // Why: a fork MR's job lives in the source project, not the repo's own. + projectRef: checkRunDetails.gitlabProjectRef ?? null + }) + : await get().fetchPRCheckDetails( + repo.path, + { + checkRunId: check.checkRunId, + workflowRunId: check.workflowRunId, + checkName: check.name, + url: check.url, + prRepo: null + }, + { repoId: repo.id } + ) patch({ details, loading: false, diff --git a/src/renderer/src/store/slices/recently-closed-tab-position.ts b/src/renderer/src/store/slices/recently-closed-tab-position.ts new file mode 100644 index 00000000000..69a144534c5 --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tab-position.ts @@ -0,0 +1,167 @@ +import type { AppState } from '../types' + +export type RecentlyClosedTabPosition = { + tabBarIndex?: number + groupId?: string + groupIndex?: number +} + +/** Reusable position lookup for one worktree. Bulk closes resolve a position per + * closed tab; without a shared index every lookup rescans tab order and group + * membership, which makes close-all cubic in the tab count. */ +export type RecentlyClosedTabPositionIndex = { + positionFor: (entityId: string) => RecentlyClosedTabPosition | undefined +} + +export function createRecentlyClosedTabPositionIndex( + state: Pick, + worktreeId: string +): RecentlyClosedTabPositionIndex { + const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] + const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] + const groups = state.groupsByWorktree?.[worktreeId] ?? [] + + // First occurrence wins everywhere, matching the `indexOf`/`find` scans this replaces. + const tabBarIndexByEntityId = new Map() + const tabByEntityId = new Map() + const tabById = new Map() + for (const tab of unifiedTabs) { + if (!tabByEntityId.has(tab.entityId)) { + tabByEntityId.set(tab.entityId, tab) + } + if (!tabById.has(tab.id)) { + tabById.set(tab.id, tab) + } + } + const tabBarEntityIdsByGroupId = new Map() + tabBarOrder?.forEach((entityId, index) => { + if (!tabBarIndexByEntityId.has(entityId)) { + tabBarIndexByEntityId.set(entityId, index) + } + const tab = tabByEntityId.get(entityId) + if (!tab?.groupId) { + return + } + const bucket = tabBarEntityIdsByGroupId.get(tab.groupId) + if (bucket) { + bucket.push(tab.entityId) + } else { + tabBarEntityIdsByGroupId.set(tab.groupId, [tab.entityId]) + } + }) + + const groupById = new Map() + const tabOrderIndexByGroupId = new Map>() + const groupOrderMatchesTabBarByGroupId = new Map() + for (const group of groups) { + if (groupById.has(group.id)) { + continue + } + groupById.set(group.id, group) + const indexByTabId = new Map() + const groupTabEntityIds = group.tabOrder.map((tabId, index) => { + if (!indexByTabId.has(tabId)) { + indexByTabId.set(tabId, index) + } + return tabById.get(tabId)?.entityId + }) + tabOrderIndexByGroupId.set(group.id, indexByTabId) + const tabBarGroupEntityIds = tabBarEntityIdsByGroupId.get(group.id) ?? [] + groupOrderMatchesTabBarByGroupId.set( + group.id, + groupTabEntityIds.length === tabBarGroupEntityIds.length && + groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index]) + ) + } + + return { + positionFor: (entityId) => { + const tabBarIndex = tabBarOrder ? (tabBarIndexByEntityId.get(entityId) ?? -1) : -1 + const unifiedTab = tabByEntityId.get(entityId) + const group = unifiedTab ? groupById.get(unifiedTab.groupId) : undefined + const groupIndex = + group && unifiedTab ? (tabOrderIndexByGroupId.get(group.id)?.get(unifiedTab.id) ?? -1) : -1 + if (tabBarIndex < 0 && (!group || groupIndex < 0)) { + return undefined + } + const groupOrderMatchesTabBar = + !group || (groupOrderMatchesTabBarByGroupId.get(group.id) ?? true) + return { + ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), + ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) + } + } + } +} + +export function getRecentlyClosedTabPosition( + state: Pick, + worktreeId: string, + entityId: string +): RecentlyClosedTabPosition | undefined { + return createRecentlyClosedTabPositionIndex(state, worktreeId).positionFor(entityId) +} + +export function insertTabAtRecentlyClosedPosition( + order: readonly string[], + tabId: string, + position?: RecentlyClosedTabPosition +): string[] { + const nextOrder = order.filter((id) => id !== tabId) + const index = position?.tabBarIndex + if (index === undefined) { + return [...nextOrder, tabId] + } + nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) + return nextOrder +} + +export function restoreRecentlyClosedTabPosition( + getState: () => Pick< + AppState, + | 'tabBarOrderByWorktree' + | 'groupsByWorktree' + | 'unifiedTabsByWorktree' + | 'setTabBarOrder' + | 'reorderUnifiedTabs' + >, + worktreeId: string, + entityId: string, + position?: RecentlyClosedTabPosition +): void { + if (!position) { + return + } + const state = getState() + const order = state.tabBarOrderByWorktree?.[worktreeId] + if (order && typeof state.setTabBarOrder === 'function') { + state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) + } + + if (position?.groupIndex === undefined) { + return + } + const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => + candidate.entityId === entityId && + (position.groupId === undefined || candidate.groupId === position.groupId) + ) + if (!unifiedTab) { + return + } + const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === unifiedTab.groupId + ) + if (!group) { + return + } + if (typeof getState().reorderUnifiedTabs === 'function') { + getState().reorderUnifiedTabs( + group.id, + insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { + tabBarIndex: position.groupIndex + }), + { recordInteraction: false } + ) + } +} diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts index 7faebebfa4f..d7904eb6c8f 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -5,114 +5,21 @@ import { isWindowsAbsolutePathLike, relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { + restoreRecentlyClosedTabPosition, + type RecentlyClosedTabPosition +} from './recently-closed-tab-position' -export type RecentlyClosedTabPosition = { - tabBarIndex?: number - groupId?: string - groupIndex?: number -} - -export function getRecentlyClosedTabPosition( - state: Pick, - worktreeId: string, - entityId: string -): RecentlyClosedTabPosition | undefined { - const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] - const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] - const tabBarIndex = tabBarOrder?.indexOf(entityId) ?? -1 - const unifiedTab = unifiedTabs.find((tab) => tab.entityId === entityId) - const group = unifiedTab - ? (state.groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - : undefined - const groupIndex = group?.tabOrder.indexOf(unifiedTab?.id ?? '') ?? -1 - const groupTabEntityIds = group - ? group.tabOrder.map((tabId) => unifiedTabs.find((tab) => tab.id === tabId)?.entityId) - : [] - const tabBarGroupEntityIds = group - ? (tabBarOrder ?? []) - .map((tabId) => unifiedTabs.find((tab) => tab.entityId === tabId)) - .filter((tab) => tab?.groupId === group.id) - .map((tab) => tab?.entityId) - : [] - const groupOrderMatchesTabBar = - !group || - (groupTabEntityIds.length === tabBarGroupEntityIds.length && - groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index])) - if (tabBarIndex < 0 && (!group || groupIndex < 0)) { - return undefined - } - - return { - ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), - ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) - } -} - -export function insertTabAtRecentlyClosedPosition( - order: readonly string[], - tabId: string, - position?: RecentlyClosedTabPosition -): string[] { - const nextOrder = order.filter((id) => id !== tabId) - const index = position?.tabBarIndex - if (index === undefined) { - return [...nextOrder, tabId] - } - nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) - return nextOrder -} - -export function restoreRecentlyClosedTabPosition( - getState: () => Pick< - AppState, - | 'tabBarOrderByWorktree' - | 'groupsByWorktree' - | 'unifiedTabsByWorktree' - | 'setTabBarOrder' - | 'reorderUnifiedTabs' - >, - worktreeId: string, - entityId: string, - position?: RecentlyClosedTabPosition -): void { - if (!position) { - return - } - const state = getState() - const order = state.tabBarOrderByWorktree?.[worktreeId] - if (order && typeof state.setTabBarOrder === 'function') { - state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) - } - - if (position?.groupIndex === undefined) { - return - } - const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (candidate) => - candidate.entityId === entityId && - (position.groupId === undefined || candidate.groupId === position.groupId) - ) - if (!unifiedTab) { - return - } - const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - if (!group) { - return - } - if (typeof getState().reorderUnifiedTabs === 'function') { - getState().reorderUnifiedTabs( - group.id, - insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { - tabBarIndex: position.groupIndex - }), - { recordInteraction: false } - ) - } -} +export { + createRecentlyClosedTabPositionIndex, + getRecentlyClosedTabPosition, + insertTabAtRecentlyClosedPosition, + restoreRecentlyClosedTabPosition +} from './recently-closed-tab-position' +export type { + RecentlyClosedTabPosition, + RecentlyClosedTabPositionIndex +} from './recently-closed-tab-position' /** Snapshot of a terminal tab captured at user-initiated close time. Reopen * recreates a fresh shell in the same startup directory (Ghostty semantics) — diff --git a/src/renderer/src/store/slices/remote-server-updates.ts b/src/renderer/src/store/slices/remote-server-updates.ts index 909c05e3190..80b8a7370c1 100644 --- a/src/renderer/src/store/slices/remote-server-updates.ts +++ b/src/renderer/src/store/slices/remote-server-updates.ts @@ -33,8 +33,13 @@ const transport: RemoteServerUpdateTransport = { window.api.runtimeEnvironments .getStatus({ selector: environmentId, timeoutMs }) .then((response) => unwrapRuntimeRpcResult(response)), - getUpdaterStatus: (environmentId) => - callRemoteUpdater(environmentId, 'updater.getStatus'), + getUpdaterStatus: (environmentId, timeoutMs) => + callRemoteUpdater( + environmentId, + 'updater.getStatus', + undefined, + timeoutMs + ), check: (environmentId, options) => callRemoteUpdater(environmentId, 'updater.check', options), download: (environmentId) => diff --git a/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts new file mode 100644 index 00000000000..5908c56118a --- /dev/null +++ b/src/renderer/src/store/slices/repos-cross-host-project-collisions.test.ts @@ -0,0 +1,287 @@ +/** + * #11994 follow-up: the host-side `reposChanged` broadcast added by this change makes every + * paired client refetch a remote catalog after any host-local repo mutation, and the client + * now tolerates `repo_not_found` while purging its own row. Both must stay host-scoped. + * + * Two collisions are legal in this codebase and are pinned here: + * - the same project *name* on a local host and one or more remotes (ids are per-host UUIDs); + * - the same repo *id* on two execution hosts (see persistence.ts `removeProjectForHost`). + * Deleting on one host must never take another host's row, in either direction. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from './store-test-helpers' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +vi.mock('sonner', () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } +})) + +const SHARED_NAME = 'Shared Project' + +function repo(id: string, overrides: Partial = {}): Repo { + return { + id, + path: `/hosts/${id}`, + displayName: SHARED_NAME, + badgeColor: '#000', + addedAt: 1, + ...overrides + } as Repo +} + +const localTwin = repo('local-uuid', { + path: '/laptop/shared', + executionHostId: 'local' +}) +const remoteATwin = repo('env-a-uuid', { + path: '/mini/shared', + executionHostId: 'runtime:env-a' +}) +const remoteBTwin = repo('env-b-uuid', { + path: '/server/shared', + executionHostId: 'runtime:env-b' +}) + +const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn<(args: RuntimeCall) => unknown>() + +type RuntimeCall = RuntimeEnvironmentCallRequest & { + selector: string + params?: unknown +} + +/** repo.rm answers `code` for `failingSelector`'s environment and succeeds everywhere else. */ +function answerRepoRm(options: { failingSelector?: string; code?: string } = {}): void { + runtimeEnvironmentCall.mockImplementation((args: RuntimeCall) => { + if (args.method === 'repo.rm' && args.selector === options.failingSelector) { + return { + id: 'rpc-repo-rm', + ok: false, + error: { + code: options.code ?? 'repo_not_found', + message: options.code ?? 'repo_not_found' + }, + _meta: { runtimeId: `runtime-${args.selector}` } + } + } + return { + id: 'rpc', + ok: true, + result: {}, + _meta: { runtimeId: `runtime-${args.selector}` } + } + }) +} + +/** repo.list per environment, so a catalog refresh sees exactly what that host still has. */ +function answerCatalogs(reposByEnvironment: Record): void { + runtimeEnvironmentCall.mockImplementation((args: RuntimeCall) => { + const meta = { runtimeId: `runtime-${args.selector}` } + if (args.method === 'repo.list') { + return { + id: 'rpc-repo-list', + ok: true, + result: { repos: reposByEnvironment[args.selector] ?? [] }, + _meta: meta + } + } + if (args.method === 'project.list') { + return { + id: 'rpc-project-list', + ok: true, + result: { projects: [] }, + _meta: meta + } + } + if (args.method === 'projectHostSetup.list') { + return { + id: 'rpc-setup-list', + ok: true, + result: { setups: [] }, + _meta: meta + } + } + return { id: 'rpc', ok: true, result: {}, _meta: meta } + }) +} + +function seed(repos: readonly Repo[], activeRuntimeEnvironmentId: string | null = null) { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId } as never, + repos: [...repos] + }) + return store +} + +function repoRmCalls(): RuntimeCall[] { + return runtimeEnvironmentTransportCall.mock.calls + .map(([args]) => args) + .filter((args) => args.method === 'repo.rm') +} + +function remainingRepoIds(store: ReturnType): string[] { + return store.getState().repos.map((entry) => entry.id) +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + for (const mock of [ + reposRemove, + reposRemoveForHost, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall + ]) { + mock.mockReset() + } + runtimeEnvironmentTransportCall.mockImplementation( + (args) => createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + answerRepoRm() + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + pty: { kill: vi.fn() }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +describe('deleting one host copy of a same-named project', () => { + it('removes only the remote row when the same name exists locally', async () => { + const store = seed([localTwin, remoteATwin], 'env-a') + + await store.getState().removeProject('env-a-uuid') + + expect(repoRmCalls()).toEqual([ + expect.objectContaining({ + selector: 'env-a', + params: { repo: 'env-a-uuid' } + }) + ]) + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).not.toHaveBeenCalled() + expect(remainingRepoIds(store)).toEqual(['local-uuid']) + }) + + it('removes only the local row when the same name exists on a remote', async () => { + const store = seed([localTwin, remoteATwin], 'env-a') + + await store.getState().removeProject('local-uuid', { hostId: 'local' }) + + expect(reposRemove).toHaveBeenCalledWith({ repoId: 'local-uuid' }) + expect(repoRmCalls()).toEqual([]) + expect(remainingRepoIds(store)).toEqual(['env-a-uuid']) + }) + + it('removes only remote A when the same name exists on remote B', async () => { + const store = seed([remoteATwin, remoteBTwin], 'env-b') + + await store.getState().removeProject('env-a-uuid') + + expect(repoRmCalls()).toEqual([ + expect.objectContaining({ + selector: 'env-a', + params: { repo: 'env-a-uuid' } + }) + ]) + expect(remainingRepoIds(store)).toEqual(['env-b-uuid']) + }) + + it('keeps remote B when remote A reports the project already gone', async () => { + // The reporter's #11994 case with a name collision: the ghost purge is host-scoped. + answerRepoRm({ failingSelector: 'env-a', code: 'repo_not_found' }) + const store = seed([localTwin, remoteATwin, remoteBTwin], 'env-b') + + await store.getState().removeProject('env-a-uuid', { hostId: 'runtime:env-a' }) + + expect(remainingRepoIds(store)).toEqual(['local-uuid', 'env-b-uuid']) + }) +}) + +describe('deleting one host copy of a project whose id exists on two hosts', () => { + const duplicateLocal = repo('dup-id', { + path: '/laptop/dup', + executionHostId: 'local' + }) + const duplicateRemote = repo('dup-id', { + path: '/mini/dup', + executionHostId: 'runtime:env-a' + }) + + it('scopes the local removal to the local host instead of the bare id', async () => { + const store = seed([duplicateLocal, duplicateRemote], 'env-a') + + await store.getState().removeProject('dup-id', { hostId: 'local' }) + + // repos:remove is id-only and would delete every host's row in main persistence. + expect(reposRemove).not.toHaveBeenCalled() + expect(reposRemoveForHost).toHaveBeenCalledWith({ + repoId: 'dup-id', + hostId: 'local' + }) + expect(store.getState().repos.map((entry) => entry.path)).toEqual(['/mini/dup']) + }) + + it('leaves the local row when the remote copy is removed', async () => { + const store = seed([duplicateLocal, duplicateRemote], 'env-a') + + await store.getState().removeProject('dup-id', { hostId: 'runtime:env-a' }) + + expect(repoRmCalls()).toEqual([ + expect.objectContaining({ + selector: 'env-a', + params: { repo: 'dup-id' } + }) + ]) + expect(reposRemove).not.toHaveBeenCalled() + expect(store.getState().repos.map((entry) => entry.path)).toEqual(['/laptop/dup']) + }) + + it('keeps the row when the owner cannot disambiguate a duplicate id', async () => { + // A host holding the id on two of its own hosts answers selector_ambiguous; that is a + // real failure, not a ghost, so the tolerance must not swallow it. + answerRepoRm({ failingSelector: 'env-a', code: 'selector_ambiguous' }) + const store = seed([duplicateLocal, duplicateRemote], 'env-a') + + await store.getState().removeProject('dup-id', { hostId: 'runtime:env-a' }) + + expect(store.getState().repos.map((entry) => entry.path)).toEqual(['/laptop/dup', '/mini/dup']) + }) +}) + +describe('refetching one host catalog after its own delete', () => { + it('prunes only that host rows when the name exists on other hosts', async () => { + answerCatalogs({ 'env-a': [], 'env-b': [remoteBTwin] }) + const store = seed([localTwin, remoteATwin, remoteBTwin], 'env-a') + + await store.getState().fetchRuntimeEnvironmentRepos('env-a') + + expect(remainingRepoIds(store)).toEqual(['local-uuid', 'env-b-uuid']) + }) + + it('keeps another host row that shares the deleted repo id', async () => { + const duplicateLocal = repo('dup-id', { + path: '/laptop/dup', + executionHostId: 'local' + }) + const duplicateRemote = repo('dup-id', { + path: '/mini/dup', + executionHostId: 'runtime:env-a' + }) + answerCatalogs({ 'env-a': [] }) + const store = seed([duplicateLocal, duplicateRemote], 'env-a') + + await store.getState().fetchRuntimeEnvironmentRepos('env-a') + + expect(store.getState().repos.map((entry) => entry.path)).toEqual(['/laptop/dup']) + }) +}) diff --git a/src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts b/src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts new file mode 100644 index 00000000000..ef9c5bd7431 --- /dev/null +++ b/src/renderer/src/store/slices/repos-remove-missing-remote-project.test.ts @@ -0,0 +1,144 @@ +/** + * #11994: a project deleted on the paired Orca host stayed in the client's sidebar and + * could not be removed there. `repo.rm` answers `repo_not_found` (it is already gone on + * the host) and `removeProject` wrapped its whole body in one try/catch, so the rejection + * aborted the local purge before the `set()` — the ghost row survived and nothing was + * surfaced to the user. Only `repo_not_found` is tolerated; any other failure must keep + * the row, and the error toast is opt-in so bulk/background callers stay silent. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { createTestStore } from './store-test-helpers' +import type { Repo } from '../../../../shared/types' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) + +const staleRemoteRepo: Repo = { + id: 'project-b', + path: '/Users/mini/project-b', + displayName: 'Project B', + badgeColor: '#000', + addedAt: 1, + executionHostId: 'runtime:env-1' +} + +const liveRemoteRepo: Repo = { + id: 'project-a', + path: '/Users/mini/project-a', + displayName: 'Project A', + badgeColor: '#111', + addedAt: 2, + executionHostId: 'runtime:env-1' +} + +const localTwinRepo: Repo = { + id: 'project-b', + path: '/Users/laptop/project-b', + displayName: 'Project B (local)', + badgeColor: '#222', + addedAt: 3 +} + +const reposRemove = vi.fn() +const reposRemoveForHost = vi.fn() +const ptyKill = vi.fn() +const runtimeEnvironmentCall = vi.fn() +const runtimeEnvironmentTransportCall = vi.fn() + +function answerRepoRmWith(code: string): void { + runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.rm') { + return { + id: 'rpc-repo-rm', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-remote' } + } + } + return { id: 'rpc-other', ok: true, result: {}, _meta: { runtimeId: 'runtime-remote' } } + }) +} + +function seedRemoteProjects(repos: readonly Repo[]): ReturnType { + const store = createTestStore() + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + repos: [...repos] + }) + return store +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.mocked(toast.error).mockReset() + for (const mock of [ + reposRemove, + reposRemoveForHost, + ptyKill, + runtimeEnvironmentCall, + runtimeEnvironmentTransportCall + ]) { + mock.mockReset() + } + runtimeEnvironmentTransportCall.mockImplementation( + (args: RuntimeEnvironmentCallRequest) => + createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args) + ) + vi.stubGlobal('window', { + api: { + repos: { remove: reposRemove, removeForHost: reposRemoveForHost }, + pty: { kill: ptyKill }, + runtimeEnvironments: { call: runtimeEnvironmentTransportCall }, + ui: { set: vi.fn().mockResolvedValue(undefined) } + } + }) +}) + +describe('removeProject when the owning host already dropped the project', () => { + it('drops the ghost row when the remote reports repo_not_found', async () => { + answerRepoRmWith('repo_not_found') + const store = seedRemoteProjects([liveRemoteRepo, staleRemoteRepo]) + + await store.getState().removeProject('project-b', { hostId: 'runtime:env-1' }) + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['project-a']) + }) + + it('keeps the row and stays silent when the remote fails for another reason', async () => { + answerRepoRmWith('runtime_unavailable') + const store = seedRemoteProjects([liveRemoteRepo, staleRemoteRepo]) + + await store.getState().removeProject('project-b', { hostId: 'runtime:env-1' }) + + expect(store.getState().repos.map((repo) => repo.id)).toEqual(['project-a', 'project-b']) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('toasts once for a genuine failure when the caller opts in', async () => { + answerRepoRmWith('runtime_unavailable') + const store = seedRemoteProjects([liveRemoteRepo, staleRemoteRepo]) + + await store + .getState() + .removeProject('project-b', { hostId: 'runtime:env-1', errorFeedback: 'toast' }) + + expect(toast.error).toHaveBeenCalledTimes(1) + }) + + it('leaves a same-id project on another host untouched', async () => { + answerRepoRmWith('repo_not_found') + const store = seedRemoteProjects([liveRemoteRepo, staleRemoteRepo, localTwinRepo]) + + await store.getState().removeProject('project-b', { hostId: 'runtime:env-1' }) + + expect(store.getState().repos.map((repo) => repo.path)).toEqual([ + '/Users/mini/project-a', + '/Users/laptop/project-b' + ]) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 2c80b970dc6..f8fa9ebc61e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -66,6 +66,7 @@ import { assertRuntimeEnvironmentCapability, callRuntimeRpc, getActiveRuntimeTarget, + hasRuntimeRpcErrorCode, settingsForRuntimeOwner } from '../../runtime/runtime-rpc-client' import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client' @@ -1719,7 +1720,11 @@ export type RepoSlice = { order?: number ) => Promise // options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed. - removeProject: (projectId: string, options?: { hostId?: ExecutionHostId }) => Promise + // options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting. + removeProject: ( + projectId: string, + options?: { hostId?: ExecutionHostId; errorFeedback?: 'toast' | 'silent' } + ) => Promise updateProject: (projectId: string, updates: ProjectUpdate) => Promise // options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the focused host is assumed. updateRepo: ( @@ -3307,11 +3312,18 @@ export const createRepoSlice: StateCreator = (set, const idExistsOnOtherHost = get().repos.some( (repo) => repo.id === projectId && getRepoExecutionHostId(repo) !== ownerHostId ) - await (target.kind === 'local' - ? idExistsOnOtherHost - ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) - : window.api.repos.remove({ repoId: projectId }) - : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + try { + await (target.kind === 'local' + ? idExistsOnOtherHost + ? window.api.repos.removeForHost({ repoId: projectId, hostId: ownerHostId }) + : window.api.repos.remove({ repoId: projectId }) + : callRuntimeRpc(target, 'repo.rm', { repo: projectId }, { timeoutMs: 15_000 })) + } catch (err) { + // Why: the owner already dropped this project, so purge the local ghost row instead of aborting (#11994). + if (!hasRuntimeRpcErrorCode(err, 'repo_not_found')) { + throw err + } + } get().clearOrcaHookTrustForRepo(projectId) const repoPath = get().repos.find((repo) => @@ -3454,6 +3466,16 @@ export const createRepoSlice: StateCreator = (set, }) } catch (err) { console.error('Failed to remove repo:', err) + // Why: bulk and background callers aggregate their own failures, so only opted-in single-project entry points toast (#11994). + if (options?.errorFeedback === 'toast') { + toast.error( + translate('auto.store.slices.repos.removeProjectFailed', 'Failed to remove project'), + { + description: err instanceof Error ? err.message : String(err), + duration: ERROR_TOAST_DURATION + } + ) + } } }, diff --git a/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts new file mode 100644 index 00000000000..d3e8f37f15f --- /dev/null +++ b/src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts @@ -0,0 +1,683 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { useAppStore } from '@/store' +import { buildEditorSessionData } from '@/lib/workspace-session' +import { + createExternalWatchEventHandler, + getEditorExternalWatchTargets, + useEditorExternalWatch +} from '@/hooks/useEditorExternalWatch' +import { + captureEditorFileOperationProvenance, + getEditorFileOperationContext +} from '@/lib/editor-file-operation-owner' +import type { AppState } from '@/store/types' +import type { WorkspaceSessionState } from '../../../../shared/types' + +const SOURCE = 'repo-a::/repo-a' +const TARGET = 'repo-b::/repo-b' +const FILE_PATH = '/repo-b/docs/readme.md' + +function WatchProbe(): null { + useEditorExternalWatch() + return null +} + +function installWorkspaceState(): void { + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + activeWorktreeId: SOURCE, + settings: { editorAutoSave: false }, + repos: [ + { id: 'repo-a', path: '/repo-a', kind: 'git', executionHostId: 'local' }, + { id: 'repo-b', path: '/repo-b', kind: 'git', executionHostId: 'local' } + ], + worktreesByRepo: { + 'repo-a': [{ id: SOURCE, repoId: 'repo-a', path: '/repo-a', hostId: 'local', branch: '' }], + 'repo-b': [{ id: TARGET, repoId: 'repo-b', path: '/repo-b', hostId: 'local', branch: '' }] + }, + detectedWorktreesByRepo: {}, + runtimeEnvironments: [], + runtimeEnvironmentCatalogHydrated: true, + removedRuntimeEnvironmentIds: new Set(), + sshConnectionStates: new Map(), + sshStateByEnvironment: new Map(), + folderWorkspaces: [], + projectGroups: [], + rightSidebarOpen: false, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + gitStatusHugeByWorktree: {} + } as unknown as Partial) +} + +function openRestoredSource(): string { + return useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: FILE_PATH, + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit', + pendingDiskBaselineVerification: true + }, + { suppressActiveRuntimeFallback: true } + ) +} + +function reparent(fileId: string) { + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(fileId, true) + return state.reparentRestoredEditorFileOwner({ + fileId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(state, TARGET, null, true) + }) +} + +describe('restored editor owner reparent', () => { + beforeEach(() => { + vi.restoreAllMocks() + installWorkspaceState() + }) + + afterEach(() => vi.useRealTimers()) + + it('atomically migrates ownership, ids, placement, views, previews, and provenance', () => { + const oldId = openRestoredSource() + const state = useAppStore.getState() + state.setEditorDraft(oldId, 'edited draft') + state.markFileDirty(oldId, true) + state.setEditorCursorLine(oldId, 37) + state.setMarkdownViewMode(oldId, 'rich') + state.setEditorViewMode(oldId, 'changes') + state.setMarkdownFrontmatterVisible(oldId, false) + state.setMarkdownTableOfContentsVisible(oldId, true) + useAppStore.setState((current) => ({ + pendingEditorReveal: { + fileId: oldId, + filePath: FILE_PATH, + line: 4, + column: 2, + matchLength: 0 + }, + pendingEditorFocusRequest: { + fileId: oldId, + worktreeId: SOURCE, + viewStateId: 'view-1', + expiresAt: Date.now() + 1_000, + token: 1 + }, + pendingExplorerReveal: { worktreeId: SOURCE, filePath: FILE_PATH, requestId: 1 }, + openFiles: [ + ...current.openFiles, + { + ...current.openFiles[0], + id: 'preview-1', + mode: 'markdown-preview' as const, + markdownPreviewSourceFileId: oldId, + isDirty: false + } + ] + })) + + const result = reparent(oldId) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const next = useAppStore.getState() + const moved = next.openFiles.find((file) => file.id === result.fileId)! + expect(moved).toMatchObject({ + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + isDirty: true, + pendingDiskBaselineVerification: true + }) + expect(moved.pendingOwnerMigration).toBeUndefined() + expect(moved.operationProvenance?.generation.route).toEqual({ + executionHostId: 'local', + runtimeEnvironmentId: null + }) + expect(getEditorFileOperationContext(next, moved, '/repo-b').worktreeId).toBe(TARGET) + expect(next.editorDrafts).toEqual({ [result.fileId]: 'edited draft' }) + expect(next.editorCursorLine[result.fileId]).toBe(37) + expect(next.markdownViewMode[result.fileId]).toBe('rich') + expect(next.editorViewMode[result.fileId]).toBe('changes') + expect(next.markdownFrontmatterVisible[result.fileId]).toBe(false) + expect(next.markdownTableOfContentsVisible[result.fileId]).toBe(true) + expect(next.openFiles.find((file) => file.mode === 'markdown-preview')).toMatchObject({ + id: `markdown-preview::${result.fileId}`, + markdownPreviewSourceFileId: result.fileId, + worktreeId: TARGET, + relativePath: 'docs/readme.md' + }) + expect(next.unifiedTabsByWorktree[SOURCE]).toEqual([]) + const movedTabId = next.unifiedTabsByWorktree[TARGET]?.[0]?.id + expect(next.unifiedTabsByWorktree[TARGET]?.[0]?.entityId).toBe(result.fileId) + expect(next.groupsByWorktree[TARGET]?.[0]).toMatchObject({ + activeTabId: movedTabId, + tabOrder: [movedTabId] + }) + expect(next.tabBarOrderByWorktree[TARGET]).toEqual([result.fileId]) + expect(next.activeFileIdByWorktree[SOURCE]).toBeNull() + expect(next.activeFileIdByWorktree[TARGET]).toBe(result.fileId) + expect(next.activeWorktreeId).toBe(TARGET) + expect(next.activeWorkspaceExecutionHostId).toBe('local') + expect(next.activeRepoId).toBe('repo-b') + expect(next.activeGroupIdByWorktree[SOURCE]).toBeUndefined() + expect(next.pendingEditorReveal?.fileId).toBe(result.fileId) + expect(next.pendingEditorFocusRequest).toMatchObject({ + fileId: result.fileId, + worktreeId: TARGET + }) + expect(next.pendingExplorerReveal?.worktreeId).toBe(TARGET) + }) + + it('does not retarget an explorer reveal owned by another workspace', () => { + const oldId = openRestoredSource() + useAppStore.setState({ + pendingExplorerReveal: { worktreeId: 'other-workspace', filePath: FILE_PATH, requestId: 1 } + }) + + expect(reparent(oldId).ok).toBe(true) + expect(useAppStore.getState().pendingExplorerReveal?.worktreeId).toBe('other-workspace') + }) + + it('commits active reparenting through the complete workspace activation projection', () => { + const oldId = openRestoredSource() + const refreshGitHubForWorktreeIfStale = vi.fn() + useAppStore.setState({ + activeTabId: 'source-terminal', + activeBrowserTabId: 'source-browser', + activePendingCreationId: 'source-creation', + rightSidebarExplorerView: 'files', + rightSidebarExplorerViewByWorktree: { [TARGET]: 'search' }, + tabsByWorktree: { + [SOURCE]: [ + { + id: 'source-terminal', + ptyId: null, + worktreeId: SOURCE, + title: 'Source terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 1 + } + ], + [TARGET]: [ + { + id: 'target-terminal', + ptyId: null, + worktreeId: TARGET, + title: 'Target terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + generation: 4 + } + ] + }, + activeTabIdByWorktree: { + [SOURCE]: 'source-terminal', + [TARGET]: 'target-terminal' + }, + unifiedTabsByWorktree: { + ...useAppStore.getState().unifiedTabsByWorktree, + [TARGET]: [ + { + id: 'target-terminal', + entityId: 'target-terminal', + groupId: 'target-group', + worktreeId: TARGET, + contentType: 'terminal', + label: 'Target terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + groupsByWorktree: { + ...useAppStore.getState().groupsByWorktree, + [TARGET]: [ + { + id: 'target-group', + worktreeId: TARGET, + activeTabId: 'target-terminal', + tabOrder: ['target-terminal'] + } + ] + }, + layoutByWorktree: { + ...useAppStore.getState().layoutByWorktree, + [TARGET]: { type: 'leaf', groupId: 'target-group' } + }, + activeGroupIdByWorktree: { + ...useAppStore.getState().activeGroupIdByWorktree, + [TARGET]: 'target-group' + }, + browserTabsByWorktree: { + [SOURCE]: [{ id: 'source-browser' }], + [TARGET]: [{ id: 'target-browser' }] + }, + activeBrowserTabIdByWorktree: { + [SOURCE]: 'source-browser', + [TARGET]: 'target-browser' + }, + everActivatedWorktreeIds: new Set([SOURCE]), + refreshGitHubForWorktreeIfStale + } as unknown as Partial) + const targetSnapshots: AppState[] = [] + const unsubscribe = useAppStore.subscribe((state) => { + if (state.activeWorktreeId === TARGET) { + targetSnapshots.push(state) + } + }) + + const result = reparent(oldId) + unsubscribe() + + expect(result.ok).toBe(true) + expect(targetSnapshots).toHaveLength(1) + const activated = targetSnapshots[0] + expect(activated).toMatchObject({ + activeWorktreeId: TARGET, + activeRepoId: 'repo-b', + activeWorkspaceExecutionHostId: 'local', + activeTabId: 'target-terminal', + activeBrowserTabId: 'target-browser', + activePendingCreationId: null, + rightSidebarExplorerView: 'search', + activeTabType: 'editor' + }) + expect(activated.activeFileId).toBe(result.ok ? result.fileId : null) + expect(activated.everActivatedWorktreeIds).toEqual(new Set([SOURCE, TARGET])) + expect(activated.tabsByWorktree[TARGET]?.[0]).toMatchObject({ + id: 'target-terminal', + generation: 5 + }) + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledOnce() + expect(refreshGitHubForWorktreeIfStale).toHaveBeenCalledWith(TARGET) + }) + + it('persists and restores the destination owner and dirty hot-exit draft', () => { + const oldId = openRestoredSource() + useAppStore.getState().setEditorDraft(oldId, 'hot exit') + useAppStore.getState().markFileDirty(oldId, true) + const result = reparent(oldId) + expect(result.ok).toBe(true) + + const state = useAppStore.getState() + const session = buildEditorSessionData( + state.openFiles, + state.editorDrafts, + state.markdownFrontmatterVisible, + state.activeFileIdByWorktree, + state.activeTabTypeByWorktree + ) + expect(session.openFilesByWorktree?.[SOURCE]).toBeUndefined() + expect(session.openFilesByWorktree?.[TARGET]?.[0]).toMatchObject({ + relativePath: 'docs/readme.md', + dirtyDraftContent: 'hot exit' + }) + + installWorkspaceState() + useAppStore.getState().hydrateEditorSession(session as WorkspaceSessionState) + const restored = useAppStore.getState().openFiles[0] + expect(restored).toMatchObject({ + worktreeId: TARGET, + relativePath: 'docs/readme.md', + isDirty: true + }) + expect(useAppStore.getState().editorDrafts[restored.id]).toBe('hot exit') + }) + + it.each([false, true])('fails closed on a %s destination collision', (dirtyDestination) => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + if (dirtyDestination) { + useAppStore.getState().setEditorDraft(destinationId, 'destination draft') + useAppStore.getState().markFileDirty(destinationId, true) + } + const before = useAppStore.getState().openFiles + + expect(reparent(sourceId)).toEqual({ ok: false, reason: 'collision' }) + expect(useAppStore.getState().openFiles.map((file) => file.id)).toEqual( + before.map((file) => file.id) + ) + expect(useAppStore.getState().openFiles.find((file) => file.id === sourceId)?.worktreeId).toBe( + SOURCE + ) + }) + + it('leaves destination tabs and groups byte-identical when activation hits a collision', () => { + const sourceId = openRestoredSource() + const destinationId = useAppStore.getState().openFile( + { + filePath: FILE_PATH, + relativePath: 'docs/readme.md', + worktreeId: TARGET, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const destinationTab = useAppStore + .getState() + .unifiedTabsByWorktree[TARGET]?.find((tab) => tab.entityId === destinationId) + expect(destinationTab).toBeDefined() + const staleTab = { + ...destinationTab!, + id: 'stale-target-tab', + entityId: 'stale-target-file' + } + useAppStore.setState((state) => ({ + activeWorktreeId: SOURCE, + activeFileId: sourceId, + unifiedTabsByWorktree: { + ...state.unifiedTabsByWorktree, + [TARGET]: [...(state.unifiedTabsByWorktree[TARGET] ?? []), staleTab] + }, + groupsByWorktree: { + ...state.groupsByWorktree, + [TARGET]: (state.groupsByWorktree[TARGET] ?? []).map((group) => + group.id === staleTab.groupId + ? { + ...group, + activeTabId: staleTab.id, + tabOrder: [...group.tabOrder, staleTab.id], + recentTabIds: [...(group.recentTabIds ?? []), staleTab.id] + } + : group + ) + } + })) + const state = useAppStore.getState() + state.setRestoredEditorOwnerMigrationPending(sourceId, true) + const before = useAppStore.getState() + + const result = before.reparentRestoredEditorFileOwner({ + fileId: sourceId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance(before, TARGET, null, true) + }) + + expect(result).toEqual({ ok: false, reason: 'collision' }) + const after = useAppStore.getState() + expect(after.unifiedTabsByWorktree).toBe(before.unifiedTabsByWorktree) + expect(after.groupsByWorktree).toBe(before.groupsByWorktree) + expect(after.layoutByWorktree).toBe(before.layoutByWorktree) + expect(after.activeGroupIdByWorktree).toBe(before.activeGroupIdByWorktree) + expect(after.activeWorktreeId).toBe(SOURCE) + expect(after.activeFileId).toBe(sourceId) + }) + + it('moves the watcher projection from source to destination without fanout', () => { + const oldId = openRestoredSource() + const before = getEditorExternalWatchTargets(useAppStore.getState()) + const result = reparent(oldId) + expect(result.ok).toBe(true) + const after = getEditorExternalWatchTargets(useAppStore.getState()) + + expect(before.targets.map((target) => target.worktreeId)).toEqual([SOURCE]) + expect(after.targets).toEqual([ + { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('unsubscribes the source watch once and subscribes the destination once', async () => { + const watchWorktree = vi.fn().mockResolvedValue(undefined) + const unwatchWorktree = vi.fn().mockResolvedValue(undefined) + const previousApi = (window as unknown as { api?: unknown }).api + ;(window as unknown as { api: unknown }).api = { + fs: { + watchWorktree, + unwatchWorktree, + onFsChanged: vi.fn(() => vi.fn()) + } + } + const oldId = openRestoredSource() + const container = document.body.appendChild(document.createElement('div')) + const root = createRoot(container) + await act(async () => root.render(createElement(WatchProbe))) + await vi.waitFor(() => expect(watchWorktree).toHaveBeenCalledTimes(1)) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + + await act(async () => { + expect(reparent(oldId).ok).toBe(true) + }) + await vi.waitFor(() => { + expect(unwatchWorktree).toHaveBeenCalledTimes(1) + expect(watchWorktree).toHaveBeenCalledTimes(2) + }) + expect(unwatchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-a', + connectionId: undefined + }) + expect(watchWorktree).toHaveBeenLastCalledWith({ + worktreePath: '/repo-b', + connectionId: undefined + }) + + await act(async () => root.unmount()) + container.remove() + ;(window as unknown as { api?: unknown }).api = previousApi + }) + + it('reparents into a folder workspace with local save and watch authority', () => { + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'notes', + projectGroupId: 'group-notes', + folderPath: '/notes', + connectionId: null, + executionHostId: 'local' + } + ], + projectGroups: [{ id: 'group-notes', name: 'Notes', executionHostId: 'local' }] + } as unknown as Partial) + const oldId = useAppStore.getState().openFile( + { + filePath: '/notes/todo.md', + relativePath: '/notes/todo.md', + worktreeId: SOURCE, + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: 'folder:notes', + targetRelativePath: 'todo.md', + targetExecutionHostId: 'local', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + 'folder:notes', + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const state = useAppStore.getState() + const moved = state.openFiles.find((file) => file.id === result.fileId)! + expect(getEditorFileOperationContext(state, moved, null)).toMatchObject({ + worktreeId: 'folder:notes', + worktreePath: '/notes', + expectedExecutionHostId: 'local' + }) + expect(getEditorExternalWatchTargets(state).targets).toEqual([ + { + worktreeId: 'folder:notes', + worktreePath: '/notes', + connectionId: undefined, + runtimeEnvironmentId: null + } + ]) + }) + + it('captures exact direct-SSH host and connection-generation authority', () => { + useAppStore.setState((state) => ({ + repos: state.repos.map((repo) => ({ + ...repo, + connectionId: 'ssh-1', + executionHostId: 'ssh:ssh-1' + })), + worktreesByRepo: Object.fromEntries( + Object.entries(state.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => ({ ...worktree, hostId: 'ssh:ssh-1' as const })) + ]) + ), + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connected' as const, + error: null, + reconnectAttempt: 0, + connectionGeneration: 7 + } + ] + ]) + })) + const oldId = openRestoredSource() + useAppStore.getState().setRestoredEditorOwnerMigrationPending(oldId, true) + const result = useAppStore.getState().reparentRestoredEditorFileOwner({ + fileId: oldId, + targetWorktreeId: TARGET, + targetRelativePath: 'docs/readme.md', + targetExecutionHostId: 'ssh:ssh-1', + targetRuntimeEnvironmentId: null, + targetOperationProvenance: captureEditorFileOperationProvenance( + useAppStore.getState(), + TARGET, + null, + true + ) + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId) + ).toMatchObject({ + externalSshTargetId: 'ssh-1', + operationProvenance: { + expectedSshConnectionGeneration: 7, + generation: { + route: { executionHostId: 'ssh:ssh-1', runtimeEnvironmentId: null } + } + } + }) + }) + + it('correlates destination change, delete, and rename events after reparent', () => { + vi.useFakeTimers() + const oldId = openRestoredSource() + const result = reparent(oldId) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const target = { + worktreeId: TARGET, + worktreePath: '/repo-b', + connectionId: undefined, + runtimeEnvironmentId: null + } + const externalChanges: unknown[] = [] + const listener = (event: Event): void => { + externalChanges.push((event as CustomEvent).detail) + } + window.addEventListener('orca:editor-external-file-change', listener) + const watcher = createExternalWatchEventHandler((path, owner) => + path === target.worktreePath && owner === null ? target : undefined + ) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'update', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect(externalChanges).toContainEqual({ + worktreeId: TARGET, + worktreePath: '/repo-b', + relativePath: 'docs/readme.md', + runtimeEnvironmentId: null + }) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [{ kind: 'delete', absolutePath: FILE_PATH }] + }) + vi.advanceTimersByTime(100) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('deleted') + useAppStore.getState().setExternalMutation(result.fileId, null) + + watcher.handleFsChanged({ + worktreePath: '/repo-b', + events: [ + { kind: 'delete', absolutePath: FILE_PATH }, + { kind: 'create', absolutePath: '/repo-b/archive/readme.md' } + ] + }) + expect( + useAppStore.getState().openFiles.find((file) => file.id === result.fileId)?.externalMutation + ).toBe('renamed') + + watcher.dispose() + window.removeEventListener('orca:editor-external-file-change', listener) + vi.useRealTimers() + }) +}) diff --git a/src/renderer/src/store/slices/store-cascades.test.ts b/src/renderer/src/store/slices/store-cascades.test.ts index 57bf015adec..1380c311579 100644 --- a/src/renderer/src/store/slices/store-cascades.test.ts +++ b/src/renderer/src/store/slices/store-cascades.test.ts @@ -5319,7 +5319,7 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) }) - it('skips allowlisted done live sleeping pane sessions during manual sleep', async () => { + it('captures allowlisted done live sleeping pane sessions during manual sleep', async () => { const store = createTestStore() const wt = 'repo1::/path/wt1' @@ -5370,7 +5370,12 @@ describe('shutdownWorktreeTerminals (sleep) — agent status hygiene', () => { }) const state = store.getState() - expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toBeUndefined() + expect(state.sleepingAgentSessionsByPaneKey['tab-1:live']).toMatchObject({ + origin: 'worktree-sleep', + state: 'done', + providerSession: { key: 'session_id', id: 'live-session' } + }) + // Outside the allowlist, so manual sleep never claimed it. expect(state.sleepingAgentSessionsByPaneKey['tab-1:retained']).toBeUndefined() }) diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 72e22adc136..5a2456c24c0 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -616,6 +616,215 @@ function activeSurfacePatchMatchesState( ) } +export type WorktreeTabModelReconciliation = { + patch: Partial + renderableTabCount: number + activeRenderableTabId: string | null +} + +export function projectWorktreeTabModelReconciliation( + state: AppState, + worktreeId: string +): WorktreeTabModelReconciliation { + const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const groups = state.groupsByWorktree[worktreeId] ?? [] + const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] + const unifiedTerminalEntityIds = new Set( + unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) + ) + const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { + if (unifiedTerminalEntityIds.has(tab.id)) { + return false + } + // Why: reconnectable legacy tabs must re-enter the unified model instead of being orphaned. + return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) + }) + const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) + const ensuredGroupState = + legacyRuntimeTerminalTabs.length > 0 + ? ensureGroup( + state.groupsByWorktree, + state.activeGroupIdByWorktree, + worktreeId, + state.activeGroupIdByWorktree[worktreeId] + ) + : null + const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null + const restoredLegacyTabs = + reconciliationGroup == null + ? [] + : legacyRuntimeTerminalTabs + .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) + .map((tab) => ({ + id: tab.id, + entityId: tab.id, + groupId: reconciliationGroup.id, + worktreeId, + contentType: 'terminal' as const, + label: tab.title, + ...(tab.quickCommandLabel?.trim() + ? { quickCommandLabel: tab.quickCommandLabel.trim() } + : {}), + ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), + customLabel: tab.customTitle, + color: tab.color, + sortOrder: tab.sortOrder, + createdAt: tab.createdAt + })) + const reconciledUnifiedTabs = + restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs + const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] + const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) + const legacyFallbackActiveTabId = + rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) + ? rememberedLegacyActiveTabId + : (restoredLegacyTabs[0]?.id ?? null) + const reconciledGroups = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { + ...reconciliationGroup, + // Why: restore runtime tabs into the active/root group so reattach cannot spawn a duplicate. + activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, + tabOrder: dedupeTabOrder([ + ...reconciliationGroup.tabOrder, + ...restoredLegacyTabs.map((tab) => tab.id) + ]) + }) + : groups + const liveTerminalIds = new Set( + runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) + ) + const liveEditorIds = new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) + const liveBrowserIds = new Set( + (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) + ) + + const isRenderableTab = (tab: Tab): boolean => { + if (tab.contentType === 'terminal') { + return liveTerminalIds.has(tab.entityId) + } + if (tab.contentType === 'browser') { + return liveBrowserIds.has(tab.entityId) + } + if (tab.contentType === 'simulator') { + return true + } + return liveEditorIds.has(tab.entityId) + } + + const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) + const validTabIds = new Set(validTabs.map((tab) => tab.id)) + const nextGroupsWithEmpty = reconciledGroups.map((group) => { + const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) + const activeTabId = + group.activeTabId && validTabIds.has(group.activeTabId) + ? group.activeTabId + : (tabOrder[0] ?? null) + const tabOrderUnchanged = + tabOrder.length === group.tabOrder.length && + tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) + const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) + const recentUnchanged = + recentTabIds.length === (group.recentTabIds ?? []).length && + recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) + return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged + ? group + : { ...group, tabOrder, activeTabId, recentTabIds } + }) + const nextGroups = + validTabs.length > 0 + ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) + : nextGroupsWithEmpty + const currentActiveGroupId = + state.activeGroupIdByWorktree[worktreeId] ?? + ensuredGroupState?.activeGroupIdByWorktree[worktreeId] + const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) + const nextActiveGroupId = activeGroupStillExists + ? currentActiveGroupId + : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? + nextGroups[0]?.id ?? + currentActiveGroupId) + const groupsChanged = + nextGroups.length !== groups.length || + nextGroups.some((group, index) => group !== groups[index]) + const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 + const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId + const baseNextLayout = + restoredLegacyTabs.length > 0 && reconciliationGroup + ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) + : state.layoutByWorktree[worktreeId] + const validGroupIds = new Set(nextGroups.map((group) => group.id)) + const prunedNextLayout = + baseNextLayout && validGroupIds.size > 0 + ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) + : baseNextLayout + const nextLayout = + prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) + const currentLayout = state.layoutByWorktree[worktreeId] + const layoutChanged = nextLayout !== currentLayout + let patch: Partial = {} + + if ( + tabsChanged || + groupsChanged || + activeGroupChanged || + layoutChanged || + orphanTerminalIds.size > 0 + ) { + const droppedTerminalEntityIds = unifiedTabs.flatMap((tab) => + tab.contentType === 'terminal' && !validTabIds.has(tab.id) ? [tab.entityId] : [] + ) + let nextUnreadTerminalTabs = state.unreadTerminalTabs + if (droppedTerminalEntityIds.length > 0) { + const copy = { ...state.unreadTerminalTabs } + let changed = false + for (const entityId of droppedTerminalEntityIds) { + if (copy[entityId]) { + delete copy[entityId] + changed = true + } + } + if (changed) { + nextUnreadTerminalTabs = copy + } + } + patch = { + unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, + groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, + activeGroupIdByWorktree: { + ...state.activeGroupIdByWorktree, + [worktreeId]: nextActiveGroupId + }, + ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs + ? { unreadTerminalTabs: nextUnreadTerminalTabs } + : {}), + ...(nextLayout && layoutChanged + ? { + layoutByWorktree: { + ...state.layoutByWorktree, + // Why: restored runtime terminals need a concrete leaf before activation. + [worktreeId]: nextLayout + } + } + : {}), + ...(orphanTerminalIds.size > 0 + ? buildOrphanTerminalCleanupPatch(state, worktreeId, orphanTerminalIds) + : {}) + } + } + + return { + patch, + renderableTabCount: validTabs.length, + activeRenderableTabId: + nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? + nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? + null + } +} + export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, renamingTabId: null, @@ -1807,220 +2016,13 @@ export const createTabsSlice: StateCreator = (set, }, reconcileWorktreeTabModel: (worktreeId) => { - const state = get() - const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groups = state.groupsByWorktree[worktreeId] ?? [] - const runtimeTerminalTabs = state.tabsByWorktree[worktreeId] ?? [] - const unifiedTerminalEntityIds = new Set( - unifiedTabs.filter((tab) => tab.contentType === 'terminal').map((tab) => tab.entityId) - ) - const legacyRuntimeTerminalTabs = runtimeTerminalTabs.filter((tab) => { - if (unifiedTerminalEntityIds.has(tab.id)) { - return false - } - // Why: migration filter — keep any tab still owning a live/reconnecting - // PTY (preserved sessionId or a reconnect-map session) so it re-enters the - // unified model for wake/reconnect reattach instead of vanishing (#9911). - return terminalTabHasReconnectablePty(state, tab.id, tab.ptyId) - }) - const orphanTerminalIds = getOrphanTerminalIds(state, worktreeId) - const ensuredGroupState = - legacyRuntimeTerminalTabs.length > 0 - ? ensureGroup( - state.groupsByWorktree, - state.activeGroupIdByWorktree, - worktreeId, - state.activeGroupIdByWorktree[worktreeId] - ) - : null - const reconciliationGroup = ensuredGroupState?.group ?? groups[0] ?? null - const restoredLegacyTabs = - reconciliationGroup == null - ? [] - : legacyRuntimeTerminalTabs - .sort((a, b) => a.sortOrder - b.sortOrder || a.createdAt - b.createdAt) - .map((tab) => ({ - id: tab.id, - entityId: tab.id, - groupId: reconciliationGroup.id, - worktreeId, - contentType: 'terminal' as const, - label: tab.title, - ...(tab.quickCommandLabel?.trim() - ? { quickCommandLabel: tab.quickCommandLabel.trim() } - : {}), - ...(tab.generatedTitle?.trim() ? { generatedLabel: tab.generatedTitle.trim() } : {}), - customLabel: tab.customTitle, - color: tab.color, - sortOrder: tab.sortOrder, - createdAt: tab.createdAt - })) - const reconciledUnifiedTabs = - restoredLegacyTabs.length > 0 ? [...unifiedTabs, ...restoredLegacyTabs] : unifiedTabs - // Why: seed a freshly-ensured group's active tab from the remembered selection, else the worktree always reopens on Terminal 1. - const rememberedLegacyActiveTabId = state.activeTabIdByWorktree[worktreeId] - const restoredLegacyTabIds = new Set(restoredLegacyTabs.map((tab) => tab.id)) - const legacyFallbackActiveTabId = - rememberedLegacyActiveTabId && restoredLegacyTabIds.has(rememberedLegacyActiveTabId) - ? rememberedLegacyActiveTabId - : (restoredLegacyTabs[0]?.id ?? null) - const reconciledGroups = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? updateGroup(ensuredGroupState!.groupsByWorktree[worktreeId] ?? [], { - ...reconciliationGroup, - // Why: restore runtime-slice legacy tabs into the active/root group so live PTYs stay reachable instead of spawning a duplicate. - activeTabId: reconciliationGroup.activeTabId ?? legacyFallbackActiveTabId, - tabOrder: dedupeTabOrder([ - ...reconciliationGroup.tabOrder, - ...restoredLegacyTabs.map((tab) => tab.id) - ]) - }) - : groups - const liveTerminalIds = new Set( - runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) - ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) - const liveBrowserIds = new Set( - (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) - ) - - const isRenderableTab = (tab: Tab): boolean => { - if (tab.contentType === 'terminal') { - return liveTerminalIds.has(tab.entityId) - } - if (tab.contentType === 'browser') { - return liveBrowserIds.has(tab.entityId) - } - if (tab.contentType === 'simulator') { - return true - } - return liveEditorIds.has(tab.entityId) + const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) + if (Object.keys(reconciliation.patch).length > 0) { + set(reconciliation.patch) } - - const validTabs = reconciledUnifiedTabs.filter(isRenderableTab) - const validTabIds = new Set(validTabs.map((tab) => tab.id)) - - const nextGroupsWithEmpty = reconciledGroups.map((group) => { - const tabOrder = group.tabOrder.filter((tabId) => validTabIds.has(tabId)) - const activeTabId = - group.activeTabId && validTabIds.has(group.activeTabId) - ? group.activeTabId - : (tabOrder[0] ?? null) - const tabOrderUnchanged = - tabOrder.length === group.tabOrder.length && - tabOrder.every((tabId, index) => tabId === group.tabOrder[index]) - // Why: keep the MRU stack in sync with dropped tabs so the next close doesn't activate one the renderer no longer owns. - const recentTabIds = sanitizeRecentTabIds(group.recentTabIds, tabOrder) - const recentUnchanged = - recentTabIds.length === (group.recentTabIds ?? []).length && - recentTabIds.every((id, index) => id === (group.recentTabIds ?? [])[index]) - return tabOrderUnchanged && activeTabId === group.activeTabId && recentUnchanged - ? group - : { ...group, tabOrder, activeTabId, recentTabIds } - }) - const nextGroups = - validTabs.length > 0 - ? nextGroupsWithEmpty.filter((group) => group.tabOrder.length > 0) - : nextGroupsWithEmpty - - const currentActiveGroupId = - state.activeGroupIdByWorktree[worktreeId] ?? - ensuredGroupState?.activeGroupIdByWorktree[worktreeId] - const activeGroupStillExists = nextGroups.some((group) => group.id === currentActiveGroupId) - const nextActiveGroupId = activeGroupStillExists - ? currentActiveGroupId - : (nextGroups.find((group) => group.activeTabId !== null)?.id ?? - nextGroups[0]?.id ?? - currentActiveGroupId) - - const groupsChanged = - nextGroups.length !== groups.length || - nextGroups.some((group, index) => group !== groups[index]) - const tabsChanged = validTabs.length !== unifiedTabs.length || restoredLegacyTabs.length > 0 - const activeGroupChanged = nextActiveGroupId !== currentActiveGroupId - - const baseNextLayout = - restoredLegacyTabs.length > 0 && reconciliationGroup - ? (state.layoutByWorktree[worktreeId] ?? { type: 'leaf', groupId: reconciliationGroup.id }) - : state.layoutByWorktree[worktreeId] - const validGroupIds = new Set(nextGroups.map((group) => group.id)) - const prunedNextLayout = - baseNextLayout && validGroupIds.size > 0 - ? pruneTabGroupLayoutForGroups(baseNextLayout, validGroupIds) - : baseNextLayout - const nextLayout = - prunedNextLayout ?? (nextGroups[0] ? { type: 'leaf', groupId: nextGroups[0].id } : undefined) - const currentLayout = state.layoutByWorktree[worktreeId] - const layoutChanged = nextLayout !== currentLayout - - if ( - tabsChanged || - groupsChanged || - activeGroupChanged || - layoutChanged || - orphanTerminalIds.size > 0 - ) { - // Why: drop unreadTerminalTabs entries (keyed by entityId) for terminal tabs reconcile removed, or the stale flag lingers forever. - const droppedTerminalEntityIds: string[] = [] - for (const tab of unifiedTabs) { - if (tab.contentType !== 'terminal') { - continue - } - if (!validTabIds.has(tab.id)) { - droppedTerminalEntityIds.push(tab.entityId) - } - } - set((current) => { - let nextUnreadTerminalTabs = current.unreadTerminalTabs - if (droppedTerminalEntityIds.length > 0) { - let changed = false - const copy = { ...current.unreadTerminalTabs } - for (const entityId of droppedTerminalEntityIds) { - if (copy[entityId]) { - delete copy[entityId] - changed = true - } - } - if (changed) { - nextUnreadTerminalTabs = copy - } - } - return { - unifiedTabsByWorktree: { ...current.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...current.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...current.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, - ...(nextUnreadTerminalTabs !== current.unreadTerminalTabs - ? { unreadTerminalTabs: nextUnreadTerminalTabs } - : {}), - ...(nextLayout && layoutChanged - ? { - layoutByWorktree: { - ...current.layoutByWorktree, - // Why: restored runtime terminal needs a concrete leaf before activation, else the fallback spawns a duplicate "Terminal 2". - [worktreeId]: nextLayout! - } - } - : {}), - ...(orphanTerminalIds.size > 0 - ? buildOrphanTerminalCleanupPatch(current, worktreeId, orphanTerminalIds) - : {}) - } - }) - } - - const activeRenderableTabId = - nextGroups.find((group) => group.id === nextActiveGroupId)?.activeTabId ?? - nextGroups.find((group) => group.activeTabId !== null)?.activeTabId ?? - null - return { - renderableTabCount: validTabs.length, - activeRenderableTabId + renderableTabCount: reconciliation.renderableTabCount, + activeRenderableTabId: reconciliation.activeRenderableTabId } }, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 8c41cedab6e..d947c2a5dd4 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -3397,7 +3397,8 @@ export const createTerminalSlice: StateCreator ? removeSleepingRecordsReplacedByManualWorktreeSleep( s.sleepingAgentSessionsByPaneKey, worktreeId, - opts?.sleepingPaneKeys + opts?.sleepingPaneKeys, + sleepingAgentSessionRecords ).records : s.sleepingAgentSessionsByPaneKey return { diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 2e0e48546e2..dbd81acee9f 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -721,6 +721,33 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().showSleepingWorkspaces).toBe(true) }) + it('defaults the default-branch sleeping exemption to on', () => { + expect(getDefaultUIState().alwaysShowDefaultBranchWorkspace).toBe(true) + expect(createUIStore().getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('treats a legacy profile with no default-branch exemption key as opted in', () => { + // Why: profiles written before #8873 are exactly the ones showing the bug, + // so an absent key must hydrate to on rather than silently re-hiding main. + const store = createUIStore() + const legacy = makePersistedUI() + delete (legacy as Partial).alwaysShowDefaultBranchWorkspace + + store.getState().hydratePersistedUI(legacy, 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(true) + }) + + it('preserves an explicit default-branch exemption opt-out on hydration', () => { + const store = createUIStore() + + store + .getState() + .hydratePersistedUI(makePersistedUI({ alwaysShowDefaultBranchWorkspace: false }), 'startup') + + expect(store.getState().alwaysShowDefaultBranchWorkspace).toBe(false) + }) + it('defaults workspace host scope to all hosts', () => { expect(getDefaultUIState().workspaceHostScope).toBe('all') expect(createUIStore().getState().workspaceHostScope).toBe('all') diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index 625a8e9aba0..d926a100f2f 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -875,6 +875,8 @@ export type UISlice = { setHideCliCreatedWorkspaces: (v: boolean) => void hideDetachedHeadWorkspaces: boolean setHideDetachedHeadWorkspaces: (v: boolean) => void + alwaysShowDefaultBranchWorkspace: boolean + setAlwaysShowDefaultBranchWorkspace: (v: boolean) => void showDotfilesByWorktree: Record setShowDotfilesForWorktree: (worktreeId: string, showDotfiles: boolean) => void toggleShowDotfilesForWorktree: (worktreeId: string) => void @@ -2053,6 +2055,8 @@ export const createUISlice: StateCreator = (set, get) setHideCliCreatedWorkspaces: (v) => set({ hideCliCreatedWorkspaces: v }), hideDetachedHeadWorkspaces: false, setHideDetachedHeadWorkspaces: (v) => set({ hideDetachedHeadWorkspaces: v }), + alwaysShowDefaultBranchWorkspace: true, + setAlwaysShowDefaultBranchWorkspace: (v) => set({ alwaysShowDefaultBranchWorkspace: v }), showDotfilesByWorktree: {}, setShowDotfilesForWorktree: (worktreeId, showDotfiles) => @@ -2459,6 +2463,9 @@ export const createUISlice: StateCreator = (set, get) hideAutomationGeneratedWorkspaces: ui.hideAutomationGeneratedWorkspaces === true, hideCliCreatedWorkspaces: ui.hideCliCreatedWorkspaces === true, hideDetachedHeadWorkspaces: ui.hideDetachedHeadWorkspaces === true, + // Why !== false: profiles written before #8873 have no key, and they are + // precisely the ones showing the bug, so absence must mean "exempt". + alwaysShowDefaultBranchWorkspace: ui.alwaysShowDefaultBranchWorkspace !== false, showDotfilesByWorktree: sanitizeShowDotfilesByWorktree(ui.showDotfilesByWorktree), // Why: startup hydrates UI before repo catalogs, so defer repo-filter validation to the all-host refresh. filterRepoIds: diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index fd548c91e04..16a58fd985f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -35,6 +35,7 @@ import type { WorktreeCreationPhase } from '@/lib/pending-worktree-creation' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' +import type { AppState } from '../types' export { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id' export type WorktreeDeleteState = { @@ -70,6 +71,12 @@ export type WorktreeRenameRequest = { rowKey?: string } +export type ActiveWorktreeStateTransition = (state: AppState) => { + patch: Partial + activate: boolean + preferredActiveUnifiedTabId?: string +} + export type WorktreeSlice = { worktreesByRepo: Record detectedWorktreesByRepo: Record @@ -236,11 +243,13 @@ export type WorktreeSlice = { expectedHead: string ) => Promise<({ ok: true } & ForceDeleteWorktreeBranchResult) | { ok: false; error: string }> clearWorktreeDeleteState: (worktreeId: string) => void + /** Never rejects — most callers fire-and-forget. Callers that own a surface + * the user is waiting on should read the result and say what went wrong. */ updateWorktreeMeta: ( worktreeId: string, updates: Partial, options?: WorktreeMetaUpdateOptions - ) => Promise + ) => Promise<{ ok: true } | { ok: false; error: string }> ensureHostedReviewPushTarget: (worktreeId: string) => Promise updateWorktreesMeta: ( updatesByWorktreeId: ReadonlyMap> @@ -280,7 +289,11 @@ export type WorktreeSlice = { * fresh visit. */ seedActiveWorktreeLastVisitedIfMissing: () => void - setActiveWorktree: (worktreeId: string | null, executionHostId?: ExecutionHostId) => void + setActiveWorktree: ( + worktreeId: string | null, + executionHostId?: ExecutionHostId, + options?: { stateTransition?: ActiveWorktreeStateTransition } + ) => boolean /** * Health-driven remount of one terminal tab: bumps the tab's generation so * TerminalPane unmounts, detaches (preserving a live PTY), and remounts with diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index d4d944db201..1d2b822dffd 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -7024,6 +7024,37 @@ describe('worktree remote runtime mutations', () => { }) }) + // A rejected folder update reconciles the optimistic write away, so reporting + // ok would show the dialog a save that silently undid itself. + it('reports a rejected folder workspace update to the caller', async () => { + const store = createTestStore() + const folderWorkspace = makeFolderWorkspace({ id: 'folder-rejected' }) + store.setState({ folderWorkspaces: [folderWorkspace] } as Partial) + const updateFolderWorkspace = vi.fn().mockResolvedValue(false) + store.setState({ updateFolderWorkspace } as Partial) + + const result = await store + .getState() + .updateWorktreeMeta(folderWorkspaceKey(folderWorkspace.id), { comment: 'note' }) + + expect(updateFolderWorkspace).toHaveBeenCalledTimes(1) + expect(result).toEqual({ ok: false, error: expect.any(String) }) + }) + + it('reports a thrown folder workspace update instead of rejecting', async () => { + const store = createTestStore() + const folderWorkspace = makeFolderWorkspace({ id: 'folder-throwing' }) + store.setState({ folderWorkspaces: [folderWorkspace] } as Partial) + const updateFolderWorkspace = vi.fn().mockRejectedValue(new Error('Runtime is offline')) + store.setState({ updateFolderWorkspace } as Partial) + + const result = await store + .getState() + .updateWorktreeMeta(folderWorkspaceKey(folderWorkspace.id), { comment: 'note' }) + + expect(result).toEqual({ ok: false, error: 'Runtime is offline' }) + }) + it('persists activity for hidden detected worktrees', async () => { const store = createTestStore() const hidden = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 66bf295a715..6cd073abbe1 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -26,6 +26,7 @@ import { type WorktreeFetchOptions, type WorktreeSlice } from './worktree-helpers' +import { projectWorktreeTabModelReconciliation } from './tabs' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree-id' import { areWorkspaceLinkedItemsEqual } from '../../../../shared/workspace-linked-item' import { areTaskSourceContextsEqual } from '../../../../shared/task-source-context' @@ -50,7 +51,10 @@ import { isRuntimeScopeForbiddenError, RuntimeRpcCallError } from '../../runtime/runtime-rpc-client' -import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review' import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch' @@ -1710,16 +1714,26 @@ async function persistWorktreeMeta( await window.api.worktrees.updateMeta({ worktreeId, updates }) return } - // Why: same gate as worktree.create — an older paired runtime would drop the Jira link silently. + // Why: `worktree.set` parses in strip mode, so an older runtime drops the key + // and applies the rest. Both gates key off presence, not value — a dropped + // *clear* strands a stale link that the Issue row then hides. if ( target.kind === 'environment' && - (updates.linkedWorkItem?.provider === 'jira' || - updates.linkedTaskSourceContext?.provider === 'jira') + ('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates) ) { await assertRuntimeEnvironmentCapability( target.environmentId, WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' + 'Update the remote runtime to change this workspace’s linked issue' + ) + } + // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them + // to the schema and is an ancestor of the commit introducing that capability. + if (target.kind === 'environment' && 'linkedLinearIssue' in updates) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Linear issues' ) } await callRuntimeRpc( @@ -4497,15 +4511,34 @@ export const createWorktreeSlice: StateCreator const shouldApplyUpdate = options?.shouldApply const existingWorktree = get().getKnownWorktreeById(worktreeId) if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { - return + return { ok: true } } const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderUpdates = getFolderWorkspaceMetaUpdates(updates) - if (Object.keys(folderUpdates).length > 0) { - await get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, folderUpdates) + if (Object.keys(folderUpdates).length === 0) { + return { ok: true } + } + try { + // Why: a rejected folder update reconciles the optimistic write away, so + // reporting ok would show the dialog a save that silently undid itself. + const updated = await get().updateFolderWorkspace( + workspaceScope.folderWorkspaceId, + folderUpdates + ) + return updated + ? { ok: true } + : { + ok: false, + error: translate( + 'auto.store.slices.worktrees.a17f4d2e93', + 'Could not update this workspace.' + ) + } + } catch (err) { + console.error('Failed to update folder workspace meta:', err) + return { ok: false, error: err instanceof Error ? err.message : String(err) } } - return } const normalizedUpdates = existingWorktree ? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree) @@ -4540,7 +4573,7 @@ export const createWorktreeSlice: StateCreator existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key const worktreeForUpdate = get().getKnownWorktreeById(worktreeId) if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) { - return + return { ok: true } } const shouldRefreshHostedReview = (normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) || @@ -4661,7 +4694,7 @@ export const createWorktreeSlice: StateCreator } }) if (shouldApplyUpdate && !didApply) { - return + return { ok: true } } if (hasHostedReviewLinkUpdates(enriched)) { bumpHostedReviewLinkMutationGeneration(worktreeId) @@ -4709,11 +4742,21 @@ export const createWorktreeSlice: StateCreator } catch (err) { if (isRuntimeSelectorNotFoundError(err)) { void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - return + return { + ok: false, + error: translate( + 'auto.store.slices.worktrees.c6cf133786', + 'This workspace is no longer available.' + ) + } } console.error('Failed to update worktree meta:', err) void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + // Why: the refetch above reverts the optimistic write, so a caller that + // closes its surface on this path shows the user a save that undid itself. + return { ok: false, error: err instanceof Error ? err.message : String(err) } } + return { ok: true } }, ensureHostedReviewPushTarget: async (worktreeId) => { @@ -5238,7 +5281,14 @@ export const createWorktreeSlice: StateCreator return remounted }, - setActiveWorktree: (worktreeId, executionHostId) => { + setActiveWorktree: (worktreeId, executionHostId, options) => { + const stateTransition = options?.stateTransition?.(get()) + if (stateTransition && !stateTransition.activate) { + if (Object.keys(stateTransition.patch).length > 0) { + set(stateTransition.patch) + } + return false + } const workspaceScope = worktreeId ? parseWorkspaceKey(worktreeId) : null if (worktreeId && shouldDeferActivationTerminalPrep()) { markInputQuietSchedulerInput() @@ -5247,15 +5297,27 @@ export const createWorktreeSlice: StateCreator if (get().activeWorktreeId !== worktreeId) { moveFocusToRendererBeforeFocusedWebviewHidden() } - const reconciledActiveTabId = worktreeId - ? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId - : null let shouldClearUnread = false let shouldPrepareTerminalTabs = false let shouldTagTerminalTabs = false - set((s) => { + set((current) => { + const transitioned = stateTransition + ? ({ ...current, ...stateTransition.patch } as AppState) + : current + const reconciliation = worktreeId + ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) + : null + const reconciliationChanged = Boolean( + reconciliation && Object.keys(reconciliation.patch).length > 0 + ) + const s = + reconciliation && reconciliationChanged + ? ({ ...transitioned, ...reconciliation.patch } as AppState) + : transitioned + const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null if (!worktreeId) { return { + ...stateTransition?.patch, activeWorktreeId: null, activeWorkspaceKey: null, activeWorkspaceExecutionHostId: null, @@ -5279,7 +5341,11 @@ export const createWorktreeSlice: StateCreator ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? null) : null - const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null + const activeUnifiedTabId = + stateTransition?.preferredActiveUnifiedTabId ?? + reconciledActiveTabId ?? + activeGroup?.activeTabId ?? + null const activeUnifiedTab = activeUnifiedTabId != null ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( @@ -5408,7 +5474,12 @@ export const createWorktreeSlice: StateCreator : workspace ) : s.folderWorkspaces - const nextActiveRepoId = workspaceScope?.type === 'folder' ? null : s.activeRepoId + const nextActiveRepoId = + workspaceScope?.type === 'folder' + ? null + : stateTransition + ? (worktree?.repoId ?? s.activeRepoId) + : s.activeRepoId const tabsByWorktreeUpdate = allDead && worktreeId != null ? { @@ -5444,13 +5515,17 @@ export const createWorktreeSlice: StateCreator nextWorktrees !== s.worktreesByRepo || nextDetectedWorktrees !== s.detectedWorktreesByRepo || nextFolderWorkspaces !== s.folderWorkspaces || - nextActiveRepoId !== s.activeRepoId + nextActiveRepoId !== s.activeRepoId || + reconciliationChanged || + stateTransition !== undefined if (!hasStateChange) { // Why: preserve the root Zustand reference on a no-op re-activation so session persistence/runtime sync don't fan out. return s } return { + ...stateTransition?.patch, + ...reconciliation?.patch, activeRepoId: nextActiveRepoId, activeWorktreeId: worktreeId, activeWorkspaceKey: isWorkspaceKey(worktreeId) @@ -5531,13 +5606,13 @@ export const createWorktreeSlice: StateCreator } if (!worktreeId || !get().getKnownWorktreeById(worktreeId, executionHostId)) { - return + return true } if (shouldClearUnread) { if (workspaceScope?.type === 'folder') { void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) - return + return true } persistPassiveWorktreeMetaForOwner( get, @@ -5546,6 +5621,7 @@ export const createWorktreeSlice: StateCreator 'persist worktree activation state' ) } + return true }, setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => { diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 098ceeff9a2..73c1aae3a6d 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -32,6 +32,20 @@ import { makePaneKey } from './stable-pane-id' const LEAF_ID = '11111111-1111-4111-8111-111111111111' const PANE_KEY = makePaneKey('tab-1', LEAF_ID) +const CLAUDE_PROMPT_ID = '22222222-2222-4222-8222-222222222222' +const CLAUDE_PREVIOUS_PROMPT_ID = '33333333-3333-4333-8333-333333333333' + +function normalizeAndAccept( + state: HookListenerState, + source: Parameters[1], + payload: Record +): ReturnType { + const event = normalizeHookPayload(state, source, { paneKey: PANE_KEY, payload }, 'production') + if (event) { + state.lastStatusByPaneKey.set(PANE_KEY, event) + } + return event +} type FakeIncomingMessage = EventEmitter & { headers: IncomingHttpHeaders @@ -1452,6 +1466,69 @@ describe('shared agent-hook-listener', () => { expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) }) + // Why: Kimi shares Claude-compatible compact/harness hooks; cover the same sticky-working + // guards so a Kimi-only regression cannot slip past the Claude-only tests (issue #11352). + it('ignores harness-injected UserPromptSubmit for Kimi', () => { + normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: [{ type: 'text', text: 'list the files here' }] + } + }, + 'production' + ) + const harness = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: + 'This session is being continued from a previous conversation that ran out of context.' + } + }, + 'production' + ) + expect(harness).toBeNull() + const tool = normalizeHookPayload( + state, + 'kimi', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'ls' } + } + }, + 'production' + ) + expect(tool).not.toBeNull() + expect(tool!.payload.state).toBe('working') + expect(tool!.payload.prompt).toBe('list the files here') + expect(tool!.payload.agentType).toBe('kimi') + }) + + it('ignores unproven Kimi compact lifecycle events', () => { + const pre = normalizeAndAccept(state, 'kimi', { + hook_event_name: 'PreCompact', + trigger: 'manual' + }) + const post = normalizeAndAccept(state, 'kimi', { + hook_event_name: 'PostCompact', + trigger: 'manual' + }) + + expect(pre).toBeNull() + expect(post).toBeNull() + expect(state.lastStatusByPaneKey.has(PANE_KEY)).toBe(false) + }) + it('normalizes MiMo Code OpenCode-compatible lifecycle events as mimo-code status', () => { const message = normalizeHookPayload( state, @@ -1563,15 +1640,13 @@ describe('shared agent-hook-listener', () => { expect(event).toBeNull() }) - it('keeps the cached prompt when a harness-injected turn fires UserPromptSubmit', () => { + it('resumes work for task notifications without replacing the cached prompt', () => { 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', @@ -1590,7 +1665,7 @@ describe('shared agent-hook-listener', () => { expect(event!.hasExplicitPrompt).toBe(false) }) - it('resolves an empty prompt for a harness-injected turn with nothing cached', () => { + it('emits a harness-injected UserPromptSubmit with an empty uncached prompt', () => { const event = normalizeHookPayload( state, 'claude', @@ -1604,10 +1679,81 @@ describe('shared agent-hook-listener', () => { 'production' ) expect(event).not.toBeNull() + expect(event!.payload.state).toBe('working') expect(event!.payload.prompt).toBe('') expect(event!.hasExplicitPrompt).toBe(false) }) + it('does not leave working after a compact-summary UserPromptSubmit (issue #11352)', () => { + // Live repro: after /compact Claude injects "This session is being continued…" with no Stop. + const event = normalizeHookPayload( + state, + 'claude', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + prompt: + 'This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.' + } + }, + 'production' + ) + expect(event).toBeNull() + }) + + it('maps an identity-matched Claude manual compact lifecycle', () => { + normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + prompt: 'work before compact', + prompt_id: CLAUDE_PREVIOUS_PROMPT_ID, + session_id: 'session-a' + }) + const pre = normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'manual', + prompt_id: CLAUDE_PROMPT_ID, + session_id: 'session-a' + }) + expect(pre).not.toBeNull() + expect(pre!.payload.state).toBe('working') + expect(pre!.payload.agentType).toBe('claude') + + const post = normalizeAndAccept(state, 'claude', { + hook_event_name: 'PostCompact', + trigger: 'manual', + prompt_id: CLAUDE_PROMPT_ID, + session_id: 'session-a' + }) + expect(post).not.toBeNull() + expect(post!.payload.state).toBe('done') + expect(post!.payload.agentType).toBe('claude') + }) + + it('keeps the preceding user prompt on the completed compact row', () => { + normalizeAndAccept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + prompt: 'work before compact', + prompt_id: CLAUDE_PREVIOUS_PROMPT_ID, + session_id: 'session-a' + }) + normalizeAndAccept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'manual', + prompt_id: CLAUDE_PROMPT_ID, + session_id: 'session-a' + }) + const post = normalizeAndAccept(state, 'claude', { + hook_event_name: 'PostCompact', + trigger: 'manual', + prompt_id: CLAUDE_PROMPT_ID, + session_id: 'session-a' + }) + expect(post).not.toBeNull() + expect(post!.payload.state).toBe('done') + expect(post!.payload.prompt).toBe('work before compact') + }) + it('treats a custom-element paste as an explicit user turn, not machinery', () => { normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index a9404299901..985f95b1876 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -55,11 +55,15 @@ import { import { ORCA_HOOK_PROTOCOL_VERSION } from './agent-hook-types' import { REMOTE_AGENT_HOOK_ENV, type AgentHookSource } from './agent-hook-relay' import { + agentProviderSessionsEqual, extractAgentProviderSession, type AgentProviderSessionMetadata } from './agent-session-resume' import { parsePaneKey } from './stable-pane-id' -import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { + isCompactContinuationUserTurnText, + isKnownHarnessInjectedUserTurnText +} from './harness-injected-user-turns' import { buildGrokChatHistoryPathCandidates, findGrokChatHistoryBySessionId, @@ -102,6 +106,15 @@ function capOpenCodeHookText(text: string): string { /** Bound paneKey size (real keys are well under 200); caps per-pane caches against pathological input. Exported so non-HTTP ingest (`ingestRemote`) applies the same cap as defense-in-depth. */ export const MAX_PANE_KEY_LEN = 200 +const CLAUDE_PROMPT_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i + +export function normalizeClaudePromptId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim().toLowerCase() + return CLAUDE_PROMPT_ID_RE.test(normalized) ? normalized : undefined +} /** Per-listener-instance caches needing per-PTY teardown; Orca's main process and the relay each get their own, never shared. */ export type HookListenerState = { @@ -298,6 +311,8 @@ export function warnOnHookEnvOrVersionMismatch( export type AgentHookEventPayload = { paneKey: string + /** Authenticated hook route that produced this event. */ + source?: AgentHookSource /** Ephemeral Orca launch identity stamped into the PTY env for this process. */ launchToken?: string tabId?: string @@ -310,6 +325,10 @@ export type AgentHookEventPayload = { promptInteractionKey?: string /** Raw agent hook event name, used by main-process transition guards. */ hookEventName?: string + /** Claude's provider-owned user-prompt UUID. */ + providerPromptId?: string + /** Active Claude compact generation, keyed by provider prompt identity. */ + compactTrigger?: 'manual' | 'auto' /** Claude tool-use identifier when the hook source exposes one. */ toolUseId?: string /** Claude agent/subagent identifier when the hook source exposes one. */ @@ -327,6 +346,92 @@ export type AgentHookEventPayload = { payload: ParsedAgentStatusPayload } +type ClaudeCompactIdentity = Pick< + AgentHookEventPayload, + | 'source' + | 'connectionId' + | 'hookEventName' + | 'providerPromptId' + | 'compactTrigger' + | 'providerSession' +> + +export function canAcceptClaudeCompactTransition( + previous: AgentHookEventPayload | undefined, + incoming: ClaudeCompactIdentity, + options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {} +): boolean { + if ( + incoming.source !== 'claude' || + incoming.compactTrigger === undefined || + incoming.providerPromptId === undefined || + (incoming.hookEventName !== 'PreCompact' && incoming.hookEventName !== 'PostCompact') + ) { + return false + } + if (incoming.hookEventName === 'PreCompact' && options.allowUnanchoredPreCompact) { + return true + } + if (incoming.hookEventName === 'PostCompact' && options.allowUnanchoredPostCompact) { + return true + } + if ( + previous?.source !== 'claude' || + previous.payload.agentType !== 'claude' || + previous.connectionId !== incoming.connectionId || + !agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession) + ) { + return false + } + if (incoming.hookEventName === 'PostCompact') { + return ( + previous.compactTrigger === incoming.compactTrigger && + previous.providerPromptId === incoming.providerPromptId + ) + } + return incoming.compactTrigger === 'manual' + ? previous.providerPromptId !== undefined + : previous.providerPromptId === incoming.providerPromptId +} + +export function resolveCachedClaudeCompactOwnership( + previous: AgentHookEventPayload | undefined, + incoming: AgentHookEventPayload +): AgentHookEventPayload { + const sameClaudeOwner = + previous?.source === 'claude' && + previous.payload.agentType === 'claude' && + incoming.source === 'claude' && + incoming.payload.agentType === 'claude' && + incoming.connectionId === previous.connectionId && + agentProviderSessionsEqual('claude', previous.providerSession, incoming.providerSession) + ? previous + : undefined + if (incoming.hookEventName === 'PreCompact' && incoming.compactTrigger) { + return sameClaudeOwner?.payload.prompt && incoming.payload.prompt.length === 0 + ? { ...incoming, payload: { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt } } + : incoming + } + if (incoming.hookEventName === 'PostCompact') { + return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming + } + const ownsCompact = + sameClaudeOwner?.compactTrigger !== undefined && + sameClaudeOwner.providerPromptId !== undefined && + incoming.providerPromptId === sameClaudeOwner.providerPromptId + if (ownsCompact) { + return { + ...incoming, + compactTrigger: sameClaudeOwner.compactTrigger, + payload: + incoming.payload.prompt.length === 0 && sameClaudeOwner.payload.prompt + ? { ...incoming.payload, prompt: sameClaudeOwner.payload.prompt } + : incoming.payload + } + } + return incoming.compactTrigger ? { ...incoming, compactTrigger: undefined } : incoming +} + // ─── Body parsing ─────────────────────────────────────────────────── export function parseFormEncodedBody(body: string): Record { @@ -504,6 +609,14 @@ function stripGrokUserQueryWrapper(promptText: string): string { return text.trim() } +// Why: the post-compact continuation prompt has no matching Stop and would resurrect working. +function shouldIgnoreCompactContinuationUserPromptSubmit( + eventName: unknown, + promptText: string +): boolean { + return eventName === 'UserPromptSubmit' && isCompactContinuationUserTurnText(promptText) +} + function resolvePrompt( state: HookListenerState, paneKey: string, @@ -2640,19 +2753,27 @@ function normalizeClaudeEvent( const sessionCronInventoryPresent = Array.isArray(sessionCrons) const hasActiveSessionCron = sessionCronInventoryPresent && sessionCrons.length > 0 + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + // Why: Claude's auto-allowed AskUserQuestion emits PreToolUse (not PermissionRequest; its Notification hook isn't registered) while blocked on a human answer. // Treat that PreToolUse as waiting so the sidebar shows amber attention, not a spinner that decays to grey. Mirrors normalizeKimiEvent. const isAskUserQuestion = eventName === 'PreToolUse' && isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + // Why: /compact can take minutes and does not emit Stop. PreCompact marks the pane busy; + // PostCompact clears it so a finished compact cannot leave a sticky working spinner (#11352). const reportedStateName = eventName === 'UserPromptSubmit' || eventName === 'PostToolUse' || eventName === 'PostToolUseFailure' || + eventName === 'PreCompact' || + (eventName === 'PostCompact' && hookPayload.trigger === 'auto') || (eventName === 'PreToolUse' && !isAskUserQuestion) ? 'working' : eventName === 'PermissionRequest' || isAskUserQuestion ? 'waiting' - : isTurnBoundary + : isTurnBoundary || (eventName === 'PostCompact' && hookPayload.trigger === 'manual') ? 'done' : null @@ -2863,6 +2984,10 @@ function normalizeKimiEvent( paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + const toolName = readString(hookPayload, 'tool_name') const isUserInputTool = isKimiUserInputTool(toolName) @@ -3938,7 +4063,8 @@ export function normalizeHookPayload( state: HookListenerState, source: AgentHookSource, body: unknown, - expectedEnv: string + expectedEnv: string, + options: { allowUnanchoredPreCompact?: boolean; allowUnanchoredPostCompact?: boolean } = {} ): AgentHookEventPayload | null { if (typeof body !== 'object' || body === null) { return null @@ -3987,6 +4113,52 @@ export function normalizeHookPayload( readFirstString(record, ['hook_event_name', 'hookEventName', 'hook_type', 'hookType']) ?? hookPayloadRecord.hook_event_name ?? hookPayloadRecord.hookEventName + // Why: Codex child hooks expose the child's session_id on the parent's pane. + const providerSession = + source === 'codex' && readString(hookPayloadRecord, 'agent_id') + ? null + : extractAgentProviderSession(source, hookPayloadRecord) + const providerPromptId = + source === 'claude' ? normalizeClaudePromptId(hookPayloadRecord.prompt_id) : undefined + const compactTrigger = + source === 'claude' && + (eventName === 'PreCompact' || eventName === 'PostCompact') && + (hookPayloadRecord.trigger === 'manual' || hookPayloadRecord.trigger === 'auto') + ? hookPayloadRecord.trigger + : undefined + const isCompactEvent = eventName === 'PreCompact' || eventName === 'PostCompact' + if (isCompactEvent && compactTrigger === undefined) { + return null + } + const previousStatus = state.lastStatusByPaneKey.get(paneKey) + if ( + compactTrigger !== undefined && + !canAcceptClaudeCompactTransition( + previousStatus, + { + source, + connectionId: null, + hookEventName: typeof eventName === 'string' ? eventName : undefined, + providerPromptId, + compactTrigger, + providerSession: providerSession ?? undefined + }, + { + allowUnanchoredPreCompact: options.allowUnanchoredPreCompact, + allowUnanchoredPostCompact: options.allowUnanchoredPostCompact + } + ) + ) { + return null + } + if ( + eventName === 'PostCompact' && + compactTrigger !== undefined && + previousStatus?.payload.prompt && + !state.lastPromptByPaneKey.has(paneKey) + ) { + state.lastPromptByPaneKey.set(paneKey, previousStatus.payload.prompt) + } const extractedPrompt = extractPromptText(hookPayload as Record) const promptText = extractedPrompt.text let resolvedPromptText = promptText @@ -4108,12 +4280,6 @@ export function normalizeHookPayload( } // Why: connectionId is null here; ingestRemote stamps it from mux identity on receive. See docs/design/agent-status-over-ssh.md §5. - // Why: Codex child hooks expose the child's session_id on the parent's pane; - // treating it as the root resume id would replace the terminal's real session. - const providerSession = - source === 'codex' && readString(hookPayloadRecord, 'agent_id') - ? null - : extractAgentProviderSession(source, hookPayloadRecord) const providerSessionOnly = source === 'pi' && eventName === 'session_start' && providerSession !== null // Why: Pi session_start carries resume identity while idle; providerSessionOnly makes receivers discard the placeholder row. @@ -4125,6 +4291,7 @@ export function normalizeHookPayload( return transportPayload ? { paneKey, + source, launchToken, tabId, worktreeId, @@ -4143,6 +4310,8 @@ export function normalizeHookPayload( ), promptInteractionKey, hookEventName: typeof eventName === 'string' ? eventName : undefined, + providerPromptId, + compactTrigger, toolUseId: readFirstString(hookPayloadRecord, ['tool_use_id', 'toolUseId']), toolAgentId: readFirstString(hookPayloadRecord, ['agent_id', 'agentId']), toolAgentType: readString(hookPayloadRecord, 'agent_type'), diff --git a/src/shared/agent-hook-relay.test.ts b/src/shared/agent-hook-relay.test.ts index 338581c2859..dd738a618bc 100644 --- a/src/shared/agent-hook-relay.test.ts +++ b/src/shared/agent-hook-relay.test.ts @@ -6,6 +6,7 @@ import { AGENT_HOOK_SHED_FIELDS_KEY, ORCA_FEATURE_REMOTE_AGENT_HOOKS_ENV, createShedSubagentsField, + isAgentHookSource, isRemoteAgentHooksEnabled, restoreShedStatusFields, type AgentHookRelayEnvelope @@ -22,6 +23,8 @@ describe('agent-hook-relay wire shape', () => { connectionId: null, env: 'production', version: '1', + providerPromptId: '11111111-1111-4111-8111-111111111111', + compactTrigger: 'manual', payload: { state: 'working', prompt: 'roundtrip', @@ -40,6 +43,14 @@ describe('agent-hook-relay wire shape', () => { expect(AGENT_HOOK_REQUEST_REPLAY_METHOD).toBe('agent_hook.requestReplay') expect(AGENT_HOOK_INSTALL_PLUGINS_METHOD).toBe('agent_hook.installPlugins') }) + + it('validates hook sources crossing persisted and relay trust boundaries', () => { + expect(isAgentHookSource('claude')).toBe(true) + expect(isAgentHookSource('kimi')).toBe(true) + expect(isAgentHookSource('claude\0codex')).toBe(false) + expect(isAgentHookSource('unknown')).toBe(false) + expect(isAgentHookSource({ source: 'claude' })).toBe(false) + }) }) describe('isRemoteAgentHooksEnabled', () => { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index ad578fddae2..128397551bc 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -34,24 +34,33 @@ import type { AgentHookTarget } from './agent-hook-types' // Promoted from `src/main/agent-hooks/server.ts` so the relay can import it // without dragging Electron in (the shared listener module is the only place // that consumes it from the relay side). -export type AgentHookSource = - | 'claude' - | 'codex' - | 'gemini' - | 'antigravity' - | 'amp' - | 'opencode' - | 'mimo-code' - | 'cursor' - | 'pi' - | 'omp' - | 'droid' - | 'command-code' - | 'grok' - | 'copilot' - | 'hermes' - | 'devin' - | 'kimi' +const AGENT_HOOK_SOURCES = [ + 'claude', + 'codex', + 'gemini', + 'antigravity', + 'amp', + 'opencode', + 'mimo-code', + 'cursor', + 'pi', + 'omp', + 'droid', + 'command-code', + 'grok', + 'copilot', + 'hermes', + 'devin', + 'kimi' +] as const + +export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] + +const AGENT_HOOK_SOURCE_SET: ReadonlySet = new Set(AGENT_HOOK_SOURCES) + +export function isAgentHookSource(value: unknown): value is AgentHookSource { + return typeof value === 'string' && AGENT_HOOK_SOURCE_SET.has(value) +} /** Env marker used by the remote relay. It is a transport/location marker, not * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ @@ -75,6 +84,10 @@ export type AgentHookRelayEnvelope = { promptInteractionKey?: string /** Hook discriminator preserved for main-process transition rules. */ hookEventName?: string + /** Claude's provider-owned user-prompt UUID. */ + providerPromptId?: string + /** Active Claude compact generation, keyed by provider prompt identity. */ + compactTrigger?: 'manual' | 'auto' /** Claude tool execution id, when the source hook provides one. */ toolUseId?: string /** Claude subagent identity, when the source hook provides one. */ diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 19ccde5e783..e263a4f9a19 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -63,6 +63,11 @@ export type SleepingAgentSessionRecord = { /** Prevents provider-session relaunch while main reconciles a durable * orchestration assignment against authoritative PTY inventory. */ automaticResumeBlockedBy?: 'legacy-orchestration-worker' + /** Set on a finished pane captured by an explicit workspace sleep. Its + * `--resume` is issued by the pane's own cold restore when its tab is + * opened, so a mobile wake must not background-mount every such tab and + * respawn the whole workspace the user just slept (#11598). */ + restoreOnTabOpenOnly?: boolean } const RESUMABLE_TUI_AGENT_SET: ReadonlySet = new Set(RESUMABLE_TUI_AGENTS) diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index 1f2c3680048..0d902ecb0fc 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -181,6 +181,31 @@ export type AgentStatusPayload = { */ export type ParsedAgentStatusPayload = Omit & { prompt: string } +/** + * Narrow an `AgentStatusIpcPayload` (or any superset) down to the status fields alone. + * Why: the IPC shape is flattened, so a spread cannot be narrowed structurally — copying + * a hook row into a client-visible projection would otherwise ship `launchToken`, + * `connectionId`, `promptInteractionKey` and `providerSessionOnly` to every paired client. + */ +export function pickParsedAgentStatusPayload( + row: ParsedAgentStatusPayload +): ParsedAgentStatusPayload { + return { + state: row.state, + prompt: row.prompt, + ...(row.agentType !== undefined ? { agentType: row.agentType } : {}), + ...(row.model !== undefined ? { model: row.model } : {}), + ...(row.toolName !== undefined ? { toolName: row.toolName } : {}), + ...(row.toolInput !== undefined ? { toolInput: row.toolInput } : {}), + ...(row.interactivePrompt !== undefined ? { interactivePrompt: row.interactivePrompt } : {}), + ...(row.lastAssistantMessage !== undefined + ? { lastAssistantMessage: row.lastAssistantMessage } + : {}), + ...(row.interrupted !== undefined ? { interrupted: row.interrupted } : {}), + ...(row.subagents !== undefined ? { subagents: row.subagents } : {}) + } +} + /** * Wire shape for agent-status IPC. Both `agentStatus:set` and `agentStatus:getSnapshot` * produce this shape so renderer call sites share a single `setAgentStatus` path. diff --git a/src/shared/ansi-escape-sequences.ts b/src/shared/ansi-escape-sequences.ts new file mode 100644 index 00000000000..1d914900c24 --- /dev/null +++ b/src/shared/ansi-escape-sequences.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-control-regex -- these patterns exist to match terminal control bytes. */ + +/** OSC title/progress frames, 7-bit (ESC ]) and 8-bit (U+009D) introducers. */ +export const OSC_SEQUENCE_PATTERN = /(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g +/** DCS/APC/PM/SOS string sequences terminated by ST. */ +export const STRING_SEQUENCE_PATTERN = + /(?:\u001b[P_^X]|\u0090|\u0098|\u009e|\u009f)[\s\S]*?(?:\u001b\\|\u009c)/g +/** CSI sequences (colors, cursor moves, `\u001b[0K` erase-to-EOL). */ +export const CSI_SEQUENCE_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g +/** Remaining two/three-byte escape sequences (charset selection, RIS, ...). */ +export const ESCAPE_SEQUENCE_PATTERN = /\u001b[ -/]*[0-~]/g +/** C0/C1 control bytes except tab, line feed and carriage return. */ +export const TERMINAL_CONTROL_CHARACTER_PATTERN = + /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g + +/** + * Remove ANSI escape sequences while preserving text, tabs and line breaks. + * Ordering matters: OSC/string sequences must be consumed before the generic + * escape pattern, which would otherwise eat only their introducer. + */ +export function stripAnsiEscapeSequences(value: string): string { + return value + .replace(OSC_SEQUENCE_PATTERN, '') + .replace(STRING_SEQUENCE_PATTERN, '') + .replace(CSI_SEQUENCE_PATTERN, '') + .replace(ESCAPE_SEQUENCE_PATTERN, '') +} diff --git a/src/shared/app-version.ts b/src/shared/app-version.ts index a904b4990c3..e3c537f4534 100644 --- a/src/shared/app-version.ts +++ b/src/shared/app-version.ts @@ -94,3 +94,13 @@ export function compareAppVersions(left: string, right: string): number { return 0 } + +/** True when `current` is at or past `target`. Unparseable or absent versions never qualify. */ +export function hasReachedAppVersion(current: string, target: string | null): boolean { + return ( + target !== null && + isValidAppVersion(current) && + isValidAppVersion(target) && + compareAppVersions(current, target) >= 0 + ) +} diff --git a/src/main/github/check-job-log-tail-slice.test.ts b/src/shared/check-job-log-tail-slice.test.ts similarity index 100% rename from src/main/github/check-job-log-tail-slice.test.ts rename to src/shared/check-job-log-tail-slice.test.ts diff --git a/src/main/github/check-job-log-tail-slice.ts b/src/shared/check-job-log-tail-slice.ts similarity index 82% rename from src/main/github/check-job-log-tail-slice.ts rename to src/shared/check-job-log-tail-slice.ts index 694a5dfec99..a5a9f483b1f 100644 --- a/src/main/github/check-job-log-tail-slice.ts +++ b/src/shared/check-job-log-tail-slice.ts @@ -10,8 +10,19 @@ export const PR_CHECK_LOG_TAIL_EARLIER_SEPARATOR = '… earlier errors …' const ERROR_LINE_PATTERN = /(?:##\[error\]|::error::|::error\b|\berror:|FAILED|exit code|ENOENT|EACCES|panic:|AssertionError)/i +// Why: this slicer runs in main (GitHub/GitLab log fetches) and in the renderer +// (defensive re-slice of traces from older remote runtimes), so no Buffer. +function utf8ByteLength(text: string): number { + let bytes = 0 + for (const character of text) { + const codePoint = character.codePointAt(0) ?? 0 + bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : codePoint < 0x10000 ? 3 : 4 + } + return bytes +} + function applyLogTailByteCap(text: string): string { - if (Buffer.byteLength(text, 'utf8') <= PR_CHECK_LOG_TAIL_BYTES) { + if (utf8ByteLength(text) <= PR_CHECK_LOG_TAIL_BYTES) { return text } return sliceTrailingTextByUtf8Bytes(text, PR_CHECK_LOG_TAIL_BYTES) @@ -21,7 +32,7 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { let byteLength = 0 const characters = Array.from(text) for (let index = characters.length - 1; index >= 0; index -= 1) { - const characterByteLength = Buffer.byteLength(characters[index] ?? '', 'utf8') + const characterByteLength = utf8ByteLength(characters[index] ?? '') if (byteLength + characterByteLength > byteLimit) { return characters.slice(index + 1).join('') } @@ -32,14 +43,13 @@ function sliceTrailingTextByUtf8Bytes(text: string, byteLimit: number): string { function joinLogExcerptWithByteCap(prefixLines: string[], recentLines: string[]): string { const prefix = prefixLines.join('\n') - const prefixByteLength = Buffer.byteLength(prefix, 'utf8') + const prefixByteLength = utf8ByteLength(prefix) if (prefixByteLength >= PR_CHECK_LOG_TAIL_BYTES) { return sliceTrailingTextByUtf8Bytes(prefix, PR_CHECK_LOG_TAIL_BYTES) } const separator = prefix.length > 0 && recentLines.length > 0 ? '\n' : '' - const recentBudget = - PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - Buffer.byteLength(separator, 'utf8') + const recentBudget = PR_CHECK_LOG_TAIL_BYTES - prefixByteLength - utf8ByteLength(separator) const recentTail = sliceTrailingTextByUtf8Bytes(recentLines.join('\n'), recentBudget) return `${prefix}${separator}${recentTail}` } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9645d3ba3b6..80fca73c828 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -479,6 +479,7 @@ export function getDefaultUIState(): PersistedUIState { hideAutomationGeneratedWorkspaces: false, hideCliCreatedWorkspaces: false, hideDetachedHeadWorkspaces: false, + alwaysShowDefaultBranchWorkspace: true, showDotfilesByWorktree: {}, filterRepoIds: [], collapsedGroups: [], diff --git a/src/shared/contextual-tours.ts b/src/shared/contextual-tours.ts index e57161d3860..623a0bbd6ec 100644 --- a/src/shared/contextual-tours.ts +++ b/src/shared/contextual-tours.ts @@ -30,6 +30,8 @@ export type ContextualTourStepAction = { export type ContextualTourStepPlacement = 'top' | 'right' | 'bottom' | 'left' export type ContextualTourStep = { + // Stable anchor for localized copy — position-keyed translations shift onto the wrong step when one is inserted. + id?: string title: string body: string targetSelector: string @@ -146,14 +148,16 @@ export const CONTEXTUAL_TOURS = [ id: 'automations', steps: [ { + id: 'automations-intro', title: 'What is an automation?', body: 'Automations run agent work on a schedule. Add an automation by clicking this button.', targetSelector: '[data-contextual-tour-target="automations-create"]', requiredForStart: true }, { + id: 'automations-results', title: 'Find the results', - body: 'Runs show when automations executed, what happened, and where to inspect their output.', + body: 'Runs show when automations ran, what happened, and where to inspect their output.', targetSelector: '[data-contextual-tour-target="automations-runs"]' } ] diff --git a/src/shared/gitlab-job-log-excerpt.test.ts b/src/shared/gitlab-job-log-excerpt.test.ts new file mode 100644 index 00000000000..d477f9f094b --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { gitLabJobTraceToLogExcerpt, toGitLabJobLogExcerptResult } from './gitlab-job-log-excerpt' +import { PR_CHECK_LOG_TAIL_BYTES } from './check-job-log-tail-slice' + +const CSI_ERASE = '\u001b[0K' +const RESET = '\u001b[0;m' +const CR = '\r' + +describe('gitLabJobTraceToLogExcerpt', () => { + it('keeps the visible header of a section marker and drops the marker itself', () => { + const trace = [ + `section_start:1699000000:build_script${CR}${CSI_ERASE}$ pnpm build`, + 'built in 3s', + `section_end:1699000030:build_script${CR}${CSI_ERASE}` + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('$ pnpm build') + expect(excerpt).toContain('built in 3s') + expect(excerpt).not.toContain('section_start') + expect(excerpt).not.toContain('section_end') + }) + + it('strips ANSI colour sequences', () => { + const excerpt = gitLabJobTraceToLogExcerpt(`${RESET}ERROR: Job failed${RESET}`) + + expect(excerpt).toBe('ERROR: Job failed') + expect(excerpt).not.toContain('[0;m') + }) + + it('splits carriage-return progress output so the line tail still applies', () => { + const progress = Array.from({ length: 5_000 }, (_, index) => `downloading ${index}%`).join('\r') + + const excerpt = gitLabJobTraceToLogExcerpt(`${progress}\nERROR: Job failed: exit code 1`) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).not.toContain('downloading 0%') + expect(excerpt.length).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + }) + + it('bounds a multi-megabyte trace below the 1 MB runtime transport frame cap', () => { + const huge = Array.from( + { length: 60_000 }, + (_, index) => `line ${index} ${'x'.repeat(60)}` + ).join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(huge) + + expect(huge.length).toBeGreaterThan(1024 * 1024) + expect(Buffer.byteLength(excerpt, 'utf8')).toBeLessThanOrEqual(PR_CHECK_LOG_TAIL_BYTES) + expect(excerpt).toContain('line 59999') + }) + + it('preserves earlier error context buried under trailing noise', () => { + const trace = [ + ...Array.from({ length: 150 }, (_, index) => `Installing package ${index}`), + 'ERROR: Job failed: exit code 1', + ...Array.from({ length: 90 }, (_, index) => `Uploading artifact ${index}`) + ].join('\n') + + const excerpt = gitLabJobTraceToLogExcerpt(trace) + + expect(excerpt).toContain('ERROR: Job failed: exit code 1') + expect(excerpt).toContain('Uploading artifact 89') + }) + + it('bounds the raw trace before stripping so a huge log is not scanned in full', () => { + const filler = `${'x'.repeat(200)}\n`.repeat(6_000) // ~1.2 MB, above the raw cap + const excerpt = gitLabJobTraceToLogExcerpt(`head marker\n${filler}ERROR: Job failed`) + + expect(excerpt).toContain('ERROR: Job failed') + expect(excerpt).not.toContain('head marker') + }) + + it('returns an empty excerpt for a job that never produced a log', () => { + expect(gitLabJobTraceToLogExcerpt('')).toBe('') + expect(gitLabJobTraceToLogExcerpt(` \n${CSI_ERASE}\n `)).toBe('') + }) +}) + +describe('toGitLabJobLogExcerptResult', () => { + it('bounds a successful trace and passes errors through untouched', () => { + const bounded = toGitLabJobLogExcerptResult({ ok: true, trace: `${RESET}done` }) + expect(bounded).toEqual({ ok: true, trace: 'done' }) + + const failure = { ok: false, error: '403 Forbidden' } as const + expect(toGitLabJobLogExcerptResult(failure)).toBe(failure) + }) +}) diff --git a/src/shared/gitlab-job-log-excerpt.ts b/src/shared/gitlab-job-log-excerpt.ts new file mode 100644 index 00000000000..66754edb751 --- /dev/null +++ b/src/shared/gitlab-job-log-excerpt.ts @@ -0,0 +1,52 @@ +import { stripAnsiEscapeSequences } from './ansi-escape-sequences' +import { sliceCheckLogTail } from './check-job-log-tail-slice' +import type { GitLabJobTraceResult } from './gitlab-types' + +// GitLab wraps collapsible sections in `section_start::\r0K`; +// strip only through the CR so the human-readable header on the same line survives. +const SECTION_MARKER_PREFIX_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*?\r/gm +// Sections without a visible header leave a marker-only line; drop it whole. +const SECTION_MARKER_LINE_PATTERN = /^section_(?:start|end):\d+:[^\r\n]*\n?/gm + +// Why: stripping makes several full passes over the input, so a multi-megabyte CI log +// would stall the main-process event loop before the tail is trimmed to 16 KB anyway. +// 512 K chars is far more than the excerpt can keep, even with markup removed. +const MAX_RAW_TRACE_CHARS = 512 * 1024 + +function rawTraceTail(trace: string): string { + if (trace.length <= MAX_RAW_TRACE_CHARS) { + return trace + } + const tail = trace.slice(trace.length - MAX_RAW_TRACE_CHARS) + // Drop the partial first line so a marker or escape cut in half cannot survive stripping. + const firstLineBreak = tail.indexOf('\n') + return firstLineBreak === -1 ? tail : tail.slice(firstLineBreak + 1) +} + +/** + * Turn a raw `glab api .../jobs/:id/trace` body into a bounded, readable excerpt. + * + * Bounding is not cosmetic: the runtime RPC transports drop frames over 1 MB + * (`MAX_WS_MESSAGE_BYTES` / `MAX_RELAY_MESSAGE_BYTES`), so a multi-megabyte CI + * log must be cut before it crosses the wire, not after. + */ +export function gitLabJobTraceToLogExcerpt(trace: string): string { + if (!trace) { + return '' + } + // Order matters: ANSI first so the erase-to-EOL sequence between the marker's CR + // and its header text is gone, CR normalisation last so the marker patterns can + // still see the CR that separates a marker from its visible header. + const readable = stripAnsiEscapeSequences(rawTraceTail(trace)) + .replace(SECTION_MARKER_PREFIX_PATTERN, '') + .replace(SECTION_MARKER_LINE_PATTERN, '') + // Why: progress output redraws with bare CR, which would otherwise make the + // whole job log a single line and defeat the line-based tail. + .replace(/\r\n?/g, '\n') + return sliceCheckLogTail(readable).trim() +} + +/** Applied in main so only the excerpt crosses IPC / the runtime RPC socket. */ +export function toGitLabJobLogExcerptResult(result: GitLabJobTraceResult): GitLabJobTraceResult { + return result.ok ? { ok: true, trace: gitLabJobTraceToLogExcerpt(result.trace) } : result +} diff --git a/src/shared/gitlab-job-trace-check-details.test.ts b/src/shared/gitlab-job-trace-check-details.test.ts new file mode 100644 index 00000000000..59cf24cbf79 --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import { + gitLabJobCanHaveTrace, + gitLabJobTraceToCheckRunDetails +} from './gitlab-job-trace-check-details' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './gitlab-types' +import type { PRCheckDetail } from './types' + +const STRINGS = { emptyTrace: 'No log is available for this GitLab job.' } + +const failedJob: PRCheckDetail = { + name: 'Component Tests: Purchase API', + status: 'completed', + conclusion: 'failure', + url: 'https://gitlab.example.test/acme/orca/-/jobs/42', + gitlabJobId: 42 +} + +describe('gitLabJobTraceToCheckRunDetails', () => { + it('renders the trace as a single job log tail', () => { + const details = gitLabJobTraceToCheckRunDetails( + failedJob, + 'FAIL src/purchase/refund.spec.ts\nERROR: Job failed: exit code 1', + STRINGS + ) + + expect(details.jobs).toHaveLength(1) + expect(details.jobs[0]?.id).toBe(42) + expect(details.jobs[0]?.conclusion).toBe('failure') + expect(details.jobs[0]?.logTail).toContain('ERROR: Job failed: exit code 1') + expect(details.summary).toBeNull() + }) + + it('copies status and conclusion from the row so the poll cache is not evicted', () => { + const details = gitLabJobTraceToCheckRunDetails(failedJob, 'boom', STRINGS) + + expect(details.status).toBe(failedJob.status) + expect(details.conclusion).toBe(failedJob.conclusion) + }) + + it('explains an empty trace instead of falling back to "no inline details"', () => { + const manualJob: PRCheckDetail = { + name: 'deploy: production', + status: 'completed', + conclusion: 'neutral', + url: null, + gitlabJobId: 77 + } + + const details = gitLabJobTraceToCheckRunDetails(manualJob, '', STRINGS) + + expect(details.jobs).toEqual([]) + expect(details.summary).toBe(STRINGS.emptyTrace) + }) + + it('re-slices a raw trace from an older remote runtime that ignored logExcerpt', () => { + const raw = Array.from({ length: 5_000 }, (_, index) => `line ${index}`).join('\n') + + const details = gitLabJobTraceToCheckRunDetails(failedJob, raw, STRINGS) + + expect(details.jobs[0]?.logTail).toContain('line 4999') + expect(details.jobs[0]?.logTail).not.toContain('line 0\n') + }) +}) + +describe('gitLabJobCanHaveTrace', () => { + // Why: GitLab answers 404 for a job that never ran, so skip the round trip. Jobs that + // may have run are still asked; main turns a missing log into an empty trace. + const cases: { status: string; expected: boolean }[] = [ + { status: 'failed', expected: true }, + { status: 'success', expected: true }, + { status: 'running', expected: true }, + { status: 'canceled', expected: true }, + { status: 'created', expected: false }, + { status: 'pending', expected: false }, + { status: 'scheduled', expected: false }, + { status: 'manual', expected: false }, + { status: 'skipped', expected: false } + ] + + for (const { status, expected } of cases) { + it(`${expected ? 'requests' : 'skips'} a trace for a ${status} job`, () => { + const job = { + id: 5, + name: 'unit', + stage: 'test', + status, + webUrl: '', + duration: null + } as unknown as GitLabPipelineJob + const [check] = gitLabPipelineJobsToPRChecks([job]) + + expect(gitLabJobCanHaveTrace(check)).toBe(expected) + }) + } +}) diff --git a/src/shared/gitlab-job-trace-check-details.ts b/src/shared/gitlab-job-trace-check-details.ts new file mode 100644 index 00000000000..fd81789bc0a --- /dev/null +++ b/src/shared/gitlab-job-trace-check-details.ts @@ -0,0 +1,70 @@ +import { gitLabJobTraceToLogExcerpt } from './gitlab-job-log-excerpt' +import type { PRCheckDetail, PRCheckRunDetails } from './types' + +export type GitLabJobTraceCheckDetailsStrings = { + /** Shown for a job with no log: never ran, waiting on a human, or log erased/expired. */ + emptyTrace: string +} + +/** + * Whether GitLab can have a trace for this job yet. + * + * Jobs that never started (`created`/`pending` -> queued) and jobs waiting on a human + * (`manual` -> neutral) or bypassed (`skipped`) have no trace, so skip the round trip. + * Other jobs may still lack a log (canceled before start, erased); main maps that 404 + * to an empty trace rather than an error. + */ +export function gitLabJobCanHaveTrace(check: PRCheckDetail): boolean { + if (check.status === 'queued') { + return false + } + return check.conclusion !== 'neutral' && check.conclusion !== 'skipped' +} + +/** + * Adapt a GitLab job trace to the provider-neutral check-details shape the Checks + * panel and the full-details tab already render. GitLab exposes one flat trace per + * job rather than GitHub's step/annotation breakdown, so it lands in a single job's + * `logTail`. + */ +export function gitLabJobTraceToCheckRunDetails( + check: PRCheckDetail, + trace: string, + strings: GitLabJobTraceCheckDetailsStrings +): PRCheckRunDetails { + // Re-slice defensively: an older remote runtime returns the raw trace because it + // does not understand the `logExcerpt` request flag. + const logTail = gitLabJobTraceToLogExcerpt(trace) + return { + name: check.name, + // Why: copying the row's own state keeps the panel's status/conclusion cache + // invalidation from evicting this entry on every poll tick. + status: check.status, + conclusion: check.conclusion, + url: check.url, + detailsUrl: check.url, + startedAt: null, + completedAt: null, + title: null, + // Why: a job with no log still needs a distinct explanation, otherwise it falls + // back to the generic "no inline details" text this feature exists to remove. + summary: logTail ? null : strings.emptyTrace, + text: null, + annotations: [], + jobs: logTail + ? [ + { + id: check.gitlabJobId ?? null, + name: check.name, + status: check.status, + conclusion: check.conclusion, + startedAt: null, + completedAt: null, + url: check.url, + logTail, + steps: [] + } + ] + : [] + } +} diff --git a/src/shared/gitlab-pipeline-checks.test.ts b/src/shared/gitlab-pipeline-checks.test.ts index 673fcf3cd16..71f2be73d8d 100644 --- a/src/shared/gitlab-pipeline-checks.test.ts +++ b/src/shared/gitlab-pipeline-checks.test.ts @@ -44,26 +44,42 @@ describe('gitLabPipelineJobsToPRChecks', () => { name: 'test: unit', status: 'completed', conclusion: 'failure', - url: 'https://gitlab.com/acme/orca/-/jobs/1' + url: 'https://gitlab.com/acme/orca/-/jobs/1', + gitlabJobId: 1 }, { name: 'deploy: deploy', status: 'completed', conclusion: 'neutral', - url: null + url: null, + gitlabJobId: 2 }, { name: 'deploy: delayed deploy', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/3' + url: 'https://gitlab.com/acme/orca/-/jobs/3', + gitlabJobId: 3 }, { name: 'integration: external callback', status: 'queued', conclusion: 'pending', - url: 'https://gitlab.com/acme/orca/-/jobs/4' + url: 'https://gitlab.com/acme/orca/-/jobs/4', + gitlabJobId: 4 } ]) }) + + it('omits the job id when GitLab does not report a usable one', () => { + const jobs = [ + { id: 0, name: 'zero', stage: '', status: 'failed', webUrl: '', duration: null }, + { name: 'missing', stage: '', status: 'failed', webUrl: '', duration: null } + ] as unknown as GitLabPipelineJob[] + + // Why: the runtime RPC schema requires a positive int, so a falsy id must not be forwarded. + for (const check of gitLabPipelineJobsToPRChecks(jobs)) { + expect(check).not.toHaveProperty('gitlabJobId') + } + }) }) diff --git a/src/shared/gitlab-pipeline-checks.ts b/src/shared/gitlab-pipeline-checks.ts index f993be157a3..9cb9a2b1ebd 100644 --- a/src/shared/gitlab-pipeline-checks.ts +++ b/src/shared/gitlab-pipeline-checks.ts @@ -62,6 +62,8 @@ export function gitLabPipelineJobsToPRChecks(jobs: GitLabPipelineJob[]): PRCheck name: job.stage ? `${job.stage}: ${job.name}` : job.name, status: mapGitLabPipelineJobStatusToCheckStatus(job.status), conclusion: mapGitLabPipelineJobStatusToConclusion(job.status), - url: job.webUrl || null + url: job.webUrl || null, + // Truthiness, not != null: the runtime RPC schema requires a positive int. + ...(job.id ? { gitlabJobId: job.id } : {}) })) } diff --git a/src/shared/harness-injected-user-turns.test.ts b/src/shared/harness-injected-user-turns.test.ts index 41f139059c6..b18eb451316 100644 --- a/src/shared/harness-injected-user-turns.test.ts +++ b/src/shared/harness-injected-user-turns.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest' -import { isKnownHarnessInjectedUserTurnText } from './harness-injected-user-turns' +import { + isCompactContinuationUserTurnText, + isKnownHarnessInjectedUserTurnText +} from './harness-injected-user-turns' describe('isKnownHarnessInjectedUserTurnText', () => { it('matches every known harness tag, including attribute-carrying forms', () => { @@ -59,6 +62,16 @@ describe('isKnownHarnessInjectedUserTurnText', () => { ).toBe(true) }) + it('distinguishes compact continuation from other harness turns', () => { + expect( + isCompactContinuationUserTurnText( + 'This session is being continued from a previous conversation. Summary follows.' + ) + ).toBe(true) + expect(isCompactContinuationUserTurnText('done')).toBe(false) + expect(isCompactContinuationUserTurnText('/review')).toBe(false) + }) + it('is case-insensitive and ignores surrounding whitespace', () => { expect(isKnownHarnessInjectedUserTurnText(' done')).toBe(true) expect(isKnownHarnessInjectedUserTurnText('\n hi')).toBe(true) diff --git a/src/shared/harness-injected-user-turns.ts b/src/shared/harness-injected-user-turns.ts index c9275732fa3..016a0839d10 100644 --- a/src/shared/harness-injected-user-turns.ts +++ b/src/shared/harness-injected-user-turns.ts @@ -39,6 +39,7 @@ const KNOWN_HARNESS_TAG_NAMES = new Set([ // Injected turns identified by a leading string rather than a known tag name: // the harness only emits in its attributed `` form // (a bare is a real RSS/XML paste), plus prose deliveries and notices. +const COMPACT_CONTINUATION_PREFIX = 'this session is being continued from a previous conversation' const HARNESS_INJECTED_TURN_PREFIXES = [ '= text.length) { - return false - } - const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT) - const normalized = text.slice(start, headEnd).toLowerCase() - if (normalized.length === 0) { + const normalized = normalizedHarnessTurnHead(text) + if (!normalized) { return false } const tagName = LEADING_TAG_NAME.exec(normalized)?.[1] @@ -77,6 +69,24 @@ export function isKnownHarnessInjectedUserTurnText(text: string): boolean { return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix)) } +/** True only for the observed post-compaction continuation prompt. */ +export function isCompactContinuationUserTurnText(text: string): boolean { + return normalizedHarnessTurnHead(text).startsWith(COMPACT_CONTINUATION_PREFIX) +} + +function normalizedHarnessTurnHead(text: string): string { + let start = 0 + const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT) + while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) { + start += 1 + } + if (start >= text.length) { + return '' + } + const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT) + return text.slice(start, headEnd).toLowerCase() +} + function isAsciiWhitespace(code: number): boolean { return code === 32 || code === 9 || code === 10 || code === 13 || code === 12 } diff --git a/src/shared/issue-link-input.test.ts b/src/shared/issue-link-input.test.ts new file mode 100644 index 00000000000..bf026edc43f --- /dev/null +++ b/src/shared/issue-link-input.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' + +import { getIssueLinkProviderFromUrl, parseIssueLinkInput } from './issue-link-input' + +describe('getIssueLinkProviderFromUrl', () => { + it('detects GitHub issue URLs', () => { + expect(getIssueLinkProviderFromUrl('https://github.com/o/r/issues/12')).toBe('github') + }) + + it('does not flip the provider for a GitHub pull URL', () => { + expect(getIssueLinkProviderFromUrl('https://github.com/o/r/pull/12')).toBeNull() + }) + + it('detects Linear issue URLs with and without a slug', () => { + expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335')).toBe('linear') + expect(getIssueLinkProviderFromUrl('https://linear.app/acme/issue/STA-335/some-slug')).toBe( + 'linear' + ) + }) + + it('ignores non-issue paths on the Linear host', () => { + expect(getIssueLinkProviderFromUrl('https://linear.app/acme/team/ENG/all')).toBeNull() + }) + + it('rejects hosts that merely contain linear.app', () => { + expect(getIssueLinkProviderFromUrl('https://linear.app.evil.com/acme/issue/STA-335')).toBeNull() + }) + + // Linear and Jira issue-key shapes are byte-identical, so a bare key must + // never override the user's explicit provider choice. + it('is not decisive for bare issue keys', () => { + expect(getIssueLinkProviderFromUrl('STA-335')).toBeNull() + expect(getIssueLinkProviderFromUrl(' STA-335 ')).toBeNull() + }) + + it('is not decisive for bare numbers', () => { + expect(getIssueLinkProviderFromUrl('1234')).toBeNull() + expect(getIssueLinkProviderFromUrl('#1234')).toBeNull() + }) + + it('returns null for empty input', () => { + expect(getIssueLinkProviderFromUrl('')).toBeNull() + expect(getIssueLinkProviderFromUrl(' ')).toBeNull() + }) +}) + +describe('parseIssueLinkInput', () => { + describe('github provider', () => { + it('accepts bare and hash-prefixed numbers', () => { + expect(parseIssueLinkInput('12', 'github')).toEqual({ provider: 'github', number: 12 }) + expect(parseIssueLinkInput('#12', 'github')).toEqual({ provider: 'github', number: 12 }) + }) + + it('accepts issue URLs', () => { + expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'github')).toEqual({ + provider: 'github', + number: 12 + }) + }) + + it('rejects pull URLs', () => { + expect(parseIssueLinkInput('https://github.com/o/r/pull/12', 'github')).toBeNull() + }) + + it('rejects non-positive numbers and junk', () => { + expect(parseIssueLinkInput('0', 'github')).toBeNull() + expect(parseIssueLinkInput('-1', 'github')).toBeNull() + expect(parseIssueLinkInput('not an issue', 'github')).toBeNull() + expect(parseIssueLinkInput(' ', 'github')).toBeNull() + }) + + it('rejects Linear identifiers', () => { + expect(parseIssueLinkInput('STA-335', 'github')).toBeNull() + }) + + // Past the safe-integer range every digit string parses to the same float, + // so an unbounded parse would link an arbitrary issue number. + it('rejects numbers beyond the safe-integer range', () => { + expect(parseIssueLinkInput('9'.repeat(400), 'github')).toBeNull() + }) + }) + + describe('linear provider', () => { + it('accepts bare identifiers and normalizes case', () => { + expect(parseIssueLinkInput('STA-335', 'linear')).toEqual({ + provider: 'linear', + identifier: 'STA-335' + }) + expect(parseIssueLinkInput('sta-335', 'linear')).toEqual({ + provider: 'linear', + identifier: 'STA-335' + }) + }) + + it('omits the organization key for bare identifiers', () => { + expect(parseIssueLinkInput('STA-335', 'linear')).not.toHaveProperty('organizationUrlKey') + }) + + it('accepts issue URLs and returns the organization key', () => { + expect( + parseIssueLinkInput('https://linear.app/acme/issue/STA-335/some-slug', 'linear') + ).toEqual({ + provider: 'linear', + identifier: 'STA-335', + organizationUrlKey: 'acme' + }) + }) + + it('rejects GitHub URLs', () => { + expect(parseIssueLinkInput('https://github.com/o/r/issues/12', 'linear')).toBeNull() + }) + + it('rejects junk and empty input', () => { + expect(parseIssueLinkInput('not an issue', 'linear')).toBeNull() + expect(parseIssueLinkInput(' ', 'linear')).toBeNull() + }) + }) +}) diff --git a/src/shared/issue-link-input.ts b/src/shared/issue-link-input.ts new file mode 100644 index 00000000000..37d98f55072 --- /dev/null +++ b/src/shared/issue-link-input.ts @@ -0,0 +1,75 @@ +import { parseGitHubIssueOrPRLink } from './github-links' +import { parseLinearIssueInput } from './linear-links' +import type { WorkspaceSourceProvider } from './new-workspace/workspace-source' + +// Why: narrows the canonical provider union instead of minting a parallel one, +// so adding Jira here is a one-entry change rather than a new axis. +export const ISSUE_LINK_PROVIDERS = [ + 'github', + 'linear' +] as const satisfies readonly WorkspaceSourceProvider[] + +export type IssueLinkProvider = (typeof ISSUE_LINK_PROVIDERS)[number] + +export function isIssueLinkProvider(value: unknown): value is IssueLinkProvider { + return ISSUE_LINK_PROVIDERS.includes(value as IssueLinkProvider) +} + +/** URL input only. Linear and Jira issue keys are byte-identical in shape, so a + * bare `STA-335` can never decide a provider — it would override the chip. */ +export function getIssueLinkProviderFromUrl(input: string): IssueLinkProvider | null { + const trimmed = input.trim() + if (!/^https?:\/\//i.test(trimmed)) { + return null + } + + // Why: a URL is decisive only if it names that provider's *issue*. A GitHub + // pull URL is a valid GitHub link but not an issue, and must not flip the + // provider into a state where the field then refuses to save it. + if (parseGitHubIssueOrPRLink(trimmed)?.type === 'issue') { + return 'github' + } + if (parseLinearIssueInput(trimmed)) { + return 'linear' + } + return null +} + +export type ParsedIssueLinkInput = + | { provider: 'github'; number: number } + | { provider: 'linear'; identifier: string; organizationUrlKey?: string } + +/** + * Single parse shared by the dialog's save gate and its payload builder, so a + * value that enables Save is always the value that gets written. + */ +export function parseIssueLinkInput( + input: string, + provider: IssueLinkProvider +): ParsedIssueLinkInput | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + if (provider === 'linear') { + const parsed = parseLinearIssueInput(trimmed) + return parsed ? { provider: 'linear', ...parsed } : null + } + + const link = parseGitHubIssueOrPRLink(trimmed) + if (link) { + // Why: issue and PR numbers live in separate GitHub namespaces for refs; a + // pull URL pasted here must not silently become an issue link. + return link.type === 'issue' ? { provider: 'github', number: link.number } : null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (!/^\d+$/.test(numeric)) { + return null + } + const parsed = Number.parseInt(numeric, 10) + // Why: `/^\d+$/` happily accepts 400 digits, which parseInt turns into + // Infinity — Save would enable and JSON.stringify would persist `null`. + return Number.isSafeInteger(parsed) && parsed > 0 ? { provider: 'github', number: parsed } : null +} diff --git a/src/shared/linear-links.test.ts b/src/shared/linear-links.test.ts index de7993714e0..24ac962df6e 100644 --- a/src/shared/linear-links.test.ts +++ b/src/shared/linear-links.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + buildLinearIssueLinkUpdates, + buildLinearIssueUrl, buildLinearPersonalApiKeySettingsUrl, buildLinearTeamUrl, buildLinearWorkspaceApiSettingsUrl, @@ -62,4 +64,83 @@ describe('linear links', () => { expect(parseLinearIssueInput('https://example.com/acme/issue/ENG-123')).toBeNull() expect(parseLinearIssueInput('not an issue')).toBeNull() }) + + // A hostname-only check accepts these, and the parsed link is later handed to + // shell.openUrl — so the protocol gate is load-bearing, not cosmetic. + it('rejects linear.app URLs on non-web protocols', () => { + for (const input of [ + 'file://linear.app/acme/issue/ENG-123', + 'javascript://linear.app/acme/issue/ENG-123', + 'ftp://linear.app/acme/issue/ENG-123' + ]) { + expect(parseLinearIssueInput(input)).toBeNull() + } + }) +}) + +describe('buildLinearIssueUrl', () => { + it('builds issue URLs from an identifier and organization key', () => { + expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: 'acme' })).toBe( + 'https://linear.app/acme/issue/STA-335' + ) + }) + + it('returns null when either part is missing or blank', () => { + expect(buildLinearIssueUrl({ identifier: 'STA-335' })).toBeNull() + expect(buildLinearIssueUrl({ organizationUrlKey: 'acme' })).toBeNull() + expect(buildLinearIssueUrl({ identifier: ' ', organizationUrlKey: 'acme' })).toBeNull() + expect(buildLinearIssueUrl({ identifier: 'STA-335', organizationUrlKey: null })).toBeNull() + }) + + it('encodes URL-unsafe path segments', () => { + expect(buildLinearIssueUrl({ identifier: 'A/B-1', organizationUrlKey: 'acme inc' })).toBe( + 'https://linear.app/acme%20inc/issue/A%2FB-1' + ) + }) +}) + +describe('buildLinearIssueLinkUpdates', () => { + function expectNoUndefinedValues(updates: Record): void { + expect(Object.keys(updates).filter((key) => updates[key] === undefined)).toEqual([]) + } + + it('clears every link field for empty input', () => { + for (const input of ['', ' ']) { + const result = buildLinearIssueLinkUpdates(input) + expect(result).toEqual({ + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null + }) + } + }) + + // Inheriting the previous issue's org key would let resolveLegacyLinearLinkWorkspace + // match it and persist a workspace id the new issue does not belong to. + it('clears the organization key for bare identifiers', () => { + const result = buildLinearIssueLinkUpdates('STA-335') + + expect(result).toEqual({ + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null + }) + expectNoUndefinedValues(result as Record) + }) + + it('takes the organization key from a Linear issue URL', () => { + const result = buildLinearIssueLinkUpdates('https://linear.app/acme/issue/sta-335/fix-auth') + + expect(result).toEqual({ + linkedLinearIssue: 'STA-335', + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: 'acme' + }) + expectNoUndefinedValues(result as Record) + }) + + it('returns null for unparseable input', () => { + expect(buildLinearIssueLinkUpdates('not an issue')).toBeNull() + expect(buildLinearIssueLinkUpdates('https://github.com/o/r/issues/12')).toBeNull() + }) }) diff --git a/src/shared/linear-links.ts b/src/shared/linear-links.ts index 462ac853061..0f1db592736 100644 --- a/src/shared/linear-links.ts +++ b/src/shared/linear-links.ts @@ -24,6 +24,18 @@ export function buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string | : 'https://linear.app/settings/api' } +export function buildLinearIssueUrl(args: { + identifier?: string | null + organizationUrlKey?: string | null +}): string | null { + const identifier = args.identifier?.trim() + const organizationUrlKey = args.organizationUrlKey?.trim() + if (!identifier || !organizationUrlKey) { + return null + } + return `https://linear.app/${encodeURIComponent(organizationUrlKey)}/issue/${encodeURIComponent(identifier)}` +} + export function getLinearOrganizationUrlKeyFromIssueUrl(issueUrl?: string | null): string | null { if (!issueUrl) { return null @@ -44,6 +56,12 @@ export type ParsedLinearIssueInput = { organizationUrlKey?: string } +export type LinearIssueLinkUpdates = { + linkedLinearIssue: string | null + linkedLinearIssueWorkspaceId: string | null + linkedLinearIssueOrganizationUrlKey: string | null +} + const LINEAR_IDENTIFIER_PATTERN = /^[A-Za-z][A-Za-z0-9_]*-\d+$/ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | null { @@ -58,6 +76,12 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n try { const parsed = new URL(trimmed) + // Why: hostname alone accepts file://linear.app/..., which then passes + // validation and persists as a link. The GitHub parser already gates on + // protocol; this keeps the two in step. + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return null + } if (parsed.hostname !== 'linear.app') { return null } @@ -80,3 +104,31 @@ export function parseLinearIssueInput(input: string): ParsedLinearIssueInput | n return null } } + +export const LINEAR_ISSUE_LINK_CLEARED: LinearIssueLinkUpdates = { + linkedLinearIssue: null, + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: null +} + +/** Shared by the CLI and the meta dialog so both persist identical state for + * identical input. Returns null when the input is neither empty nor parseable. */ +export function buildLinearIssueLinkUpdates(input: string): LinearIssueLinkUpdates | null { + if (input.trim() === '') { + return { ...LINEAR_ISSUE_LINK_CLEARED } + } + + const parsed = parseLinearIssueInput(input) + if (!parsed) { + return null + } + + return { + linkedLinearIssue: parsed.identifier, + // Both scoping fields describe the issue being REPLACED. A stale org key + // pins resolveLegacyLinearLinkWorkspace to the wrong org — not-found, or a + // silently wrong issue on a prefix collision. Nulling only costs a re-read. + linkedLinearIssueWorkspaceId: null, + linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null + } +} diff --git a/src/shared/manual-compact-prompt-identity.test.ts b/src/shared/manual-compact-prompt-identity.test.ts new file mode 100644 index 00000000000..9205e8b7a80 --- /dev/null +++ b/src/shared/manual-compact-prompt-identity.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest' + +import { + createHookListenerState, + normalizeHookPayload, + resolveCachedClaudeCompactOwnership, + type AgentHookEventPayload, + type HookListenerState +} from './agent-hook-listener' +import type { AgentHookSource } from './agent-hook-relay' +import { makePaneKey } from './stable-pane-id' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('manual-compact-prompt', LEAF_ID) +const PROMPT_ID_1 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1' +const PROMPT_ID_2 = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2' + +function accept( + state: HookListenerState, + source: AgentHookSource, + payload: Record, + paneKey = PANE_KEY +): AgentHookEventPayload | null { + const event = normalizeHookPayload(state, source, { paneKey, payload }, 'production') + if (event) { + state.lastStatusByPaneKey.set( + paneKey, + resolveCachedClaudeCompactOwnership(state.lastStatusByPaneKey.get(paneKey), event) + ) + } + return event +} + +function beginManualCompact( + state: HookListenerState, + promptId: string, + sessionId: string | undefined = 'session-a' +): AgentHookEventPayload | null { + const identity = { + prompt_id: promptId, + ...(sessionId ? { session_id: sessionId } : {}) + } + accept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + prompt: '/compact', + ...identity + }) + return accept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'manual', + ...identity + }) +} + +function finishManualCompact( + state: HookListenerState, + promptId: string | undefined, + sessionId: string | null = 'session-a' +): AgentHookEventPayload | null { + return accept(state, 'claude', { + hook_event_name: 'PostCompact', + trigger: 'manual', + ...(promptId ? { prompt_id: promptId } : {}), + ...(sessionId ? { session_id: sessionId } : {}) + }) +} + +describe('manual compact prompt identity', () => { + it('settles only the matching Claude manual compact', () => { + const state = createHookListenerState() + + const pre = beginManualCompact(state, PROMPT_ID_1) + const post = finishManualCompact(state, PROMPT_ID_1) + + expect(pre).toMatchObject({ + source: 'claude', + providerPromptId: PROMPT_ID_1, + compactTrigger: 'manual', + hookEventName: 'PreCompact', + payload: { state: 'working', prompt: '/compact', agentType: 'claude' } + }) + expect(post).toMatchObject({ + source: 'claude', + providerPromptId: PROMPT_ID_1, + compactTrigger: 'manual', + hookEventName: 'PostCompact', + payload: { state: 'done', prompt: '/compact', agentType: 'claude' } + }) + }) + + it('rejects stale, duplicate, and session-mismatched completion', () => { + const state = createHookListenerState() + beginManualCompact(state, PROMPT_ID_1) + expect(finishManualCompact(state, PROMPT_ID_1, 'session-b')).toBeNull() + expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull() + expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull() + + beginManualCompact(state, PROMPT_ID_2) + expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + providerPromptId: PROMPT_ID_2, + hookEventName: 'PreCompact', + payload: { state: 'working' } + }) + }) + + it('does not let stale compact events replace intervening provider work', () => { + const state = createHookListenerState() + beginManualCompact(state, PROMPT_ID_1) + expect(finishManualCompact(state, PROMPT_ID_1)).not.toBeNull() + accept(state, 'codex', { + hook_event_name: 'UserPromptSubmit', + prompt: 'new Codex work', + session_id: 'codex-session' + }) + + expect( + accept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'manual', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + ).toBeNull() + expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + source: 'codex', + payload: { state: 'working', prompt: 'new Codex work', agentType: 'codex' } + }) + }) + + it('fails closed without exact prompt identity and session presence', () => { + const state = createHookListenerState() + expect(beginManualCompact(state, 'not-a-uuid')).toBeNull() + expect(beginManualCompact(state, PROMPT_ID_1)).not.toBeNull() + expect(finishManualCompact(state, undefined)).toBeNull() + expect(finishManualCompact(state, PROMPT_ID_1, null)).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)?.hookEventName).toBe('PreCompact') + }) + + it('keeps automatic compact working and ignores unproven Kimi events', () => { + const state = createHookListenerState() + const current = accept(state, 'claude', { + hook_event_name: 'UserPromptSubmit', + prompt: 'continue the task', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + const automaticPre = accept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'auto', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + const automaticPost = accept(state, 'claude', { + hook_event_name: 'PostCompact', + trigger: 'auto', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + const kimiPre = accept(state, 'kimi', { + hook_event_name: 'PreCompact', + trigger: 'manual', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + const kimiPost = accept(state, 'kimi', { + hook_event_name: 'PostCompact', + trigger: 'manual', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + + expect(automaticPre).toMatchObject({ + compactTrigger: 'auto', + hookEventName: 'PreCompact', + payload: { state: 'working' } + }) + expect(automaticPost).toMatchObject({ + compactTrigger: 'auto', + hookEventName: 'PostCompact', + payload: { state: 'working' } + }) + expect(kimiPre).toBeNull() + expect(kimiPost).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + hookEventName: 'PostCompact', + compactTrigger: undefined, + payload: { state: 'working', prompt: current?.payload.prompt } + }) + }) + + it('does not let stale manual completion retire a newer automatic compact', () => { + const state = createHookListenerState() + beginManualCompact(state, PROMPT_ID_1) + expect( + accept(state, 'claude', { + hook_event_name: 'PreCompact', + trigger: 'auto', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + }) + ).toMatchObject({ compactTrigger: 'auto', payload: { state: 'working' } }) + + expect(finishManualCompact(state, PROMPT_ID_1)).toBeNull() + expect(state.lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({ + compactTrigger: 'auto', + hookEventName: 'PreCompact', + payload: { state: 'working' } + }) + }) + + it('does not allocate ownership for unaccepted untrusted pane keys', () => { + const state = createHookListenerState() + for (let index = 0; index < 4_097; index += 1) { + normalizeHookPayload( + state, + 'claude', + { + paneKey: makePaneKey(`untrusted-${index}`, LEAF_ID), + payload: { + hook_event_name: 'PreCompact', + trigger: 'manual', + prompt_id: PROMPT_ID_1, + session_id: 'session-a' + } + }, + 'production' + ) + } + + expect(state.lastStatusByPaneKey.size).toBe(0) + expect(state.lastPromptByPaneKey.size).toBe(0) + }) +}) diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts index e02f2bb9bba..7b1754ad42d 100644 --- a/src/shared/native-chat-ask.test.ts +++ b/src/shared/native-chat-ask.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import type { NativeChatBlock, NativeChatMessage } from './native-chat-types' +import { + NATIVE_CHAT_INTERRUPTED_STATUS_TEXT, + type NativeChatBlock, + type NativeChatMessage +} from './native-chat-types' import { extractPendingAsk, parseAskFromStatus } from './native-chat-ask' function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage { @@ -14,6 +18,26 @@ function result(): NativeChatBlock { return { type: 'tool-result', output: 'ok' } } +/** The row the transcript decoders emit for an interrupted turn. */ +function interrupted(id: string): NativeChatMessage { + return { + id, + role: 'system', + blocks: [{ type: 'text', text: NATIVE_CHAT_INTERRUPTED_STATUS_TEXT }], + timestamp: 1, + source: 'transcript' + } +} + +function userTurn(id: string, text: string): NativeChatMessage { + return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 1, source: 'transcript' } +} + +/** Claude delivers tool results on their own turn, which decodes as role 'tool'. */ +function toolTurn(id: string): NativeChatMessage { + return { id, role: 'tool', blocks: [result()], timestamp: 1, source: 'transcript' } +} + const QUESTIONS_INPUT = { questions: [{ question: 'Deploy?', options: [{ label: 'Yes' }, { label: 'No' }] }] } @@ -51,6 +75,66 @@ describe('extractPendingAsk', () => { expect(pending?.questions[0]?.question).toBe('Deploy?') }) + it('does not strand an answered ask behind a tool call orphaned by an interrupt', () => { + // ESC on a running tool: Claude writes its interrupt record instead of a + // tool result, so that call's FIFO slot never resolves (#11761). + const pending = extractPendingAsk([ + message('m1', [call('Bash', { command: 'sleep 999' })]), + interrupted('m2'), + message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)]), + message('m4', [result()]) + ]) + expect(pending).toBeNull() + }) + + it('drops an ask abandoned by an interrupt', () => { + const pending = extractPendingAsk([ + message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]), + interrupted('m2') + ]) + expect(pending).toBeNull() + }) + + it('keeps an ask that is still awaiting its result after an earlier interrupt', () => { + const pending = extractPendingAsk([ + message('m1', [call('Bash', { command: 'sleep 999' })]), + interrupted('m2'), + message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)]) + ]) + expect(pending?.questions[0]?.question).toBe('Deploy?') + }) + + it('drops an ask the user typed past instead of answering', () => { + // Real transcripts hold asks that never get a result because the user + // escaped the selector and sent a new prompt — the question is over. + const pending = extractPendingAsk([ + message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]), + userTurn('m2', 'never mind, do this instead'), + message('m3', [{ type: 'text', text: 'on it' }]) + ]) + expect(pending).toBeNull() + }) + + it('does not strand an answered ask behind an orphan left by a plain-text interrupt', () => { + // Claude also writes the interrupt as a bare user turn (no + // `interruptedMessageId`), which decodes as a user message, not a status row. + const pending = extractPendingAsk([ + message('m1', [call('Bash', { command: 'sleep 999' })]), + userTurn('m2', '[Request interrupted by user]'), + message('m3', [call('AskUserQuestion', QUESTIONS_INPUT)]), + toolTurn('m4') + ]) + expect(pending).toBeNull() + }) + + it('resolves an ask whose result arrives on its own tool-role turn', () => { + const pending = extractPendingAsk([ + message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)]), + toolTurn('m2') + ]) + expect(pending).toBeNull() + }) + it('ignores malformed question payloads', () => { expect( extractPendingAsk([ diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index b602e5918f8..132afc2706d 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -4,7 +4,7 @@ import type { AskQuestion, InteractiveQuestionParser } from './native-chat-ask-types' -import type { NativeChatMessage } from './native-chat-types' +import { isInterruptedStatusMessage, type NativeChatMessage } from './native-chat-types' export type { AskOption, AskPrompt, AskQuestion, InteractiveQuestionParser } @@ -98,6 +98,16 @@ export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPr let pending: AskPrompt | null = null const outstanding: (AskPrompt | null)[] = [] for (const message of messages) { + // A new user turn (or an interrupt row) ends the turn that owns whatever + // calls are still in flight: their results never arrive, and `tool_use_id` + // is dropped at decode time so those orphans can never be matched by id. + // Without this reset one orphan shifts the result FIFO for the rest of the + // transcript and strands an answered ask as a permanent card over the + // composer (#11761). Claude's tool-result turns decode as role 'tool'. + if (message.role === 'user' || isInterruptedStatusMessage(message)) { + outstanding.length = 0 + pending = null + } for (const block of message.blocks) { if (block.type === 'tool-call') { const parsed = parseToolInput(block.name, block.input) diff --git a/src/shared/native-chat-types.ts b/src/shared/native-chat-types.ts index f88ff013769..96d01f64181 100644 --- a/src/shared/native-chat-types.ts +++ b/src/shared/native-chat-types.ts @@ -131,6 +131,18 @@ export function isToolResultBlock(block: NativeChatBlock): block is NativeChatTo return block.type === 'tool-result' } +/** The provider-authored interrupt row the transcript decoders emit (Claude's + * `interruptedMessageId` record, Codex's `turn_aborted`). The turn it ends + * never delivers results for the tool calls it left in flight. */ +export function isInterruptedStatusMessage(message: NativeChatMessage): boolean { + return ( + message.role === 'system' && + message.blocks.some( + (block) => block.type === 'text' && block.text === NATIVE_CHAT_INTERRUPTED_STATUS_TEXT + ) + ) +} + export function isImageRefBlock(block: NativeChatBlock): block is NativeChatImageRefBlock { return block.type === 'image-ref' } diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index c3f9980e7e6..e684bb50095 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -57,6 +57,66 @@ describe('plugin language-pack artifacts', () => { ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) }) + it.each([ + ['PluginsSettingsSection', 'title', 'Плагины'], + ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], + ['PluginDevelopmentSection', 'title', 'Разработка'] + ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { [component]: { [key]: value } } } } + }) + ).ok + ).toBe(true) + }) + + // Why: the chrome exemption must not leak into the copy that carries a claim + // about what plugins may do — that copy is the whole reason the prefix is broad. + it.each([ + ['description', 'Plugins are sandboxed and cannot read your files.'], + ['systemDescription', 'Every plugin here has been reviewed by Orca.'], + ['featureOff', 'Installed plugins keep running while the system is off.'] + ])('still refuses PluginsSettingsSection.%s', (key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { PluginsSettingsSection: { [key]: value } } } } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses the development-plugin permission promise', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { + PluginDevelopmentSection: { help: 'Dev plugins skip permission review.' } + } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + + it('still refuses install failure copy that reports a trust event', () => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { + components: { + settings: { PluginMarketplaceBrowser: { installFailed: 'Installed successfully.' } } + } + } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + it('forges no trust badge: the community→Official swap is refused', () => { expect( parsePluginLanguagePackArtifact( diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 0e4dd218d95..917adeef69e 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -1,3 +1,8 @@ +import { + translatablePluginChrome, + translatablePluginChromeContainer +} from './plugin-translatable-chrome' + export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000 export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16 @@ -5,6 +10,8 @@ const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'] // Why: every plugin-facing security surface lives under this namespace, so // protecting the whole subtree keeps a new dialog from silently becoming // plugin-writable the way PluginConsentProvenance did when it was extracted. +// The subtree also holds copy with no security meaning; those exact paths are +// listed in plugin-translatable-chrome.ts and stay translatable. const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.' const PROTECTED_TRANSLATION_MODULE = /^plugin/i @@ -45,6 +52,9 @@ function protectedTranslation(path: string): boolean { if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) { return false } + if (translatablePluginChrome(path)) { + return false + } return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length)) } @@ -88,7 +98,12 @@ export function parsePluginLanguagePackArtifact(raw: string): PluginLanguagePack return { ok: false, error: `catalog key ${key || '(empty)'} is not safe` } } const path = frame.path ? `${frame.path}.${key}` : key - if (protectedTranslation(path)) { + // A protected container stays walkable only when exempt chrome sits below + // it; every leaf inside is still matched against its own full path. + if ( + protectedTranslation(path) && + !(isCatalogObject(value) && translatablePluginChromeContainer(path)) + ) { return { ok: false, error: `catalog cannot replace protected security copy at ${path}` } } if (typeof value === 'string') { diff --git a/src/shared/plugins/plugin-translatable-chrome.ts b/src/shared/plugins/plugin-translatable-chrome.ts new file mode 100644 index 00000000000..7a2e4c0fa2a --- /dev/null +++ b/src/shared/plugins/plugin-translatable-chrome.ts @@ -0,0 +1,92 @@ +// Why: the protected-translation prefix is deliberately broad so a consent +// surface added tomorrow is covered the day it lands — the lesson from +// PluginConsentProvenance. That breadth also catches copy that carries no +// security meaning at all: section titles, empty states, search affordances, +// and the local development form. A language pack that cannot translate +// "Refresh" leaves the plugins pane half-translated in every locale. +// +// This list is the narrow exception, and it is a list of exact paths rather +// than a pattern on purpose: anything new stays protected until someone +// deliberately adds it here, so the fail-safe survives. +// +// A path belongs here only if rewriting it cannot mislead the person deciding +// whether to trust a plugin. That rules out, and this list therefore omits: +// +// - trust badges and safety status — `PluginMarketplaceListingRow.official`, +// `.blocked`, `PluginSettingsRow.blocked`; +// - promises about what plugins may do — `PluginsSettingsSection.description` +// ("Plugins run on this computer"), `.systemDescription` ("Nothing runs +// until you review and enable it"), `.featureOff` ("stays disabled"), and +// `PluginDevelopmentSection.help` ("Dev plugins still require permission +// review"). Swapping any of these for reassuring text is the attack; +// - failure copy that reports a trust event — `installFailed` ("The reviewed +// source may have changed"). Sibling `*Failed` strings stay protected too, +// so the boundary is a rule rather than a judgement call per message; +// - destructive confirmations — `PluginRemoveDialog`, `PluginRollbackDialog`; +// - every dialog the existing tests already name as security copy. +const TRANSLATABLE_PLUGIN_CHROME = new Set([ + // Plugins pane frame: headings and list states, no claims about behavior. + 'auto.components.settings.PluginsSettingsSection.title', + 'auto.components.settings.PluginsSettingsSection.systemLabel', + 'auto.components.settings.PluginsSettingsSection.install', + 'auto.components.settings.PluginsSettingsSection.loading', + 'auto.components.settings.PluginsSettingsSection.empty', + 'auto.components.settings.PluginsSettingsSection.emptyTitle', + 'auto.components.settings.PluginsSettingsSection.noInstalledResults', + 'auto.components.settings.PluginsSettingsSection.noInstalledResultsTitle', + // Marketplace browser chrome: refresh, search, and empty states. + 'auto.components.settings.PluginMarketplaceBrowser.manageSources', + 'auto.components.settings.PluginMarketplaceBrowser.addSource', + 'auto.components.settings.PluginMarketplaceBrowser.refresh', + 'auto.components.settings.PluginMarketplaceBrowser.refreshing', + 'auto.components.settings.PluginMarketplaceBrowser.loading', + 'auto.components.settings.PluginMarketplaceBrowser.tryAgain', + 'auto.components.settings.PluginMarketplaceBrowser.clearSearch', + 'auto.components.settings.PluginMarketplaceBrowser.empty', + 'auto.components.settings.PluginMarketplaceBrowser.emptyTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalled', + 'auto.components.settings.PluginMarketplaceBrowser.noInstalledTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noResults', + 'auto.components.settings.PluginMarketplaceBrowser.noResultsTitle', + 'auto.components.settings.PluginMarketplaceBrowser.noSourcesTitle', + // Local development form: field labels and validation, desktop-only paths. + 'auto.components.settings.PluginDevelopmentSection.title', + 'auto.components.settings.PluginDevelopmentSection.add', + 'auto.components.settings.PluginDevelopmentSection.remove', + 'auto.components.settings.PluginDevelopmentSection.pathLabel', + 'auto.components.settings.PluginDevelopmentSection.pathRequired', + 'auto.components.settings.PluginDevelopmentSection.placeholder', + // Settings-search index entries: they route to a pane, they do not assert. + 'auto.components.settings.plugins.search.title', + 'auto.components.settings.plugins.search.description', + 'auto.components.settings.plugins.search.install', + 'auto.components.settings.plugins.search.permissions', + 'auto.components.settings.plugins.search.logs', + 'auto.components.settings.plugins.search.development' +]) + +/** True when a protected-prefix path is plugin chrome a language pack may translate. */ +export function translatablePluginChrome(path: string): boolean { + return TRANSLATABLE_PLUGIN_CHROME.has(path) +} + +/** + * True when a protected-prefix container holds exempt chrome somewhere below it. + * The walk rejects a protected path as soon as it sees it, so a container has to + * stay walkable for its exempt leaves to be reachable — every leaf inside is + * still checked against its own full path. + */ +export function translatablePluginChromeContainer(path: string): boolean { + const prefix = `${path}.` + for (const exempt of TRANSLATABLE_PLUGIN_CHROME) { + if (exempt.startsWith(prefix)) { + return true + } + } + return false +} + +/** The exempt paths, for tests that check the list against the English catalog. */ +export function translatablePluginChromePaths(): string[] { + return [...TRANSLATABLE_PLUGIN_CHROME] +} diff --git a/src/shared/pr-checks-fix-prompt.ts b/src/shared/pr-checks-fix-prompt.ts index e31a9fbbf80..ff50a410942 100644 --- a/src/shared/pr-checks-fix-prompt.ts +++ b/src/shared/pr-checks-fix-prompt.ts @@ -82,6 +82,11 @@ export function getCheckDetailsPromptKey(check: PRCheckDetail, index: number): s if (check.workflowRunId) { return `workflow-run:${check.workflowRunId}:${check.name}` } + // Keep in step with getCheckIdentityKey / getCheckRunTabIdentity: a GitLab job + // without a web_url would otherwise key on its index and miss its loaded log. + if (check.gitlabJobId) { + return `gitlab-job:${check.gitlabJobId}:${check.name}` + } if (check.url) { return `url:${check.url}:${check.name}` } diff --git a/src/shared/pty-consumer-owner-recovery.ts b/src/shared/pty-consumer-owner-recovery.ts new file mode 100644 index 00000000000..dfac94d7e9c --- /dev/null +++ b/src/shared/pty-consumer-owner-recovery.ts @@ -0,0 +1,63 @@ +import { + PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, + PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR, + PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR, + type PtyConsumerAuthentication, + type PtyConsumerSessionHello +} from './pty-consumer-session-contract' + +type IncumbentOwner = { + principal: string + clientInstanceId: string + generation: number + lease: string + state: 'pending' | 'active' | 'disconnected' + replaces?: { generation: number } +} + +function throwRecoveryError(message: string, code: number): never { + throw Object.assign(new Error(message), { code }) +} + +export function assertPtyConsumerOwnerRecovery( + hello: PtyConsumerSessionHello, + authentication: PtyConsumerAuthentication, + current: IncumbentOwner +): void { + const resume = hello.resume + if (!resume) { + throw new Error('Owner recovery proof is required') + } + if ( + resume.ownerLease !== current.lease || + hello.clientInstanceId !== current.clientInstanceId || + authentication.principal !== current.principal + ) { + throwRecoveryError( + 'Owner recovery lease is stale or belongs to another principal', + PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR + ) + } + if (current.state === 'active' && resume.ownerGeneration < current.generation) { + throwRecoveryError( + 'Owner recovery generation was superseded', + PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR + ) + } + const generationMatches = + resume.ownerGeneration === current.generation || + (current.state === 'pending' && resume.ownerGeneration === current.replaces?.generation) || + (current.state === 'disconnected' && resume.ownerGeneration < current.generation) + if (!generationMatches) { + throwRecoveryError( + 'Owner recovery generation is stale', + PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR + ) + } + if (current.state === 'pending') { + throwRecoveryError( + 'Owner grant publication is still pending', + PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR + ) + } +} diff --git a/src/shared/pty-consumer-session-contract.ts b/src/shared/pty-consumer-session-contract.ts index 661138896cd..1e359a11f53 100644 --- a/src/shared/pty-consumer-session-contract.ts +++ b/src/shared/pty-consumer-session-contract.ts @@ -1,6 +1,10 @@ export const PTY_CONSUMER_SESSION_PROTOCOL_VERSION = 1 export const PTY_CONSUMER_OWNER_GRACE_MS = 30_000 export const PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR = -32041 +// Why: recovery is blocked only while the incumbent owner's grant publication is still settling — a +// window bounded by one response write, so the client may retry within a short budget. +export const PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR = -32042 +export const PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR = -32043 export type PtyConsumerRole = 'session-owner' | 'subscriber' @@ -41,8 +45,16 @@ export type PtyConsumerAuthentication = { allowSessionOwner: boolean } +export type PtyConsumerDisplacedOwner = { + connectionId: string + grant: Readonly +} + export type PtyConsumerSessionAdmission = { grant: Readonly + // Why: set when this admission takes over a still-attached owner. The transport layer owns closing + // that connection and releasing its deliveries — do it only once the new grant has been published. + displacedOwner?: Readonly commitPublication: () => void rollbackPublication: () => void } diff --git a/src/shared/pty-consumer-session-hello.ts b/src/shared/pty-consumer-session-hello.ts new file mode 100644 index 00000000000..c8119da88ee --- /dev/null +++ b/src/shared/pty-consumer-session-hello.ts @@ -0,0 +1,52 @@ +import type { PtyConsumerSessionHello } from './pty-consumer-session-contract' + +export const MAX_CAPABILITY_VERSIONS = 8 + +export function assertNonEmptyString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string' || value.length === 0 || value.length > 512) { + throw new Error(`${name} must be a non-empty string of at most 512 characters`) + } +} + +export function validateHello(hello: PtyConsumerSessionHello): void { + assertNonEmptyString(hello.clientInstanceId, 'clientInstanceId') + if (hello.requestedRole !== 'session-owner' && hello.requestedRole !== 'subscriber') { + throw new Error('requestedRole must be session-owner or subscriber') + } + if (hello.resume) { + if (!Number.isSafeInteger(hello.resume.ownerGeneration) || hello.resume.ownerGeneration <= 0) { + throw new Error('resume.ownerGeneration must be a positive safe integer') + } + assertNonEmptyString(hello.resume.ownerLease, 'resume.ownerLease') + } + const flow = hello.capabilities?.outputFlowControl + if (!flow) { + return + } + if ( + !Array.isArray(flow.versions) || + flow.versions.length > MAX_CAPABILITY_VERSIONS || + flow.versions.some((version) => !Number.isSafeInteger(version) || version <= 0) + ) { + throw new Error('outputFlowControl.versions must contain positive safe integers') + } + if (!Number.isSafeInteger(flow.requestedWindowSu) || flow.requestedWindowSu <= 0) { + throw new Error('outputFlowControl.requestedWindowSu must be a positive safe integer') + } +} + +// Why: a duplicate open on one connection is only idempotent if it asks for exactly the same thing. +export function helloFingerprint(hello: PtyConsumerSessionHello): string { + const flow = hello.capabilities?.outputFlowControl + return JSON.stringify({ + clientInstanceId: hello.clientInstanceId, + requestedRole: hello.requestedRole, + resume: hello.resume, + outputFlowControl: flow + ? { + versions: [...flow.versions].sort((a, b) => a - b), + requestedWindowSu: flow.requestedWindowSu + } + : undefined + }) +} diff --git a/src/shared/pty-consumer-session.test.ts b/src/shared/pty-consumer-session.test.ts index 07f9f7f9bf9..ce6282e8664 100644 --- a/src/shared/pty-consumer-session.test.ts +++ b/src/shared/pty-consumer-session.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, + PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR, PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR, PtyConsumerSession, type PtyConsumerAuthentication, @@ -121,7 +123,7 @@ describe('PtyConsumerSession', () => { ).toThrow('authentication required') }) - it('rotates the lease and increments owner generation on valid recovery', () => { + it('keeps the lease stable and increments owner generation on valid recovery', () => { const session = createSession() const first = session.admit(ownerHello(), auth('connection-1')) first.commitPublication() @@ -141,15 +143,97 @@ describe('PtyConsumerSession', () => { expect(recovered.grant).toMatchObject({ role: 'session-owner', ownerGeneration: 2, - ownerLease: 'lease-2' + ownerLease: 'lease-1' }) }) - it('rejects recovery while the current owner is still active', () => { + it('displaces a still-attached owner that a matching resume proof reclaims', () => { const session = createSession() const first = session.admit(ownerHello(), auth('connection-1')) first.commitPublication() + const recovered = session.admit( + ownerHello({ + resume: { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + }), + auth('connection-2') + ) + + expect(recovered.displacedOwner).toEqual({ + connectionId: 'connection-1', + grant: first.grant + }) + // Why: the incumbent keeps authority until the replacement grant is actually published. + expect(session.activeGrant('connection-1')).toBe(first.grant) + + recovered.commitPublication() + expect(recovered.grant).toMatchObject({ role: 'session-owner', ownerGeneration: 2 }) + expect(session.activeGrant('connection-1')).toBeNull() + expect(session.activeGrant('connection-2')).toBe(recovered.grant) + }) + + it('restores the displaced owner when the replacement publication rolls back', () => { + const session = createSession() + const first = session.admit(ownerHello(), auth('connection-1')) + first.commitPublication() + + const recovered = session.admit( + ownerHello({ + resume: { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + }), + auth('connection-2') + ) + recovered.rollbackPublication() + + expect(session.activeGrant('connection-1')).toBe(first.grant) + expect(session.activeGrant('connection-2')).toBeNull() + // Why: the restored incumbent must still hold the lease it was admitted with. + const reclaimed = session.admit( + ownerHello({ + resume: { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + }), + auth('connection-3') + ) + expect(reclaimed.displacedOwner?.connectionId).toBe('connection-1') + }) + + it('expires a displaced owner restored after its connection already closed', () => { + let now = 10 + const session = createSession({ now: () => now }) + const first = session.admit(ownerHello(), auth('connection-1')) + first.commitPublication() + + const recovered = session.admit( + ownerHello({ + resume: { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + }), + auth('connection-2') + ) + session.close('connection-1') + recovered.rollbackPublication() + + now += 30_000 + session.sweepExpired() + const fresh = session.admit(ownerHello(), auth('connection-4')) + expect(fresh.grant.role).toBe('session-owner') + }) + + it('refuses recovery while the incumbent grant publication is still settling', () => { + const session = createSession() + const first = session.admit(ownerHello(), auth('connection-1')) + expect(() => session.admit( ownerHello({ @@ -160,7 +244,99 @@ describe('PtyConsumerSession', () => { }), auth('connection-2') ) - ).toThrow('Active owner') + ).toThrow( + expect.objectContaining({ + code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR, + message: expect.stringContaining('still pending') + }) + ) + }) + + it('fences an old recovery generation after its replacement commits', () => { + const session = createSession() + const first = session.admit(ownerHello(), auth('connection-1')) + first.commitPublication() + const resume = { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + const replacement = session.admit(ownerHello({ resume }), auth('connection-2')) + + expect(() => session.admit(ownerHello({ resume }), auth('connection-3'))).toThrow( + expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) + ) + + replacement.commitPublication() + expect(() => session.admit(ownerHello({ resume }), auth('connection-3'))).toThrow( + expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_SUPERSEDED_ERROR }) + ) + + const retry = session.admit( + ownerHello({ + resume: { + ownerGeneration: replacement.grant.ownerGeneration!, + ownerLease: replacement.grant.ownerLease! + } + }), + auth('connection-3') + ) + + expect(retry.grant).toMatchObject({ + role: 'session-owner', + ownerGeneration: 3, + ownerLease: first.grant.ownerLease + }) + expect(retry.displacedOwner?.connectionId).toBe('connection-2') + // Why: the committed replacement already retired the incumbent, so only connection-2 is left to displace. + expect(session.activeGrant('connection-1')).toBeNull() + }) + + it('accepts an older stable proof after its replacement disconnects', () => { + const session = createSession() + const first = session.admit(ownerHello(), auth('connection-1')) + first.commitPublication() + const resume = { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + const replacement = session.admit(ownerHello({ resume }), auth('connection-2')) + replacement.commitPublication() + session.close('connection-2') + + const retry = session.admit(ownerHello({ resume }), auth('connection-3')) + + expect(retry.grant).toMatchObject({ + role: 'session-owner', + ownerGeneration: 3, + ownerLease: first.grant.ownerLease + }) + }) + + it('retries overlapping recovery against the incumbent after publication rolls back', () => { + const session = createSession() + const first = session.admit(ownerHello(), auth('connection-1')) + first.commitPublication() + const resume = { + ownerGeneration: first.grant.ownerGeneration!, + ownerLease: first.grant.ownerLease! + } + const replacement = session.admit(ownerHello({ resume }), auth('connection-2')) + + expect(() => session.admit(ownerHello({ resume }), auth('connection-3'))).toThrow( + expect.objectContaining({ code: PTY_CONSUMER_OWNER_RECOVERY_PENDING_ERROR }) + ) + + replacement.rollbackPublication() + const retry = session.admit(ownerHello({ resume }), auth('connection-3')) + + expect(retry.grant).toMatchObject({ + role: 'session-owner', + ownerGeneration: 3, + ownerLease: first.grant.ownerLease + }) + expect(retry.displacedOwner?.connectionId).toBe('connection-1') + // Why: the rolled-back replacement never published, so it holds no grant the retry could displace. + expect(session.activeGrant('connection-2')).toBeNull() }) it('types mismatched recovery without disturbing principal or lease ownership', () => { @@ -201,7 +377,7 @@ describe('PtyConsumerSession', () => { expect(recovered.grant).toMatchObject({ role: 'session-owner', ownerGeneration: 2, - ownerLease: 'lease-2' + ownerLease: 'lease-1' }) }) diff --git a/src/shared/pty-consumer-session.ts b/src/shared/pty-consumer-session.ts index 953b77c3a8d..3baf502acd8 100644 --- a/src/shared/pty-consumer-session.ts +++ b/src/shared/pty-consumer-session.ts @@ -4,22 +4,28 @@ import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION, PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR, type PtyConsumerAuthentication, + type PtyConsumerDisplacedOwner, type PtyConsumerSessionAdmission, type PtyConsumerSessionGrant, type PtyConsumerSessionHello, type PtyConsumerSessionOptions } from './pty-consumer-session-contract' +import { + assertNonEmptyString, + helloFingerprint, + MAX_CAPABILITY_VERSIONS, + validateHello +} from './pty-consumer-session-hello' +import { assertPtyConsumerOwnerRecovery } from './pty-consumer-owner-recovery' export * from './pty-consumer-session-contract' -const MAX_CAPABILITY_VERSIONS = 8 - type ClientRecord = { fingerprint: string principal: string clientInstanceId: string grant: Readonly - state: 'pending' | 'active' + state: 'pending' | 'active' | 'displaced' publicationState: 'pending' | 'committed' | 'rolled-back' } @@ -34,54 +40,6 @@ type OwnerRecord = { replaces?: OwnerRecord } -function assertNonEmptyString(value: unknown, name: string): asserts value is string { - if (typeof value !== 'string' || value.length === 0 || value.length > 512) { - throw new Error(`${name} must be a non-empty string of at most 512 characters`) - } -} - -function validateHello(hello: PtyConsumerSessionHello): void { - assertNonEmptyString(hello.clientInstanceId, 'clientInstanceId') - if (hello.requestedRole !== 'session-owner' && hello.requestedRole !== 'subscriber') { - throw new Error('requestedRole must be session-owner or subscriber') - } - if (hello.resume) { - if (!Number.isSafeInteger(hello.resume.ownerGeneration) || hello.resume.ownerGeneration <= 0) { - throw new Error('resume.ownerGeneration must be a positive safe integer') - } - assertNonEmptyString(hello.resume.ownerLease, 'resume.ownerLease') - } - const flow = hello.capabilities?.outputFlowControl - if (!flow) { - return - } - if ( - !Array.isArray(flow.versions) || - flow.versions.length > MAX_CAPABILITY_VERSIONS || - flow.versions.some((version) => !Number.isSafeInteger(version) || version <= 0) - ) { - throw new Error('outputFlowControl.versions must contain positive safe integers') - } - if (!Number.isSafeInteger(flow.requestedWindowSu) || flow.requestedWindowSu <= 0) { - throw new Error('outputFlowControl.requestedWindowSu must be a positive safe integer') - } -} - -function helloFingerprint(hello: PtyConsumerSessionHello): string { - const flow = hello.capabilities?.outputFlowControl - return JSON.stringify({ - clientInstanceId: hello.clientInstanceId, - requestedRole: hello.requestedRole, - resume: hello.resume, - outputFlowControl: flow - ? { - versions: [...flow.versions].sort((a, b) => a - b), - requestedWindowSu: flow.requestedWindowSu - } - : undefined - }) -} - export class PtyConsumerSession { private readonly clients = new Map() private readonly now: () => number @@ -160,7 +118,7 @@ export class PtyConsumerSession { if (owner) { this.owner = owner } - return this.admissionFor(client) + return this.admissionFor(client, this.displacedOwnerFor(owner)) } close(connectionId: string): void { @@ -170,6 +128,17 @@ export class PtyConsumerSession { } this.clients.delete(connectionId) if (this.owner?.connectionId !== connectionId) { + // Why: a pending replacement can still roll back onto the owner it is displacing; restoring an + // 'active' record whose connection has since closed would wedge an owner that can never expire. + if ( + this.owner?.replaces?.connectionId === connectionId && + this.owner.replaces.state === 'active' + ) { + this.owner = { + ...this.owner, + replaces: { ...this.owner.replaces, state: 'disconnected', disconnectedAt: this.now() } + } + } return } if (this.owner.state === 'pending') { @@ -192,9 +161,13 @@ export class PtyConsumerSession { return client?.state === 'active' ? client.grant : null } - private admissionFor(client: ClientRecord): PtyConsumerSessionAdmission { + private admissionFor( + client: ClientRecord, + displacedOwner?: Readonly + ): PtyConsumerSessionAdmission { return { grant: client.grant, + ...(displacedOwner ? { displacedOwner } : {}), commitPublication: () => { if (client.publicationState !== 'pending') { return @@ -206,6 +179,7 @@ export class PtyConsumerSession { client.state = 'active' const owner = this.owner if (owner?.connectionId === this.connectionIdFor(client) && owner.state === 'pending') { + this.retireDisplacedOwner(owner.replaces) this.owner = { ...owner, state: 'active', replaces: undefined } } }, @@ -254,32 +228,46 @@ export class PtyConsumerSession { if (!hello.resume) { return null } - const recoveryMatches = - hello.resume.ownerGeneration === current.generation && - hello.resume.ownerLease === current.lease && - hello.clientInstanceId === current.clientInstanceId && - authentication.principal === current.principal - if (!recoveryMatches) { - throw Object.assign( - new Error('Owner recovery lease is stale or belongs to another principal'), - { code: PTY_CONSUMER_STALE_OWNER_RECOVERY_ERROR } - ) - } - if (current.state === 'pending') { - throw new Error('Owner grant publication is still pending') - } - if (current.state !== 'disconnected') { - throw new Error('Active owner cannot be replaced by recovery') - } + assertPtyConsumerOwnerRecovery(hello, authentication, current) + // Why an active owner is displaced rather than refused: the resume proof matched this owner's + // generation, lease, client instance, and principal on a *different* transport, so the requester is + // the same logical owner reconnecting. Waiting for the incumbent's socket to close is unbounded — + // a half-open connection after sleep/resume or NAT loss never gets there. return this.newOwner(hello, authentication, current) } + private displacedOwnerFor( + owner: OwnerRecord | null + ): Readonly | undefined { + const replaced = owner?.replaces + if (replaced?.state !== 'active') { + return undefined + } + const client = this.clients.get(replaced.connectionId) + if (client?.state !== 'active') { + return undefined + } + return Object.freeze({ connectionId: replaced.connectionId, grant: client.grant }) + } + + // Why: the displaced connection may still be writable (half-open), so revoke its grant the moment the + // replacement is published — a stale owner must not keep driving deliveries under the old generation. + private retireDisplacedOwner(replaced: OwnerRecord | undefined): void { + if (replaced?.state !== 'active') { + return + } + const client = this.clients.get(replaced.connectionId) + if (client?.state === 'active') { + client.state = 'displaced' + } + } + private newOwner( hello: PtyConsumerSessionHello, authentication: PtyConsumerAuthentication, replaces: OwnerRecord | null ): OwnerRecord { - const lease = this.createLease() + const lease = replaces?.lease ?? this.createLease() assertNonEmptyString(lease, 'ownerLease') return { connectionId: authentication.connectionId, diff --git a/src/shared/repro-7732-gitlab-job-id-dropped.test.ts b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts new file mode 100644 index 00000000000..4f1130f26f2 --- /dev/null +++ b/src/shared/repro-7732-gitlab-job-id-dropped.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { gitLabPipelineJobsToPRChecks } from './gitlab-pipeline-checks' +import type { GitLabPipelineJob } from './types' + +// Repro for #7732: the Checks side panel can only ask for a GitLab job trace if the mapped +// check row still carries the numeric GitLab job id (gitlab:jobTrace takes { jobId }). +function numericHandles(value: object): number[] { + return Object.values(value).filter((v): v is number => typeof v === 'number') +} + +describe('#7732 GitLab pipeline job -> check row mapping', () => { + const failedJob: GitLabPipelineJob = { + id: 42, + name: 'Purchase API Component Tests', + stage: 'Component Tests', + status: 'failed', + webUrl: 'https://gitlab.com/acme/orca/-/jobs/42', + duration: 31 + } + + it('keeps the GitLab job id so the panel can fetch the job trace', () => { + const [check] = gitLabPipelineJobsToPRChecks([failedJob]) + + expect(check.name).toBe('Component Tests: Purchase API Component Tests') + expect(check.conclusion).toBe('failure') + // The id is the only handle gitlab:jobTrace accepts; without it the expand path has nothing to send. + expect(numericHandles(check)).toContain(42) + }) +}) diff --git a/src/shared/runtime-pairing-reach.ts b/src/shared/runtime-pairing-reach.ts new file mode 100644 index 00000000000..0f7f0315416 --- /dev/null +++ b/src/shared/runtime-pairing-reach.ts @@ -0,0 +1,5 @@ +// Why: STA-2370 — widening the runtime listener to every interface is one-way for the life of the process +// and persists across launches, so it must follow the reach the user picked, not the shape of the address +// they typed: a Custom `127.0.0.1:8443` is usually an SSH tunnel or reverse proxy that still needs off-host +// reach, while "This computer only" must never publish the runtime beyond loopback. +export type RuntimePairingReach = 'this-computer' | 'network' diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index d4d0efc1ad8..6e012e60b42 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -409,8 +409,8 @@ export type RuntimeTerminalPathOpenTarget = } /** 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). */ + * selected or sibling workspace root (+ optional cwd). relativePath is null + * when no workspace on the same execution host owns the path. */ export type RuntimeTerminalPathResolution = { worktree: string relativePath: string | null diff --git a/src/shared/runtime-workspace-file-owner.test.ts b/src/shared/runtime-workspace-file-owner.test.ts new file mode 100644 index 00000000000..c6d003b8ea6 --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { findRuntimeWorkspaceFileOwner } from './runtime-workspace-file-owner' + +describe('findRuntimeWorkspaceFileOwner', () => { + const roots = [ + { workspaceId: 'repo-a', rootPath: '/srv/repo-a', executionHostId: 'runtime:host-a' as const }, + { workspaceId: 'repo-b', rootPath: '/srv/repo-b', executionHostId: 'runtime:host-a' as const }, + { + workspaceId: 'repo-b-docs', + rootPath: '/srv/repo-b/docs', + executionHostId: 'runtime:host-a' as const + }, + { + workspaceId: 'other-host', + rootPath: '/srv/repo-b', + executionHostId: 'runtime:host-b' as const + } + ] + + it('finds a sibling workspace on the same execution host', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b', + relativePath: 'src/index.ts' + }) + }) + + it('uses the most specific nested workspace root', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs/guide.md', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: 'guide.md' + }) + }) + + it('uses an exact nested workspace root instead of its parent', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/docs', 'runtime:host-a') + ).toMatchObject({ + workspaceId: 'repo-b-docs', + relativePath: '' + }) + }) + + it('does not cross execution-host boundaries', () => { + expect( + findRuntimeWorkspaceFileOwner(roots, '/srv/repo-b/src/index.ts', 'runtime:host-b') + ).toMatchObject({ + workspaceId: 'other-host' + }) + expect(findRuntimeWorkspaceFileOwner(roots, '/srv/repo-a/file.ts', 'runtime:host-b')).toBeNull() + }) + + it('matches Windows roots without case-sensitive drive assumptions', () => { + expect( + findRuntimeWorkspaceFileOwner( + [ + { + workspaceId: 'windows-repo', + rootPath: 'C:\\Work\\Repo', + executionHostId: 'runtime:windows' + } + ], + 'c:\\work\\repo\\src\\index.ts', + 'runtime:windows' + ) + ).toMatchObject({ + workspaceId: 'windows-repo', + relativePath: 'src/index.ts' + }) + }) +}) diff --git a/src/shared/runtime-workspace-file-owner.ts b/src/shared/runtime-workspace-file-owner.ts new file mode 100644 index 00000000000..1edacb07bbc --- /dev/null +++ b/src/shared/runtime-workspace-file-owner.ts @@ -0,0 +1,43 @@ +import type { ExecutionHostId } from './execution-host' +import { normalizeRuntimePathForComparison, relativePathInsideRoot } from './cross-platform-path' + +export type RuntimeWorkspaceFileRoot = { + workspaceId: string + rootPath: string + executionHostId: ExecutionHostId +} + +export type RuntimeWorkspaceFileOwner = RuntimeWorkspaceFileRoot & { + relativePath: string +} + +export function findRuntimeWorkspaceFileOwner( + roots: readonly RuntimeWorkspaceFileRoot[], + absolutePath: string, + executionHostId: ExecutionHostId +): RuntimeWorkspaceFileOwner | null { + let best: RuntimeWorkspaceFileOwner | null = null + let bestRootLength = -1 + + for (const root of roots) { + if (root.executionHostId !== executionHostId) { + continue + } + const relativePath = relativePathInsideRoot(root.rootPath, absolutePath) + if (relativePath === null) { + continue + } + const rootLength = normalizeRuntimePathForComparison(root.rootPath).length + if ( + rootLength > bestRootLength || + (rootLength === bestRootLength && + best !== null && + root.workspaceId.localeCompare(best.workspaceId) < 0) + ) { + best = { ...root, relativePath } + bestRootLength = rootLength + } + } + + return best +} diff --git a/src/shared/setup-agent-sequencing.test.ts b/src/shared/setup-agent-sequencing.test.ts index bffeb0de351..61dc001e627 100644 --- a/src/shared/setup-agent-sequencing.test.ts +++ b/src/shared/setup-agent-sequencing.test.ts @@ -218,6 +218,40 @@ describe('createSequencedSetupAgentCommands', () => { }) }) + it('launches a batch runner through the cmd launcher inside a Git Bash gate', () => { + // Regression (#6896): a Git Bash terminal with a batch setup script still gets a .cmd + // runner, and the gate must not hand that runner to bash. The gate itself stays POSIX + // because the Git Bash pane types it and quoted the startup command for bash. + const result = createSequencedSetupAgentCommands({ + runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + startupCommand: "claude 'fix the user'\\''s login'", + platform: 'windows', + shell: { family: 'posix' }, + nonce: 'nonce-gitbash-cmd' + }) + + expect(result.setupCommand).toContain( + 'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand' + ) + expect(result.setupCommand).not.toMatch(/bash\s+\S*setup-runner/) + expect(decodePowerShellScript(result.setupCommand)).toContain( + "$runner = 'C:\\repo\\.git\\orca\\setup-runner.cmd'" + ) + // Why: PowerShell's `Invoke-Expression` cannot parse the POSIX `'\''` escaping a Git Bash + // pane produces, so the gate that evaluates the startup command must be bash. + expect(result.setupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).toMatch(/^bash -lc /) + expect(result.startupCommand).not.toContain('Invoke-Expression') + expect(result.startupCommand).toContain('eval "$ORCA_SEQUENCED_STARTUP_COMMAND"') + // Why: bash writes and reads the marker here, so it needs the /c/... form of the path. + expect(result.setupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + expect(result.startupCommand).toContain( + '/c/repo/.git/orca/setup-runner.cmd.nonce-gitbash-cmd.done' + ) + }) + it.skipIf(process.platform !== 'win32')( 'executes the native Windows setup-to-agent sequence through cmd.exe', async () => { diff --git a/src/shared/setup-agent-sequencing.ts b/src/shared/setup-agent-sequencing.ts index 92376f10886..1ec888e3aa6 100644 --- a/src/shared/setup-agent-sequencing.ts +++ b/src/shared/setup-agent-sequencing.ts @@ -1,5 +1,6 @@ import { encodePowerShellCommand } from './powershell-command-encoding' import { + nativeWindowsPathToPosixShellPath, resolveSetupRunnerCommand, type SetupRunnerCommandPlatform, type SetupRunnerCommandShell, @@ -41,12 +42,20 @@ export function createSequencedSetupAgentCommands(args: { }): SequencedSetupAgentCommands { const nonce = args.nonce ?? createSetupAgentSequenceNonce() const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell) + // Why: the gate is typed into the terminal pane and `startupCommand` is already quoted for that + // pane, so a batch runner launched from a Git Bash pane still needs the bash gate — PowerShell's + // `Invoke-Expression` cannot parse the POSIX `'\''` escaping the pane's quoting produces. The + // runner itself still launches through `resolution.command`, never through bash. + const posixGateForWindowsRunner = resolution.shell === 'windows' && args.shell?.family === 'posix' + const markerBasePath = posixGateForWindowsRunner + ? nativeWindowsPathToPosixShellPath(resolution.runnerScriptPathForShell) + : resolution.runnerScriptPathForShell // Why: overlapping gated launches of the same setup runner must not race on // a shared completion marker. - const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done` + const markerPath = `${markerBasePath}.${nonce}.done` const waitTimeoutSeconds = args.waitTimeoutSeconds ?? DEFAULT_WAIT_TIMEOUT_SECONDS - if (resolution.shell === 'windows') { + if (resolution.shell === 'windows' && !posixGateForWindowsRunner) { return { setupCommand: buildWindowsSetupCommand( resolution.runnerScriptPathForShell, diff --git a/src/shared/setup-runner-command.test.ts b/src/shared/setup-runner-command.test.ts index 99ba2e029c4..069130b1313 100644 --- a/src/shared/setup-runner-command.test.ts +++ b/src/shared/setup-runner-command.test.ts @@ -61,6 +61,51 @@ describe('buildSetupRunnerCommand', () => { 'bash /c/repo/.git/orca/setup-runner.sh' ) }) + + it('never hands a batch runner to bash, even from a Git Bash pane', () => { + // Regression: a Git Bash terminal with a batch-syntax setup script gets a .cmd runner, + // so the launch shell being POSIX must not be read as "the runner is a shell script". + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('bash ') + expect(command).not.toContain('/c/repo') + }) + + it('avoids the bare /c switch when a POSIX pane launches a batch runner', () => { + // Regression (#6896): MSYS rewrites `cmd.exe /c` into a drive path inside Git Bash, so cmd + // opens interactively and the runner payload never executes. + const command = buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + + expect(command).not.toContain('cmd.exe /c') + expect(command).toMatch( + /^powershell\.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand [A-Za-z0-9+/=]+$/ + ) + }) + + it('keeps the batch runner path in native form for a POSIX pane launch', () => { + // Why: the PowerShell launcher hands the path to cmd, which cannot read /c/... MSYS paths; + // marker and completion paths derive from this value too. + expect( + resolveSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', { + family: 'posix' + }) + ).toMatchObject({ + runnerScriptPathForShell: 'C:\\repo\\.git\\orca\\setup-runner.cmd', + shell: 'windows' + }) + }) + + it('still uses bash for a POSIX runner launched from a POSIX pane', () => { + expect( + buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', { + family: 'posix' + }) + ).toBe('bash /c/repo/.git/orca/setup-runner.sh') + }) }) describe('buildSetupRunnerCommand cmd metacharacter guard', () => { diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index fe9cebed50b..7b1dee560fd 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -60,7 +60,10 @@ export function resolveSetupRunnerCommand( shell: 'posix' } } - if (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath)) { + // Why: `shell` is the shell that types the command; the runner file's own extension decides + // what can execute it. A batch runner never goes to bash even from a Git Bash pane. + const cmdRunnerFile = isWindowsCmdRunnerPath(runnerScriptPath) + if (!cmdRunnerFile && (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath))) { // Why: WSL shells need /mnt/... paths, while Git Bash expects /c/... when replaying deferred setup scripts. if (isWslExecutable(shell?.executable)) { const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath) @@ -79,11 +82,15 @@ export function resolveSetupRunnerCommand( } } return { - // Why: some path characters survive no amount of quoting on a cmd command line, so those - // paths take a delayed-expansion launcher instead. Every other path keeps the plain form. - command: windowsRunnerPathNeedsCmdGuard(runnerScriptPath) - ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) - : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, + // Why: some path characters survive no amount of quoting on a cmd command line, and a Git + // Bash pane rewrites the bare `/c` switch itself into a drive path (issue #6896) so cmd + // opens interactively and the runner never starts. Both take the delayed-expansion + // launcher, which passes the switch through a PowerShell ProcessStartInfo instead. Every + // other case keeps the plain form. + command: + shell?.family === 'posix' || windowsRunnerPathNeedsCmdGuard(runnerScriptPath) + ? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath) + : `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`, runnerScriptPathForShell: runnerScriptPath, shell: 'windows' } @@ -96,6 +103,11 @@ export function resolveSetupRunnerCommand( } } +/** True when the runner file is a batch script, which only cmd can execute. */ +export function isWindowsCmdRunnerPath(runnerScriptPath: string): boolean { + return /\.(cmd|bat)$/i.test(runnerScriptPath) +} + export function isWslUncPath(path: string): boolean { const normalized = path.replace(/\\/g, '/') return /^\/\/(wsl\.localhost|wsl\$)\//i.test(normalized) diff --git a/src/shared/setup-script-shebang.test.ts b/src/shared/setup-script-shebang.test.ts new file mode 100644 index 00000000000..05dca1204da --- /dev/null +++ b/src/shared/setup-script-shebang.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest' +import { + isShebangLine, + parseSetupScriptShebang, + scriptDeclaresPosixShell, + stripLeadingShebangLine +} from './setup-script-shebang' + +describe('scriptDeclaresPosixShell', () => { + it('accepts the common env and absolute-path forms', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/bin/sh -e\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!/usr/bin/env -S bash -euo pipefail\npnpm install')).toBe( + true + ) + expect(scriptDeclaresPosixShell('#!/bin/zsh')).toBe(true) + }) + + it('rejects scripts with no interpreter line', () => { + // Regression: batch-syntax setup scripts must stay on the cmd runner. + expect(scriptDeclaresPosixShell('copy .env.example .env\nxcopy /E assets dist')).toBe(false) + expect(scriptDeclaresPosixShell('')).toBe(false) + expect(scriptDeclaresPosixShell('pnpm install\n#!/usr/bin/env bash')).toBe(false) + expect(scriptDeclaresPosixShell('# !/usr/bin/env bash\npnpm install')).toBe(false) + }) + + it('rejects interpreters that are not POSIX shells', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env node\nconsole.log(1)')).toBe(false) + expect(scriptDeclaresPosixShell('#!/usr/bin/env python3\nprint(1)')).toBe(false) + }) + + it('tolerates CRLF and Windows-style interpreter paths', () => { + expect(scriptDeclaresPosixShell('#!/usr/bin/env bash\r\npnpm install')).toBe(true) + expect(scriptDeclaresPosixShell('#!C:\\tools\\git\\bin\\bash.exe\r\npnpm install')).toBe(true) + }) +}) + +describe('parseSetupScriptShebang', () => { + it('keeps the interpreter flags a script declares', () => { + // Regression: the runner is launched as `bash `, so flags survive only if they are + // parsed out here and replayed with `set` — otherwise `pipefail` is silently lost. + expect(parseSetupScriptShebang('#!/usr/bin/env -S bash -euo pipefail\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-euo', 'pipefail'] + }) + expect(parseSetupScriptShebang('#!/bin/bash -e -x\nmake')).toEqual({ + interpreter: 'bash', + shellOptions: ['-e', '-x'] + }) + expect(parseSetupScriptShebang('#!/bin/sh\nmake')).toEqual({ + interpreter: 'sh', + shellOptions: [] + }) + }) + + it('ignores interpreter arguments that `set` cannot apply', () => { + // Regression: `set` rejects invocation-only flags with exit 2, and the runner's `set -e` + // turns that into an aborted setup before its first line runs. + expect(parseSetupScriptShebang('#!/bin/bash --norc\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -l\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -s\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -i\nmake')?.shellOptions).toEqual([]) + // Why: `-r` is accepted by `set` on some shells but silently restricts the rest of setup. + expect(parseSetupScriptShebang('#!/bin/bash -r\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -ex -l\nmake')?.shellOptions).toEqual(['-ex']) + // Why: a bare `-o` would print the whole shell-option table into the setup terminal. + expect(parseSetupScriptShebang('#!/bin/bash -o\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash -euo\nmake')?.shellOptions).toEqual([]) + expect(parseSetupScriptShebang('#!/bin/bash +o posix\nmake')?.shellOptions).toEqual([ + '+o', + 'posix' + ]) + }) + + it('returns null without an interpreter line', () => { + expect(parseSetupScriptShebang('pnpm install')).toBeNull() + expect(parseSetupScriptShebang('#!/usr/bin/env')).toBeNull() + }) +}) + +describe('stripLeadingShebangLine', () => { + it('removes only a leading interpreter line', () => { + expect(stripLeadingShebangLine('#!/usr/bin/env bash\npnpm install\n')).toBe('pnpm install\n') + expect(stripLeadingShebangLine('#!/usr/bin/env bash\r\npnpm install')).toBe('pnpm install') + expect(stripLeadingShebangLine('pnpm install\n#!/usr/bin/env bash')).toBe( + 'pnpm install\n#!/usr/bin/env bash' + ) + expect(stripLeadingShebangLine('#!/usr/bin/env bash')).toBe('') + }) +}) + +describe('isShebangLine', () => { + it('detects interpreter lines regardless of leading whitespace', () => { + expect(isShebangLine('#!/usr/bin/env bash')).toBe(true) + expect(isShebangLine(' #!/bin/sh')).toBe(true) + expect(isShebangLine('#comment')).toBe(false) + expect(isShebangLine('pnpm install')).toBe(false) + }) +}) diff --git a/src/shared/setup-script-shebang.ts b/src/shared/setup-script-shebang.ts new file mode 100644 index 00000000000..f30bbab0959 --- /dev/null +++ b/src/shared/setup-script-shebang.ts @@ -0,0 +1,100 @@ +// Why: the interpreter a setup/issue-command script is written for is a property of the script, +// not of the user's terminal preference, so a `#!` line is how a project declares it. + +const POSIX_SHELL_BASENAMES = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'ash']) +// Why: only the letters `set` itself accepts (`set [--abefhkmnptuvxBCHP] [-o option]`). An +// invocation-only flag such as `-l` or `-r` makes `set` exit 2, which under the runner's `set -e` +// aborts setup before its first line runs. +const SET_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]+$/ +// Why: `-o`/`-euo` name their option in the next token; a trailing `-o` with no name would make +// `set` dump the whole shell-option table into the setup terminal instead. +const SET_LONG_OPTION_FLAG_PATTERN = /^[-+][abefhkmnptuvxBCHP]*o$/ +const SHELL_OPTION_NAME_PATTERN = /^[a-z_]+$/ + +export type SetupScriptShebang = { + /** Lowercased interpreter basename, e.g. `bash` for `#!/usr/bin/env -S bash -e`. */ + interpreter: string + /** Interpreter flags the generated runner replays through `set`, e.g. `['-euo', 'pipefail']`. */ + shellOptions: string[] +} + +/** True when `line` is a `#!` interpreter line (leading whitespace tolerated). */ +export function isShebangLine(line: string): boolean { + return line.trimStart().startsWith('#!') +} + +/** Parses the script's leading `#!` line, or null when it has none. */ +export function parseSetupScriptShebang(script: string): SetupScriptShebang | null { + const firstLine = script.split('\n', 1)[0] ?? '' + if (!isShebangLine(firstLine)) { + return null + } + + const tokens = firstLine.trim().slice(2).trim().split(/\s+/).filter(Boolean) + const interpreterIndex = findInterpreterIndex(tokens) + if (interpreterIndex === -1) { + return null + } + + return { + interpreter: executableBasename(tokens[interpreterIndex]), + shellOptions: parseShellOptions(tokens.slice(interpreterIndex + 1)) + } +} + +/** True when the script's first line is a `#!` line naming a POSIX shell. */ +export function scriptDeclaresPosixShell(script: string): boolean { + const shebang = parseSetupScriptShebang(script) + return shebang !== null && POSIX_SHELL_BASENAMES.has(shebang.interpreter) +} + +/** Drops a leading `#!` line; the generated runner carries its own interpreter line. */ +export function stripLeadingShebangLine(script: string): string { + if (!isShebangLine(script.split('\n', 1)[0] ?? '')) { + return script + } + const lineEnd = script.indexOf('\n') + return lineEnd === -1 ? '' : script.slice(lineEnd + 1) +} + +function findInterpreterIndex(tokens: string[]): number { + for (let index = 0; index < tokens.length; index++) { + const basename = executableBasename(tokens[index]) + // Why: `env` (and its `-S` split-string form) only forwards to the real interpreter. + if (basename === '' || basename === 'env' || basename.startsWith('-')) { + continue + } + return index + } + return -1 +} + +function parseShellOptions(tokens: string[]): string[] { + const options: string[] = [] + for (let index = 0; index < tokens.length; index++) { + const token = tokens[index] + if (SET_LONG_OPTION_FLAG_PATTERN.test(token)) { + const optionName = tokens[index + 1] + if (optionName && SHELL_OPTION_NAME_PATTERN.test(optionName)) { + options.push(token, optionName) + index++ + } + continue + } + if (SET_OPTION_FLAG_PATTERN.test(token)) { + options.push(token) + } + } + return options +} + +function executableBasename(token: string): string { + return ( + token + .replaceAll('\\', '/') + .split('/') + .pop() + ?.toLowerCase() + .replace(/\.exe$/, '') ?? '' + ) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index bc88d4d6d6c..1ba5aeb7919 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1408,6 +1408,9 @@ export type PRCheckDetail = { url: string | null checkRunId?: number workflowRunId?: number + // Why: the GitLab job trace API is addressed by numeric job id only, so the + // Checks panel cannot load a job log without carrying it on the row. + gitlabJobId?: number } export type PRCheckAnnotation = { @@ -3393,6 +3396,8 @@ export type PersistedUIState = { hideCliCreatedWorkspaces?: boolean /** Hide workspaces sitting on a detached HEAD; folder workspaces (no head at all) are unaffected. */ hideDetachedHeadWorkspaces?: boolean + /** Keep each project's main workspace out of the "Hide sleeping" sweep. Absent means on (#8873). */ + alwaysShowDefaultBranchWorkspace?: boolean /** Per-worktree Explorer dotfile visibility. Missing entries inherit the default: show. */ showDotfilesByWorktree?: Record filterRepoIds: string[] diff --git a/src/shared/workspace-session-schema.sleeping-agent.test.ts b/src/shared/workspace-session-schema.sleeping-agent.test.ts index 514dd8082e2..9f58d453969 100644 --- a/src/shared/workspace-session-schema.sleeping-agent.test.ts +++ b/src/shared/workspace-session-schema.sleeping-agent.test.ts @@ -329,6 +329,39 @@ describe('parseWorkspaceSession sleeping agents', () => { } }) + it('preserves the tab-open-only restore flag 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: 'claude', + providerSession: { key: 'session_id', id: 'claude-session' }, + prompt: 'continue', + state: 'done', + capturedAt: 10, + updatedAt: 10, + origin: 'worktree-sleep', + restoreOnTabOpenOnly: true + } + } + }) + + expect(result.ok).toBe(true) + if (result.ok) { + // Why: dropping it on restart resurrects the mobile-wake fan-out this flag exists to stop. + expect( + result.value.sleepingAgentSessionsByPaneKey?.['tab1:pane-1']?.restoreOnTabOpenOnly + ).toBe(true) + } + }) + it('preserves interrupted sleeping agent records across hydration', () => { const result = parseWorkspaceSession({ activeRepoId: null, diff --git a/src/shared/workspace-session-sleeping-agents.ts b/src/shared/workspace-session-sleeping-agents.ts index 58f19ce089e..eb48de31e4b 100644 --- a/src/shared/workspace-session-sleeping-agents.ts +++ b/src/shared/workspace-session-sleeping-agents.ts @@ -98,7 +98,8 @@ const sleepingAgentSessionRecordSchema = z connectionId: z.string().nullable().optional(), launchConfig: sleepingAgentLaunchConfigSchema.optional(), origin: z.enum(['worktree-sleep', 'quit', 'live']).optional(), - automaticResumeBlockedBy: z.enum(['legacy-orchestration-worker']).optional() + automaticResumeBlockedBy: z.enum(['legacy-orchestration-worker']).optional(), + restoreOnTabOpenOnly: z.boolean().optional() }) .refine( (record) => getAgentResumeArgv(record.agent, record.providerSession) !== null, diff --git a/src/shared/worktree-removal-force-classification.test.ts b/src/shared/worktree-removal-force-classification.test.ts index 1d995d412f4..b4d95a5727d 100644 --- a/src/shared/worktree-removal-force-classification.test.ts +++ b/src/shared/worktree-removal-force-classification.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it } from 'vitest' -import { classifyWorktreeForceDeleteReason, WORKTREE_TEARDOWN_FORCE_HINT } from './worktree-removal' +import { + classifyWorktreeForceDeleteReason, + isProvenLivePtyRemovalError, + UNSTOPPED_PTY_REMOVAL_PREFIX, + WORKTREE_TEARDOWN_FORCE_HINT, + WORKTREE_TEARDOWN_TIMEOUT_PREFIX +} from './worktree-removal' + +const liveError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` +const unverifiableError = `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` // Why (#11960): the desktop Force Delete button renders only when this classifier // returns a reason. The PTY-teardown error tells the user to force-delete, so an // unclassified message leaves them reading advice they cannot act on. describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { - const liveError = `Failed to physically stop every PTY for worktree: repo-1::/w — still live: term_a. ${WORKTREE_TEARDOWN_FORCE_HINT}` - const unverifiableError = `Failed to physically stop every PTY for worktree: repo-1::/w — could not verify these exited: term_a (daemon socket closed). ${WORKTREE_TEARDOWN_FORCE_HINT}` - it('offers force for a PTY that is still live', () => { expect(classifyWorktreeForceDeleteReason(liveError)).toBe('unstopped-pty') }) @@ -29,7 +35,35 @@ describe('classifyWorktreeForceDeleteReason for unstopped PTYs', () => { expect(classifyWorktreeForceDeleteReason(liveError, false, true)).toBeNull() }) + // Why: a wedged provider rejects the sweep before any per-PTY verdict exists, so the + // failure arrives worded as a teardown timeout. The waiver clears it exactly like an + // unproven stop, so leaving it unclassified hid the button for the wedge #11960 targeted. + it('offers force when the teardown sweep itself timed out', () => { + expect( + classifyWorktreeForceDeleteReason( + `${WORKTREE_TEARDOWN_TIMEOUT_PREFIX} repo-1::/w. ${WORKTREE_TEARDOWN_FORCE_HINT}` + ) + ).toBe('unstopped-pty') + }) + it('leaves unrelated failures unclassified', () => { expect(classifyWorktreeForceDeleteReason('some other failure')).toBeNull() }) }) + +// The live verdict escalates the delete toast to "Force Delete will kill them", so it must +// come from Orca's own detail — not from the worktree id, which is a user-chosen path. +describe('isProvenLivePtyRemovalError', () => { + it('reads the verdict Orca actually recorded', () => { + expect(isProvenLivePtyRemovalError(liveError)).toBe(true) + expect(isProvenLivePtyRemovalError(unverifiableError)).toBe(false) + }) + + it('does not let a worktree path spell out a live verdict', () => { + expect( + isProvenLivePtyRemovalError( + `${UNSTOPPED_PTY_REMOVAL_PREFIX} repo-1::/Users/dev/still live: notes — could not verify these exited: term_a (daemon socket closed)` + ) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree-removal.ts b/src/shared/worktree-removal.ts index 17e1b719cae..8788171ebe0 100644 --- a/src/shared/worktree-removal.ts +++ b/src/shared/worktree-removal.ts @@ -15,8 +15,34 @@ export type WorktreeForceDeleteReason = | 'missing-registration' | 'unstopped-pty' +// Why: everything before this separator is the worktree id — a user-chosen filesystem path. +// Only the detail after it is Orca's own wording, so verdict matchers anchor on the boundary +// rather than scanning the whole message and letting a path spell out a verdict. +export const UNSTOPPED_PTY_DETAIL_SEPARATOR = ' — ' + +// Why: verification distinguishes a PTY it watched stay alive from one it could not reach, +// and the delete toast must not flatten the two — a user waiving "we could not confirm" is +// making a different decision than one killing a terminal Orca just saw running. The marker +// and its matcher stay together for the same reason the force hint does. +export const UNSTOPPED_PTY_LIVE_DETAIL_PREFIX = 'still live:' + +// Why (#11960): a sweep that never answers wedges removal exactly like a stop that could not +// be proven, and the waiver clears both — but this error carries different words, so without +// its own matcher the force affordance stayed hidden for the very case it was added for. +export const WORKTREE_TEARDOWN_TIMEOUT_PREFIX = 'Timed out waiting for physical PTY teardown:' + export function isUnstoppedPtyRemovalError(error: string): boolean { - return error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) + return ( + error.includes(UNSTOPPED_PTY_REMOVAL_PREFIX) || error.includes(WORKTREE_TEARDOWN_TIMEOUT_PREFIX) + ) +} + +/** True only when verification positively observed the PTYs still running. */ +export function isProvenLivePtyRemovalError(error: string): boolean { + return ( + isUnstoppedPtyRemovalError(error) && + error.includes(`${UNSTOPPED_PTY_DETAIL_SEPARATOR}${UNSTOPPED_PTY_LIVE_DETAIL_PREFIX}`) + ) } export function createLockedWorktreeRemovalError(lockReason?: string): Error { diff --git a/tests/e2e/default-branch-visibility.spec.ts b/tests/e2e/default-branch-visibility.spec.ts new file mode 100644 index 00000000000..166dc419431 --- /dev/null +++ b/tests/e2e/default-branch-visibility.spec.ts @@ -0,0 +1,165 @@ +/** + * Regression #8873: with "Hide sleeping" on, the project's main workspace must + * stay in the sidebar — it is the only guaranteed way back into the project — + * while a sleeping feature workspace is still swept. + */ + +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' + +type SidebarVisibilityScenario = { + defaultBranchId: string + featureId: string +} + +async function seedSidebarVisibilityScenario(page: Page): Promise { + return page.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('Sidebar visibility E2E needs a seeded repo') + } + + const currentWorktree = (state.worktreesByRepo[repo.id] ?? [])[0] + if (!currentWorktree) { + throw new Error('Sidebar visibility E2E needs a seeded worktree') + } + + const defaultBranchId = 'e2e-default-branch-visibility-main' + const featureId = 'e2e-default-branch-visibility-feature' + const currentId = currentWorktree.id + + store.setState((current) => ({ + worktreesByRepo: { + ...current.worktreesByRepo, + [repo.id]: [ + { + ...currentWorktree, + id: currentId, + displayName: 'Current workspace', + isMainWorktree: false, + branch: 'refs/heads/current', + lastActivityAt: 3 + }, + { + ...currentWorktree, + id: defaultBranchId, + displayName: 'Default branch workspace', + isMainWorktree: true, + branch: 'refs/heads/main', + lastActivityAt: 2 + }, + { + ...currentWorktree, + id: featureId, + displayName: 'Feature workspace', + isMainWorktree: false, + branch: 'refs/heads/feature', + lastActivityAt: 1 + } + ] + }, + tabsByWorktree: { + ...current.tabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + }, + browserTabsByWorktree: { + ...current.browserTabsByWorktree, + [defaultBranchId]: [], + [featureId]: [] + } + })) + + const nextState = store.getState() + nextState.setActiveView('terminal') + nextState.setSidebarOpen(true) + nextState.setGroupBy('none') + nextState.setSortBy('recent') + nextState.setShowActiveOnly(false) + nextState.setFilterRepoIds([]) + + return { defaultBranchId, featureId } + }) +} + +test.describe('Default branch visibility', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('keeps the default branch visible when sleeping workspaces are hidden', async ({ + orcaPage + }) => { + const { defaultBranchId, featureId } = await seedSidebarVisibilityScenario(orcaPage) + const defaultBranchRow = worktreeRow(orcaPage, defaultBranchId) + const featureRow = worktreeRow(orcaPage, featureId) + + // Poll rather than set once: hydration can land after the seed and reset the filters. + await expect + .poll(() => + orcaPage.evaluate( + ({ defaultBranchId, featureId }) => { + const state = window.__store?.getState() + state?.setShowSleepingWorkspaces(false) + state?.setHideDefaultBranchWorkspace(false) + state?.setAlwaysShowDefaultBranchWorkspace(true) + const featureTabs = state?.tabsByWorktree[featureId] ?? [] + return { + alwaysShowDefaultBranchWorkspace: state?.alwaysShowDefaultBranchWorkspace ?? null, + defaultBranchTabs: state?.tabsByWorktree[defaultBranchId]?.length ?? 0, + featureBrowserTabs: state?.browserTabsByWorktree[featureId]?.length ?? 0, + featureHasLivePty: featureTabs.some( + (tab) => (state?.ptyIdsByTabId[tab.id] ?? []).length > 0 + ), + featureTabs: featureTabs.length, + hideDefaultBranchWorkspace: state?.hideDefaultBranchWorkspace ?? null, + showSleepingWorkspaces: state?.showSleepingWorkspaces ?? null + } + }, + { defaultBranchId, featureId } + ) + ) + .toEqual({ + alwaysShowDefaultBranchWorkspace: true, + defaultBranchTabs: 0, + featureBrowserTabs: 0, + featureHasLivePty: false, + featureTabs: 0, + hideDefaultBranchWorkspace: false, + showSleepingWorkspaces: false + }) + + await expect(defaultBranchRow).toBeVisible() + await expect(defaultBranchRow).toContainText('Default branch workspace') + await expect(featureRow).toHaveCount(0) + + // Opting out of the exemption is the only way back to the pre-#8873 sweep. + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(false) + }) + + await expect(defaultBranchRow).toHaveCount(0) + + await orcaPage.evaluate(() => { + window.__store?.getState().setAlwaysShowDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toBeVisible() + + // The explicit hide filter still outranks the exemption. + await orcaPage.evaluate(() => { + window.__store?.getState().setHideDefaultBranchWorkspace(true) + }) + + await expect(defaultBranchRow).toHaveCount(0) + }) +}) diff --git a/tests/e2e/linear-filter-chip-labels.spec.ts b/tests/e2e/linear-filter-chip-labels.spec.ts new file mode 100644 index 00000000000..1ccb3d06921 --- /dev/null +++ b/tests/e2e/linear-filter-chip-labels.spec.ts @@ -0,0 +1,105 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { getStoreState, waitForActiveWorktree, waitForSessionReady } from './helpers/store' + +const FIXTURE = { + workspace: { + id: 'linear-workspace-3393', + displayName: 'Linear E2E User', + email: 'linear-e2e@example.test', + organizationId: 'linear-org-3393', + organizationName: 'Linear E2E Workspace' + }, + team: { + id: 'linear-team-3393', + name: 'Engineering', + key: 'ENG' + }, + state: { + id: 'linear-state-uuid-3393', + name: 'In Review', + type: 'started', + color: '#888888', + position: 1 + }, + issue: { + id: 'linear-issue-3393', + workspaceId: 'linear-workspace-3393', + identifier: 'ENG-3393', + title: 'Keep filter chip labels readable', + url: 'https://linear.example.test/ENG-3393', + state: { name: 'In Review', type: 'started', color: '#888888' }, + team: { id: 'linear-team-3393', name: 'Engineering', key: 'ENG' }, + labels: [], + labelIds: [], + priority: 0, + updatedAt: '2026-08-04T18:00:00.000Z' + } +} as const + +async function installLinearFilterBackend(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ ipcMain }, fixture) => { + ipcMain.removeHandler('linear:status') + ipcMain.handle('linear:status', async () => ({ + connected: true, + viewer: fixture.workspace, + workspaces: [fixture.workspace], + activeWorkspaceId: fixture.workspace.id, + selectedWorkspaceId: fixture.workspace.id + })) + + ipcMain.removeHandler('linear:listTeams') + ipcMain.handle('linear:listTeams', async () => [fixture.team]) + + ipcMain.removeHandler('linear:listIssues') + ipcMain.handle('linear:listIssues', async () => ({ items: [fixture.issue], hasMore: false })) + + ipcMain.removeHandler('linear:teamStates') + ipcMain.handle('linear:teamStates', async () => [fixture.state]) + + ipcMain.removeHandler('linear:teamLabels') + ipcMain.handle('linear:teamLabels', async () => []) + + ipcMain.removeHandler('linear:teamMembers') + ipcMain.handle('linear:teamMembers', async () => []) + }, FIXTURE) +} + +async function openLinearTasks(page: Page): Promise { + await page.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + await store.getState().checkLinearConnection(true) + store.getState().openTaskPage({ taskSource: 'linear' }) + }) +} + +test('Linear filter chips keep readable names after the dropdown closes', async ({ + electronApp, + orcaPage +}) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installLinearFilterBackend(electronApp) + await openLinearTasks(orcaPage) + + await expect + .poll(() => getStoreState(orcaPage, 'activeView'), { timeout: 5_000 }) + .toBe('tasks') + const filtersButton = orcaPage.getByRole('button', { name: 'Filters', exact: true }) + await expect(filtersButton).toBeVisible() + await expect(orcaPage.getByText(FIXTURE.issue.title, { exact: true })).toBeVisible() + + await filtersButton.click() + const popover = orcaPage.locator('[data-slot="popover-content"]') + await popover.getByRole('button', { name: 'Status', exact: true }).click() + await popover.getByText(FIXTURE.state.name, { exact: true }).click() + await filtersButton.click() + await expect(popover).toHaveCount(0) + + const statusChip = orcaPage.getByRole('button', { name: 'Remove Status filter' }).locator('..') + await expect(statusChip).toContainText(FIXTURE.state.name) + await expect(statusChip).not.toContainText(FIXTURE.state.id) +}) diff --git a/tests/e2e/native-chat-ask-user-question-card.spec.ts b/tests/e2e/native-chat-ask-user-question-card.spec.ts new file mode 100644 index 00000000000..a4cc2d7c4aa --- /dev/null +++ b/tests/e2e/native-chat-ask-user-question-card.spec.ts @@ -0,0 +1,161 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } 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 { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import type { GlobalSettings } from '../../src/shared/types' + +const QUESTION = 'Tabs or spaces?' + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +// Why (#11761): reproduces the paired-headless topology from the client's side — +// live status arrives carrying agent identity and a working state, but with no +// `interactivePrompt`/`toolName`, which is exactly what the host projection +// dropped. The pending ask exists only in the transcript. +async function seedStatusWithoutAskPayload( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'AskUserQuestion card proof', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +/** A transcript whose last assistant turn leaves an AskUserQuestion unanswered. */ +function pendingAskTranscript(args: { sessionId: string; userText: string }): string { + const userTime = new Date() + const assistantTime = new Date(userTime.getTime() + 2_000) + const lines = [ + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-user`, + timestamp: userTime.toISOString(), + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: args.userText }] } + }, + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-assistant`, + timestamp: assistantTime.toISOString(), + type: 'assistant', + message: { + model: 'claude-opus-4', + content: [ + { type: 'text', text: 'Before I reformat the file I need one decision from you.' }, + { + type: 'tool_use', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: QUESTION, + header: 'Style', + multiSelect: false, + options: [{ label: 'Tabs' }, { label: 'Spaces' }] + } + ] + } + } + ] + } + } + ] + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` +} + +test.describe('Desktop chat AskUserQuestion card (#11761)', () => { + test('renders the answerable question card from the transcript when live status carries no ask', async ({ + orcaPage + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-ask-card-${randomUUID()}` + + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-ask-card-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + const screenshotDir = path.join(process.cwd(), 'validation-screenshots', 'ask-user-question') + mkdirSync(screenshotDir, { recursive: true }) + + try { + const userText = 'Reformat the config file for me' + writeFileSync(transcriptPath, pendingAskTranscript({ sessionId, userText })) + + await enableNativeChatSetting(orcaPage) + await seedStatusWithoutAskPayload(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + await expect(orcaPage.getByText(userText)).toBeVisible({ timeout: 30_000 }) + + // The pre-fix build leaves the composer mounted here and never renders a + // card, so this assertion is what actually gates the regression. + await expect(orcaPage.getByText(QUESTION)).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.getByRole('button', { name: /Spaces/ })).toBeVisible() + await orcaPage.screenshot({ path: path.join(screenshotDir, '01-question-card.png') }) + + // The submit button reads "Skip" until an option is chosen; picking one is + // what proves the card is answerable rather than merely rendered. + await orcaPage.getByRole('button', { name: /Spaces/ }).click() + await expect(orcaPage.getByRole('button', { name: 'Send answer' })).toBeVisible() + await orcaPage.screenshot({ path: path.join(screenshotDir, '02-option-selected.png') }) + + await orcaPage.getByRole('button', { name: 'Send answer' }).click() + // The card owns the composer slot, so its disappearance is the visible + // signal that the answer was accepted and chat input came back. + await expect(orcaPage.getByText(QUESTION)).toHaveCount(0, { timeout: 20_000 }) + await orcaPage.screenshot({ path: path.join(screenshotDir, '03-answered.png') }) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/e2e/repo-icon-emoji-picker.spec.ts b/tests/e2e/repo-icon-emoji-picker.spec.ts new file mode 100644 index 00000000000..2698615b881 --- /dev/null +++ b/tests/e2e/repo-icon-emoji-picker.spec.ts @@ -0,0 +1,80 @@ +/** + * Coverage for the full native emoji picker that replaced the hardcoded + * 12-emoji grid in the repo icon settings (RepositoryIconTabs "Emoji" tab). + * Verifies search + selection persist through the existing RepoIcon + * contract, and captures the new picker for the PR screenshot record. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { getStoreState, waitForSessionReady } from './helpers/store' +import type { Repo } from '../../src/shared/types' + +/** Opens the repo settings panel and pins the UI language to English. */ +async function openRepoSettings(page: Page, repoId: string): Promise { + // Why: the host OS locale (e.g. ko-KR) drives Orca's default UI language. + // Pin English so this spec's locators are stable across dev machines and CI. + await page.evaluate(() => window.__store!.getState().updateSettings({ uiLanguage: 'en' })) + await page.evaluate((repoId) => { + const state = window.__store!.getState() + state.openSettingsTarget({ pane: 'repo', repoId }) + state.openSettingsPage() + }, repoId) + await expect(page.getByPlaceholder('Search settings')).toBeVisible({ timeout: 10_000 }) + const maybeLaterButton = page.getByRole('button', { name: 'Maybe Later' }) + if (await maybeLaterButton.isVisible({ timeout: 1_000 }).catch(() => false)) { + await maybeLaterButton.click() + } +} + +/** Captures a full-page screenshot and attaches it to the test report. */ +async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise { + const screenshotPath = testInfo.outputPath(`${name}.png`) + await page.screenshot({ path: screenshotPath }) + await testInfo.attach(name, { path: screenshotPath, contentType: 'image/png' }) +} + +test.describe('Repository icon emoji picker', () => { + test('search selects a native emoji and persists it as the repo icon', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + + const repos = await getStoreState(orcaPage, 'repos') + expect(repos.length).toBeGreaterThan(0) + const repo = repos[0] + + await openRepoSettings(orcaPage, repo.id) + + const repoSection = orcaPage.locator(`[data-settings-section="repo-${repo.id}"]`) + await repoSection.getByRole('tab', { name: 'Emoji' }).click() + + const picker = repoSection.locator('.repo-icon-emoji-picker') + await expect(picker).toBeVisible({ timeout: 10_000 }) + + // Full catalog with search — the removed grid only ever offered 12 fixed emoji. + const searchInput = picker.getByPlaceholder('Search emoji') + await expect(searchInput).toBeVisible() + await searchInput.fill('rocket') + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-search') + + const rocketResult = picker.getByRole('button', { name: /rocket/i }).first() + await expect(rocketResult).toBeVisible({ timeout: 10_000 }) + await rocketResult.click() + + await expect + .poll( + async () => { + const current = await getStoreState(orcaPage, 'repos') + return current.find((entry) => entry.id === repo.id)?.repoIcon + }, + { timeout: 5_000, message: 'repo icon did not persist the picked emoji' } + ) + .toEqual({ type: 'emoji', emoji: '🚀' }) + + // The store round-trip alone would pass even if the panel rendered nothing. + await expect(repoSection.getByText('Current: 🚀')).toBeVisible() + + await attachScreenshot(orcaPage, testInfo, 'repo-icon-emoji-picker-selected') + }) +}) diff --git a/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts new file mode 100644 index 00000000000..c7f87dee9e3 --- /dev/null +++ b/tests/e2e/repro-7732-gitlab-checks-job-details.spec.ts @@ -0,0 +1,160 @@ +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { openChecks } from './helpers/source-control-ai-generation' + +// Repro for #7732: expanding a GitLab pipeline job in the Checks panel must show its log, +// not "No inline details are available for this check." All fixture data is synthetic. +const FIXTURE = { + mrNumber: 4242, + jobId: 987654, + jobName: 'Purchase API Component Tests', + stage: 'Component Tests', + webUrl: 'https://gitlab.example.test/acme/orca/-/jobs/987654', + mrUrl: 'https://gitlab.example.test/acme/orca/-/merge_requests/4242', + headSha: 'e2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2ee2e', + trace: [ + '$ pnpm test:component --project purchase-api', + 'FAIL src/purchase/refund.spec.ts', + ' AssertionError: expected refunded amount 4200 to equal 4250', + 'ERROR: Job failed: exit code 1' + ].join('\n') +} as const + +const SCREENSHOT_DIR = + process.env.ORCA_GITLAB_CHECKS_JOB_DETAILS_SCREENSHOT_DIR ?? + path.join(process.cwd(), 'test-results', 'gitlab-checks-job-details') + +// contextIsolation makes window.api non-writable, so stub at the IPC boundary in main. +async function installGitLabChecksBackend(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ ipcMain }, fx) => { + ipcMain.removeHandler('hostedReview:forBranch') + ipcMain.handle('hostedReview:forBranch', async () => ({ + provider: 'gitlab', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'open', + url: fx.mrUrl, + status: 'failure', + updatedAt: '2026-07-07T12:00:00.000Z', + mergeable: 'MERGEABLE', + headSha: fx.headSha + })) + + ipcMain.removeHandler('gitlab:workItemDetails') + ipcMain.handle('gitlab:workItemDetails', async () => ({ + item: { + id: `gitlab-mr-${fx.mrNumber}`, + type: 'mr', + number: fx.mrNumber, + title: 'Add purchase API component test coverage', + state: 'opened', + url: fx.mrUrl, + labels: [], + updatedAt: '2026-07-07T12:00:00.000Z', + author: 'e2e-bot' + }, + body: 'Synthetic MR used for #7732 repro evidence.', + comments: [], + headSha: fx.headSha, + pipelineJobs: [ + { + id: fx.jobId, + pipelineId: 55, + name: fx.jobName, + stage: fx.stage, + status: 'failed', + webUrl: fx.webUrl, + duration: 87 + } + ], + reviewers: [] + })) + + ipcMain.removeHandler('gitlab:jobTrace') + ;(globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls = 0 + ipcMain.handle('gitlab:jobTrace', async () => { + const g = globalThis as { __repro7732JobTraceCalls?: number } + g.__repro7732JobTraceCalls = (g.__repro7732JobTraceCalls ?? 0) + 1 + return { ok: true, trace: fx.trace } + }) + }, FIXTURE) +} + +async function readJobTraceCallCount(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => (globalThis as { __repro7732JobTraceCalls?: number }).__repro7732JobTraceCalls ?? 0 + ) +} + +async function linkGitLabMRToWorktree(page: Page, worktreeId: string): Promise { + await page.evaluate( + ({ worktreeId, mrNumber }) => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + store.setState((current) => ({ + worktreesByRepo: Object.fromEntries( + Object.entries(current.worktreesByRepo).map(([repoId, worktrees]) => [ + repoId, + worktrees.map((worktree) => + worktree.id === worktreeId ? { ...worktree, linkedGitLabMR: mrNumber } : worktree + ) + ]) + ), + gitStatusByWorktree: { ...current.gitStatusByWorktree, [worktreeId]: [] } + })) + }, + { worktreeId, mrNumber: FIXTURE.mrNumber } + ) +} + +test.describe('#7732 GitLab Checks panel job details', () => { + test('expanding a failed pipeline job shows its log, not "No inline details"', async ({ + orcaPage, + electronApp + }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await installGitLabChecksBackend(electronApp) + + const worktreeId = await orcaPage.evaluate( + () => window.__store?.getState().activeWorktreeId ?? null + ) + if (!worktreeId) { + throw new Error('E2E fixture did not expose an active worktree') + } + // Late startup UI hydration resets the active workspace + sidebar route; let it settle first. + await orcaPage.waitForTimeout(8_000) + + const jobRow = orcaPage.getByText(`${FIXTURE.stage}: ${FIXTURE.jobName}`, { exact: true }) + for (let attempt = 0; attempt < 40 && (await jobRow.count()) === 0; attempt++) { + await linkGitLabMRToWorktree(orcaPage, worktreeId) + await openChecks(orcaPage, worktreeId) + await orcaPage.waitForTimeout(500) + } + await expect(jobRow).toBeVisible({ timeout: 15_000 }) + + mkdirSync(SCREENSHOT_DIR, { recursive: true }) + await jobRow.click() + const noDetails = orcaPage.getByText('No inline details are available for this check.') + const viewFullLogs = orcaPage.getByRole('button', { name: 'View full logs' }) + for (let attempt = 0; attempt < 20; attempt++) { + if ((await noDetails.count()) > 0 || (await viewFullLogs.count()) > 0) { + break + } + await orcaPage.waitForTimeout(500) + } + await orcaPage.screenshot({ + path: path.join(SCREENSHOT_DIR, 'gitlab-checks-job-expanded.png') + }) + + // Correct behavior: the stubbed gitlab:jobTrace log renders instead of the "no details" fallback. + expect(await readJobTraceCallCount(electronApp)).toBeGreaterThan(0) + await expect(noDetails).toHaveCount(0) + await expect(viewFullLogs).toBeVisible({ timeout: 10_000 }) + }) +}) diff --git a/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts new file mode 100644 index 00000000000..c854c8ba798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm-mouse.spec.ts @@ -0,0 +1,101 @@ +/** + * #10142 follow-ups to the X-button regression: middle-click prompts too, confirming + * actually closes, and Cmd+W raises exactly one dialog (the pane path delegates the + * last-pane close to closeTerminalTab instead of probing a second time). + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +async function startBusyTerminal(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + const hasPaneManager = await waitForActiveTerminalManager(page, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(page, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(page) + await execInTerminal(page, ptyId, 'echo close-confirm-ready') + await waitForTerminalOutput(page, 'close-confirm-ready', 20_000) + await execInTerminal(page, ptyId, 'sleep 300') + // Why: `hasChildProcesses` is already true while macOS's `login` wrapper starts the + // shell, so wait for `sleep` itself or the close legitimately sees an idle terminal. + await expect + .poll( + async () => + (await page.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)).foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + return (await getActiveTabId(page))! +} + +test.describe.configure({ mode: 'serial' }) + +test('middle-clicking a busy tab prompts, and cancelling keeps the tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + const tabsBefore = await orcaPage.locator(SORTABLE_TAB).count() + + await busyTab.click({ button: 'middle' }) + + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + expect(await orcaPage.locator(SORTABLE_TAB).count()).toBe(tabsBefore) +}) + +test('confirming the X-button prompt closes the busy tab', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) + +test('Cmd+W on a busy single-pane tab raises exactly one dialog', async ({ orcaPage }) => { + test.setTimeout(120_000) + const busyTabId = await startBusyTerminal(orcaPage) + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Stop and Close$/ }).click() + + await expect(busyTab).toHaveCount(0, { timeout: 15_000 }) + // Why: the pane used to probe and prompt on its own before delegating to + // closeTerminalTab, which now prompts too — a second dialog would mean a double prompt. + await orcaPage.waitForTimeout(1_500) + await expect(closeDialogTitle(orcaPage)).toBeHidden() +}) diff --git a/tests/e2e/terminal-tab-close-running-confirm.spec.ts b/tests/e2e/terminal-tab-close-running-confirm.spec.ts new file mode 100644 index 00000000000..68182eb4798 --- /dev/null +++ b/tests/e2e/terminal-tab-close-running-confirm.spec.ts @@ -0,0 +1,90 @@ +/** + * Regression for #10142: keyboard and mouse enforce the same running-process close + * confirmation. Both halves run against one tab with a live `sleep 300` child: + * 1. Cmd/Ctrl+W -> "Stop running command?" dialog (cancelled, tab survives). + * 2. X click -> the same dialog, and the tab is still there behind it. + */ +import { test, expect } from './helpers/orca-app' +import type { Page } from '@stablyai/playwright-test' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveTabId, + ensureTerminalVisible +} from './helpers/store' +import { + execInTerminal, + focusActiveTerminalInput, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' + +const SORTABLE_TAB = '[data-testid="sortable-tab"]' + +function countRenderedTabs(page: Page): Promise { + return page.locator(SORTABLE_TAB).count() +} + +function closeDialogTitle(page: Page) { + return page.getByText(/Stop running command\?|Stop this agent\?/) +} + +test.describe.configure({ mode: 'serial' }) + +test('the tab X button applies the same running-process confirmation as Cmd+W', async ({ + orcaPage +}) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + const hasPaneManager = await waitForActiveTerminalManager(orcaPage, 30_000) + .then(() => true) + .catch(() => false) + test.skip(!hasPaneManager, 'Electron automation never mounted the live TerminalPane manager.') + await waitForPaneCount(orcaPage, 1, 30_000) + + const ptyId = await waitForActivePanePtyId(orcaPage) + await execInTerminal(orcaPage, ptyId, 'echo repro-10142-ready') + await waitForTerminalOutput(orcaPage, 'repro-10142-ready', 20_000) + await execInTerminal(orcaPage, ptyId, 'sleep 300') + // Only press close once `sleep` is the foreground process; otherwise the probe + // legitimately sees an idle shell and closing is correct. `hasChildProcesses` alone is + // not enough: macOS spawns the shell under `login`, so a still-initialising terminal + // reports a child before `sleep 300` has run. + await expect + .poll( + async () => + (await orcaPage.evaluate((id) => window.api.pty.inspectProcess(id), ptyId)) + .foregroundProcess, + { timeout: 20_000, message: 'sleep 300 never became the foreground process' } + ) + .toBe('sleep') + + const busyTabId = (await getActiveTabId(orcaPage))! + const busyTab = orcaPage.locator(`${SORTABLE_TAB}[data-tab-id="${busyTabId}"]`).first() + + // 1. Keyboard close prompts. + await focusActiveTerminalInput(orcaPage) + await orcaPage.keyboard.press(process.platform === 'darwin' ? 'Meta+w' : 'Control+w') + await expect(closeDialogTitle(orcaPage)).toBeVisible({ timeout: 15_000 }) + await orcaPage.getByRole('button', { name: /^Cancel$/ }).click() + await expect(closeDialogTitle(orcaPage)).toBeHidden() + await expect(busyTab).toBeVisible() + const tabsBefore = await countRenderedTabs(orcaPage) + + // 2. Same tab, same running child, mouse close. + await busyTab.hover() + await busyTab.getByRole('button', { name: /^Close tab /i }).click() + await orcaPage.waitForTimeout(1_500) + + expect( + { + confirmDialogVisible: await closeDialogTitle(orcaPage).isVisible(), + tabStillPresent: (await countRenderedTabs(orcaPage)) === tabsBefore + }, + 'X-button close must apply the same running-process confirmation as Cmd+W' + ).toEqual({ confirmDialogVisible: true, tabStillPresent: true }) +})