diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index bc3c5a8afff..b8ec40a0058 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -182,20 +182,15 @@ jobs: MAIN_REPO_TOKEN: ${{ github.token }} run: | set -euo pipefail - # Why read the highest number off existing titles rather than counting - # releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would - # roll backwards after three days and reissue numbers already in use. The - # maximum is always among the retained builds, because pruning drops the - # oldest. `capture(...)?` swallows the non-match on legacy titles, which - # were the raw tag, so the series simply starts at 01. + # Existing titles, which carry the build number this series continues + # from. The script picks the number, because it restarts per base version + # and only the script knows which base this build resolved to. # # Why drafts count here but not in the freshness check: that check asks # "did this commit ship", where a draft is a no. This one asks "is the # number free", where a stranded draft still holds one. - last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \ - --jq '[.[] | (.name // "") | capture(" • (?[0-9]+) • ")? | .n | tonumber] | max // 0')" - build_number=$(( last_number + 1 )) - echo "Hourly build number $build_number (previous high: $last_number)" + names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \ + --jq '.[].name // empty')" # Why the main repo's tags decide the base version rather than # package.json: main's version only moves on `release:` commits, and # stable patches are cut from release branches that never merge back, so @@ -207,9 +202,10 @@ jobs: --repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \ --json tagName --jq '.[].tagName' || true)" echo "Highest published tag seen: $(head -1 <<<"$published")" - ORCA_PUBLISHED_VERSIONS="$published" \ - ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \ + ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \ + node config/scripts/hourly-build-version.mjs \ >"$RUNNER_TEMP/hourly-identity.txt" + grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt" # Why check rather than trust: the checkout above pins `ref: main`, but a # workflow_dispatch runs this file from whatever branch was dispatched. A # branch that edits this step while main still has the old script yields 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 ad6fdd1550c..0a010442e1f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -2338,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", diff --git a/config/scripts/adhoc-build-version.mjs b/config/scripts/adhoc-build-version.mjs index b62f28d1c4f..c5a117f8ed6 100644 --- a/config/scripts/adhoc-build-version.mjs +++ b/config/scripts/adhoc-build-version.mjs @@ -71,7 +71,7 @@ export function normalizeAdhocLabel(label) { } /** - * `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release + * `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release * title, shown verbatim in both the GitHub releases list and the build picker. * * Why the label sits where hourly puts its build number: several adhoc builds diff --git a/config/scripts/adhoc-build-version.test.mjs b/config/scripts/adhoc-build-version.test.mjs index 4824720f2fc..b36ac2cdc6b 100644 --- a/config/scripts/adhoc-build-version.test.mjs +++ b/config/scripts/adhoc-build-version.test.mjs @@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => { formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso)) it('renders version, label, Pacific timestamp, and short sha', () => { - expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241') + expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241') }) // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so // the offset between them is not a constant. A test pinned to one season would // pass all summer and start failing in November. it('follows the Pacific offset across DST', () => { - expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ') - expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ') + expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ') + expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ') }) it('sanitizes the label before it reaches the title', () => { expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe( - '1.4.163 • fix-now • 07-31 13:54 • e698241' + '1.4.163 • fix-now • Jul 31, 1:54PM • e698241' ) }) 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/hourly-build-version.mjs b/config/scripts/hourly-build-version.mjs index 12257c2b4ff..9f6b9000844 100644 --- a/config/scripts/hourly-build-version.mjs +++ b/config/scripts/hourly-build-version.mjs @@ -34,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) { } /** - * `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown - * verbatim in both the GitHub releases list and the in-app build picker. + * The next build number for `baseVersion`, counting from the titles of existing + * hourly releases. + * + * Why the series restarts at 01 on every base version: the number answers "which + * build of 1.4.163 is this", so a counter shared across versions makes it + * meaningless — 1.4.164 would open at 38 for no reason a reader can see. + * + * Why the maximum rather than a count: the prune step trims to + * HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number + * already in use. Titles that predate this naming simply do not match, which is + * how the first build of a version lands on 01. + */ +export function nextHourlyBuildNumber(baseVersion, releaseNames = []) { + const prefix = `${baseVersion} • ` + const highest = releaseNames.reduce((max, entry) => { + const name = String(entry ?? '') + if (!name.startsWith(prefix)) { + return max + } + const match = /^(\d+) • /.exec(name.slice(prefix.length)) + return match ? Math.max(max, Number(match[1])) : max + }, 0) + return highest + 1 +} + +/** + * `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title, + * shown verbatim in both the GitHub releases list and the in-app build picker. */ export function formatHourlyReleaseName(version, buildNumber, commit, date) { if (!Number.isInteger(buildNumber) || buildNumber < 1) { @@ -49,21 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) { ].join(' • ') } -export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) { +// Why the number is derived here rather than passed in: it counts builds of the +// base version, and the base is only known once the published tags have been +// resolved just above. Computing it outside meant numbering against whatever +// version the caller guessed. +export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) { const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { encoding: 'utf8' }).trim() - const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions) + const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? []) const version = createHourlyBuildVersion(base, now) - return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) } + const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? []) + return { + commit, + version, + buildNumber, + name: formatHourlyReleaseName(version, buildNumber, commit, now) + } } if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { - const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1') - const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv()) + const identity = getHourlyBuildIdentity(new Date(), { + publishedVersions: readPublishedVersionsFromEnv(), + // Titles are newline separated and contain spaces, so this cannot reuse the + // whitespace split the version list gets. + releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean) + }) // Consumed by the workflow via $GITHUB_OUTPUT. process.stdout.write( - `version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n` + `version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n` ) } diff --git a/config/scripts/hourly-build-version.test.mjs b/config/scripts/hourly-build-version.test.mjs index 4454ff81512..08fc9d28b81 100644 --- a/config/scripts/hourly-build-version.test.mjs +++ b/config/scripts/hourly-build-version.test.mjs @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest' -import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs' +import { + createHourlyBuildVersion, + formatHourlyReleaseName, + nextHourlyBuildNumber +} from './hourly-build-version.mjs' import { compareAppVersions } from '../../src/shared/app-version' describe('createHourlyBuildVersion', () => { @@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => { formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso)) it('renders version, number, Pacific timestamp, and short sha', () => { - expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241') + expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241') }) // Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so // the offset between them is not a constant. A test pinned to one season would // pass all summer and start failing in November. it('follows the Pacific offset across DST', () => { - expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241') - expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241') + expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241') + expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241') }) - // Why: hour12: false renders midnight as "24" on some ICU builds, which would - // read as an hour that does not exist and sort oddly beside 00:xx. - it('renders midnight as 00, never 24', () => { - expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ') + // Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in + // particular renders as 0:00 or 24:00 on a formatter set up carelessly. + it('renders both noon and midnight as 12', () => { + expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM') + expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM') + }) + + // Recent ICU puts U+202F before AM/PM; the title must carry no separator at all. + it('joins the meridiem with no whitespace of any kind', () => { + expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/) + expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u) }) it('pads to two digits and grows past them', () => { @@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => { ) }) }) + +describe('nextHourlyBuildNumber', () => { + const titles = [ + '1.4.163 • 01 • Jul 31, 1:54PM • e698241', + '1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa', + '1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb' + ] + + it('continues the series for the version being built', () => { + expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38) + }) + + // The point of the change: 1.4.164 opens its own series at 01 rather than + // inheriting 1.4.163's count, which said nothing about 1.4.164. + it('restarts at 1 when the base version moves', () => { + expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1) + expect( + nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc']) + ).toBe(2) + }) + + // Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting + // would roll backwards and reissue a number already used. + it('takes the highest number, not the count', () => { + expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10) + }) + + it('starts at 1 with no history at all', () => { + expect(nextHourlyBuildNumber('1.4.163')).toBe(1) + expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1) + }) + + // Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as + // a prefix of 1.4.163's series. + it('ignores titles that are not this version', () => { + expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1) + expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1) + }) +}) 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/release-title-timestamp.mjs b/config/scripts/release-title-timestamp.mjs index 56d3608ccaf..64897b4c319 100644 --- a/config/scripts/release-title-timestamp.mjs +++ b/config/scripts/release-title-timestamp.mjs @@ -1,7 +1,7 @@ const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles' /** - * `07-31 13:54` — the timestamp segment of a dev build's release title, shown + * `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown * verbatim in both the GitHub releases list and the in-app build picker. * * Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and @@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-US', { timeZone: RELEASE_NAME_TIME_ZONE, - month: '2-digit', - day: '2-digit', - hour: '2-digit', + month: 'short', + day: 'numeric', + hour: 'numeric', minute: '2-digit', - // Why h23 rather than hour12: false: some ICU builds render midnight as 24. - hourCycle: 'h23' + hour12: true }) .formatToParts(date) .map((part) => [part.type, part.value]) ) - return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}` + // Assembled from parts rather than by string-editing the formatted output: + // recent ICU separates the time from AM/PM with U+202F, not a plain space, so + // a naive replace(' ', '') leaves the gap on some runtimes and not others. + return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}` } 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/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/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/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index e6267f0e63a..d09afdbe139 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -198,7 +198,7 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc' import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership' -import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' +import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { acceptSessionSnapshot, @@ -3195,44 +3195,23 @@ export default function SessionScreen() { }) }, []) - // Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss. - const handleFileTapActivationSeqRef = useRef(0) - const handleFileTap = useCallback( - (handle: string, pathText: string, line: number | null, column: number | null) => { - if (handle !== activeHandleRef.current || !client) { - return - } - const activationSeq = ++handleFileTapActivationSeqRef.current - openMobileTerminalFileTap({ - client, - hostId, - worktreeId, - worktreeName: routeWorktreeName, - terminalHandle: handle, - pathText, - cwd: terminalCwdRef.current.get(handle) ?? null, - line, - column, - pushPreviewRoute: (href) => router.push(href), - openBrowser: (url) => void handleCreateBrowserRef.current?.(url), - triggerOpenFeedback: triggerSelection, - fetchSessionTabs, - getSessionTabs: () => sessionTabsRef.current, - getActiveSessionTabId: () => activeSessionTabIdRef.current, - getActivationState: (activated) => ({ - activated, - activationSeq, - latestActivationSeq: handleFileTapActivationSeqRef.current, - sourceTerminalHandle: handle, - activeTerminalHandle: activeHandleRef.current, - activeTabType: activeSessionTabTypeRef.current - }), - switchSessionTab: (tab) => switchSessionTabRef.current?.(tab), - scheduleDelayedAction - }) - }, - [client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId] - ) + // Tap a terminal or chat file path → resolve on host, open as file tab/preview. + const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers({ + client, + hostId, + worktreeId, + worktreeName: routeWorktreeName, + activeHandleRef, + terminalCwdRef, + openBrowser: (url) => void handleCreateBrowserRef.current?.(url), + fetchSessionTabs, + getSessionTabs: () => sessionTabsRef.current, + getActiveSessionTabId: () => activeSessionTabIdRef.current, + getActiveSessionTabType: () => activeSessionTabTypeRef.current, + switchSessionTab: (tab) => switchSessionTabRef.current?.(tab), + scheduleDelayedAction, + reportChatTapFailure: nativeChatSendError.show + }) const handleOpenedFileDiffActivationSeqRef = useRef(0) // Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus. @@ -4753,6 +4732,7 @@ export default function SessionScreen() { ))} [styles.resumeCard, pressed && styles.hostCardPressed]} onPress={() => - router.push( - createMobileSessionHref({ - hostId: resumeWorktree.hostId, - worktreeId: resumeWorktree.worktree.worktreeId, - name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo - }) - ) + openMobileSession({ + hostId: resumeWorktree.hostId, + worktreeId: resumeWorktree.worktree.worktreeId, + name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo + }) } > @@ -910,7 +910,7 @@ export default function HomeScreen() { onDismiss: () => setActionTarget(null), onReconnect: (hostId) => void forceReconnectHost(hostId).catch(showForceReconnectError), onDisconnect: closeHostClient, - onEdit: (hostId) => navigateToMobileHostEdit(router, hostId), + onEdit: openMobileHostEdit, onRemove: setConfirmRemove })} onClose={() => setActionTarget(null)} diff --git a/mobile/src/components/MobileMarkdown.file-links.test.ts b/mobile/src/components/MobileMarkdown.file-links.test.ts new file mode 100644 index 00000000000..a8e381865d8 --- /dev/null +++ b/mobile/src/components/MobileMarkdown.file-links.test.ts @@ -0,0 +1,133 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileMarkdown } from './MobileMarkdown' + +const openURL = vi.fn(() => Promise.resolve()) + +vi.mock('react-native', () => ({ + Linking: { openURL: (url: string) => openURL(url) }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) +vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' })) + +function flattenText(node: ReactTestInstance): string { + return node.children + .map((child) => (typeof child === 'string' ? child : flattenText(child))) + .join('') +} + +function pressables(renderer: ReactTestRenderer): ReactTestInstance[] { + return renderer.root.findAll( + (node) => node.type === ('Text' as never) && typeof node.props.onPress === 'function' + ) +} + +function pressByText(renderer: ReactTestRenderer, text: string): void { + const target = pressables(renderer).find((node) => flattenText(node) === text) + expect(target, `no pressable text ${JSON.stringify(text)}`).toBeDefined() + target!.props.onPress() +} + +describe('MobileMarkdown file links', () => { + let renderer: ReactTestRenderer | null = null + const onOpenFile = vi.fn() + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + onOpenFile.mockClear() + openURL.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + function render(content: string): ReactTestRenderer { + act(() => { + renderer = create(createElement(MobileMarkdown, { content, onOpenFile })) + }) + return renderer! + } + + it('opens a tapped POSIX absolute path in prose', () => { + pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx') + expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx') + }) + + it('opens a tapped path:line citation in prose', () => { + pressByText(render('see src/foo.ts:42 for the fix'), 'src/foo.ts:42') + expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42') + }) + + it('routes a relative markdown href to the file opener with its #L line', () => { + pressByText(render('read [the plan](docs/plan.md#L7) first'), 'the plan') + expect(onOpenFile).toHaveBeenCalledWith('docs/plan.md:7') + expect(openURL).not.toHaveBeenCalled() + }) + + it('routes a file: href to the file opener', () => { + pressByText(render('[artifact](file:///tmp/out/result.json)'), 'artifact') + expect(onOpenFile).toHaveBeenCalledWith('/tmp/out/result.json') + }) + + it('keeps web links on the system browser', () => { + pressByText(render('go to [site](https://example.com/docs)'), 'site') + expect(openURL).toHaveBeenCalledWith('https://example.com/docs') + expect(onOpenFile).not.toHaveBeenCalled() + }) + + it('drops unknown-scheme hrefs without opening anything', () => { + pressByText(render('[ide](editor://file/x.ts)'), 'ide') + expect(openURL).not.toHaveBeenCalled() + expect(onOpenFile).not.toHaveBeenCalled() + }) + + it('keeps snake_case paths whole instead of shredding them as emphasis', () => { + const rendered = render('compare src/foo_bar.ts and src/baz_qux.ts now') + pressByText(rendered, 'src/foo_bar.ts') + pressByText(rendered, 'src/baz_qux.ts') + expect(onOpenFile).toHaveBeenNthCalledWith(1, 'src/foo_bar.ts') + expect(onOpenFile).toHaveBeenNthCalledWith(2, 'src/baz_qux.ts') + }) + + it('keeps markdown links between snake_case paths tappable', () => { + pressByText( + render('Updated src/foo_bar.py; see [the PR](https://example.com/x) before src/baz_qux.py'), + 'the PR' + ) + expect(openURL).toHaveBeenCalledWith('https://example.com/x') + }) + + it('opens a dunder path as one link', () => { + pressByText(render('see a/__tests__/x.ts now'), 'a/__tests__/x.ts') + expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('a/__tests__/x.ts') + }) + + it('detects paths inside bold spans', () => { + pressByText(render('changed **src/foo.ts** heavily'), 'src/foo.ts') + expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts') + }) + + it('excludes trailing sentence punctuation from autolinks', () => { + pressByText(render('see https://example.com/a.'), 'https://example.com/a') + expect(openURL).toHaveBeenCalledWith('https://example.com/a') + }) + + it('opens inline-code path:line citations', () => { + pressByText(render('fix `src/foo.ts:42` now'), 'src/foo.ts:42') + expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42') + }) + + it('renders paths as plain text without onOpenFile', () => { + act(() => { + renderer = create(createElement(MobileMarkdown, { content: 'Edit src/app/Main.tsx now' })) + }) + expect(pressables(renderer!)).toHaveLength(0) + }) +}) diff --git a/mobile/src/components/MobileMarkdown.tsx b/mobile/src/components/MobileMarkdown.tsx index dc58f5e5d6a..cc88c01e564 100644 --- a/mobile/src/components/MobileMarkdown.tsx +++ b/mobile/src/components/MobileMarkdown.tsx @@ -7,6 +7,11 @@ import { isFilePathCodeSpan, normalizeFilePath } from './markdown-file-path-detection' +import { routeMarkdownHref } from './markdown-href-routing' +import { + isIntrawordUnderscoreToken, + trimAutolinkTrailingPunctuation +} from './markdown-inline-token-rules' import { isMobileMermaidLanguage } from './mobile-mermaid-language' import { parseMobileMarkdown } from './mobile-markdown-parser' import { MermaidDiagram } from './pr-sidebar/MermaidDiagram' @@ -17,10 +22,11 @@ type Props = { /** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1; * the chat view passes >1 so agent prose reads larger than the compact base. */ textScale?: number - /** When provided, detected file-path tokens render as tappable and invoke this - * with the worktree-relative path. Omitted on screens with no file viewer, where + /** When provided, detected file paths and file-target hrefs render as tappable + * and invoke this with the path text (worktree-relative or absolute, with an + * optional :line(:col) suffix). Omitted on screens with no file viewer, where * paths render as plain text (no behavior change). */ - onOpenFile?: (relativePath: string) => void + onOpenFile?: (pathText: string) => void } const MAX_TABLE_ROWS = 40 @@ -28,10 +34,16 @@ const MAX_TABLE_COLUMNS = 8 /** Prose base size — passed to MermaidDiagram fallback mono text. */ const MERMAID_BASE = 13 -function openMarkdownUrl(url: string): void { - const trimmed = url.trim() - if (/^(https?:|mailto:)/i.test(trimmed)) { - void Linking.openURL(trimmed).catch(() => {}) +// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and +// scheme-less paths — the entire desktop file-link contract) go to onOpenFile. +function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void { + const route = routeMarkdownHref(href) + if (route.kind === 'web') { + void Linking.openURL(route.url).catch(() => {}) + return + } + if (route.kind === 'file' && onOpenFile) { + onOpenFile(route.pathText) } } @@ -40,7 +52,7 @@ function openMarkdownUrl(url: string): void { function renderTextRun( text: string, keyPrefix: string, - onOpenFile?: (relativePath: string) => void + onOpenFile?: (pathText: string) => void ): ReactNode { if (!onOpenFile) { return text @@ -65,39 +77,54 @@ function renderTextRun( }) } -function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] { +function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] { const parts: ReactNode[] = [] const pattern = /(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g - let lastIndex = 0 + let pendingStart = 0 let match: RegExpExecArray | null while ((match = pattern.exec(text))) { - if (match.index > lastIndex) { - parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile)) - } const token = match[0] + // Intraword `_` runs (snake_case, dunder tails) are literal text per + // CommonMark; leaving them unflushed keeps surrounding file paths whole + // for detection in the eventual text run. + if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) { + // Resume after the opener so real tokens inside the rejected span are still scanned. + pattern.lastIndex = match.index + 1 + continue + } + if (match.index > pendingStart) { + parts.push( + renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile) + ) + } + pendingStart = pattern.lastIndex const key = `${match.index}:${token}` const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/) const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/) if (image) { parts.push( - openMarkdownUrl(image[2]!)}> + openMarkdownHref(image[2]!, onOpenFile)}> {image[1] || 'image'} ) } else if (link) { parts.push( - openMarkdownUrl(link[2]!)}> + openMarkdownHref(link[2]!, onOpenFile)}> {link[1]} ) } else if (/^https?:\/\//i.test(token)) { + const { url, trailing } = trimAutolinkTrailingPunctuation(token) parts.push( - openMarkdownUrl(token)}> - {token} + openMarkdownHref(url, onOpenFile)}> + {url} ) + if (trailing) { + parts.push({trailing}) + } } else if (token.startsWith('`')) { const code = token.slice(1, -1) if (onOpenFile && isFilePathCodeSpan(code)) { @@ -120,27 +147,26 @@ function renderInline(text: string, onOpenFile?: (relativePath: string) => void) } else if (token.startsWith('~~')) { parts.push( - {token.slice(2, -2)} + {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} ) } else if (token.startsWith('**') || token.startsWith('__')) { parts.push( - {token.slice(2, -2)} + {renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)} ) } else { parts.push( - {token.slice(1, -1)} + {renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)} ) } - lastIndex = pattern.lastIndex } - if (lastIndex < text.length) { - parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile)) + if (pendingStart < text.length) { + parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile)) } return parts } @@ -206,7 +232,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile openMarkdownUrl(block.url)} + onPress={() => openMarkdownHref(block.url, onOpenFile)} > {block.alt || 'Open image'} diff --git a/mobile/src/components/markdown-file-path-detection.test.ts b/mobile/src/components/markdown-file-path-detection.test.ts index f75da01efdc..19e608ec413 100644 --- a/mobile/src/components/markdown-file-path-detection.test.ts +++ b/mobile/src/components/markdown-file-path-detection.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { detectFilePathSegments, isFilePathCodeSpan, - normalizeFilePath + normalizeFilePath, + splitFilePathLineSuffix } from './markdown-file-path-detection' describe('detectFilePathSegments', () => { @@ -64,6 +65,69 @@ describe('detectFilePathSegments', () => { ]) }) + it('detects POSIX absolute paths', () => { + expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([ + { type: 'text', value: 'Wrote ' }, + { type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' }, + { type: 'text', value: ' today' } + ]) + expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([ + { type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' } + ]) + expect(detectFilePathSegments('/root.ts')).toEqual([ + { type: 'file', value: '/root.ts', path: '/root.ts' } + ]) + }) + + it('detects files directly under explicit Windows and relative roots', () => { + expect(detectFilePathSegments(String.raw`C:\root.ts`)).toEqual([ + { type: 'file', value: String.raw`C:\root.ts`, path: String.raw`C:\root.ts` } + ]) + expect(detectFilePathSegments('./root.ts')).toEqual([ + { type: 'file', value: './root.ts', path: 'root.ts' } + ]) + expect(detectFilePathSegments('../root.ts')).toEqual([ + { type: 'file', value: '../root.ts', path: '../root.ts' } + ]) + }) + + it('detects paths with :line and :line:col suffixes', () => { + expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([ + { type: 'text', value: 'see ' }, + { type: 'file', value: 'src/foo.ts:42', path: 'src/foo.ts:42' }, + { type: 'text', value: ' here' } + ]) + expect( + detectFilePathSegments('/wt/src/app.tsx:120:7 and C:\\repo\\a.ts:3').filter( + (s) => s.type === 'file' + ) + ).toEqual([ + { type: 'file', value: '/wt/src/app.tsx:120:7', path: '/wt/src/app.tsx:120:7' }, + { type: 'file', value: 'C:\\repo\\a.ts:3', path: 'C:\\repo\\a.ts:3' } + ]) + }) + + it('keeps a non-line colon tail out of the match', () => { + expect(detectFilePathSegments('edit src/foo.ts: then run')).toEqual([ + { type: 'text', value: 'edit ' }, + { type: 'file', value: 'src/foo.ts', path: 'src/foo.ts' }, + { type: 'text', value: ': then run' } + ]) + }) + + it('does not partially parse numeric-looking non-line tails', () => { + expect(detectFilePathSegments('log src/app.ts:1e3 oops')).toEqual([ + { type: 'text', value: 'log ' }, + { type: 'file', value: 'src/app.ts', path: 'src/app.ts' }, + { type: 'text', value: ':1e3 oops' } + ]) + expect(detectFilePathSegments('coverage src/app.ts:80% of lines')).toEqual([ + { type: 'text', value: 'coverage ' }, + { type: 'file', value: 'src/app.ts', path: 'src/app.ts' }, + { type: 'text', value: ':80% of lines' } + ]) + }) + it('does not match bare filenames without a slash', () => { expect(detectFilePathSegments('open Main.tsx please')).toEqual([ { type: 'text', value: 'open Main.tsx please' } @@ -74,6 +138,15 @@ describe('detectFilePathSegments', () => { expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([ { type: 'text', value: 'https://example.com/path/file.ts' } ]) + expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([ + { type: 'text', value: 'see https://example.com/path/file.ts:42 now' } + ]) + }) + + it('does not match protocol-relative URLs', () => { + expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([ + { type: 'text', value: 'load //cdn.example.com/lib/app.js' } + ]) }) it('does not match version numbers', () => { @@ -171,12 +244,61 @@ describe('isFilePathCodeSpan', () => { expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true) }) + it('accepts POSIX absolute paths and :line citations', () => { + expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true) + expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true) + expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true) + expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true) + expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true) + }) + it('rejects emails and git URLs with a mid-token @', () => { expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false) expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false) }) }) +describe('splitFilePathLineSuffix', () => { + it('splits :line and :line:col suffixes', () => { + expect(splitFilePathLineSuffix('src/foo.ts:42')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: null + }) + expect(splitFilePathLineSuffix('src/foo.ts:42:7')).toEqual({ + path: 'src/foo.ts', + line: 42, + column: 7 + }) + }) + + it('keeps Windows drive colons intact', () => { + expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts`)).toEqual({ + path: String.raw`C:\repo\a.ts`, + line: null, + column: null + }) + expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts:12`)).toEqual({ + path: String.raw`C:\repo\a.ts`, + line: 12, + column: null + }) + }) + + it('ignores non-numeric and zero suffixes', () => { + expect(splitFilePathLineSuffix('src/foo.ts')).toEqual({ + path: 'src/foo.ts', + line: null, + column: null + }) + expect(splitFilePathLineSuffix('src/foo.ts:0')).toEqual({ + path: 'src/foo.ts:0', + line: null, + column: null + }) + }) +}) + describe('normalizeFilePath', () => { it('strips a leading ./', () => { expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts') diff --git a/mobile/src/components/markdown-file-path-detection.ts b/mobile/src/components/markdown-file-path-detection.ts index a33c0dceae4..5e320a3f10c 100644 --- a/mobile/src/components/markdown-file-path-detection.ts +++ b/mobile/src/components/markdown-file-path-detection.ts @@ -83,9 +83,11 @@ const FILE_EXTENSIONS = [ const EXTENSION_SET = new Set(FILE_EXTENSIONS) // Accept the host's native separator because transcript paths originate on the -// connected runtime, which may be Windows even when the phone is not. +// connected runtime, which may be Windows even when the phone is not. Leading +// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional +// tail captures only bounded agent-style :line(:col) citations. const CANDIDATE_PATTERN = - /(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g + /(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g // A path candidate in chat prose is short; a much longer run can't hold one worth // linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we @@ -99,7 +101,31 @@ function hasMidTokenAt(candidate: string): boolean { return /[^\\/]@/.test(candidate) } -function isOpenablePath(candidate: string): boolean { +const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/ + +/** + * Split an agent-style `path:line(:col)` citation into its parts. Windows drive + * colons are safe: only a trailing all-digit suffix is treated as a line ref. + */ +export function splitFilePathLineSuffix(pathText: string): { + path: string + line: number | null + column: number | null +} { + const match = LINE_SUFFIX_PATTERN.exec(pathText) + if (!match) { + return { path: pathText, line: null, column: null } + } + return { + path: match[1]!, + line: Number.parseInt(match[2]!, 10), + column: match[3] ? Number.parseInt(match[3], 10) : null + } +} + +function isOpenablePath(pathText: string): boolean { + // A :line(:col) tail is part of the citation, not the file name. + const { path: candidate } = splitFilePathLineSuffix(pathText) // Reject anything URL-ish or scheme-bearing — those are handled as web links. if (candidate.includes('://') || hasMidTokenAt(candidate)) { return false @@ -153,10 +179,11 @@ export function detectFilePathSegments(text: string): FilePathSegment[] { while ((match = CANDIDATE_PATTERN.exec(text))) { const candidate = match[0] - // Skip candidates that are part of a URL (preceded by a scheme colon or an - // alphanumeric/host char that would make this a domain tail, not a path). + // Skip candidates that are part of a URL: a scheme colon, a domain tail, or + // a preceding slash (the leading slash of an absolute path is part of the + // match itself, so prev '/' means a '://' or '//' remainder, not a path). const prev = match.index > 0 ? text[match.index - 1]! : '' - if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) { + if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) { continue } if (!isOpenablePath(candidate)) { @@ -195,16 +222,18 @@ export function isFilePathCodeSpan(code: string): boolean { if (isOpenablePath(trimmed)) { return true } - // Separator-less code span: accept a clean name.ext with a known extension. - if (/[\\/]/.test(trimmed)) { + // Separator-less code span: accept a clean name.ext (with an optional + // :line(:col) citation tail) and a known extension. + const { path } = splitFilePathLineSuffix(trimmed) + if (/[\\/]/.test(path)) { return false } - const dot = trimmed.lastIndexOf('.') + const dot = path.lastIndexOf('.') if (dot <= 0) { return false } - const name = trimmed.slice(0, dot) - const ext = trimmed.slice(dot + 1).toLowerCase() + const name = path.slice(0, dot) + const ext = path.slice(dot + 1).toLowerCase() if (/[^\w.@+-]/.test(name)) { return false } diff --git a/mobile/src/components/markdown-href-routing.test.ts b/mobile/src/components/markdown-href-routing.test.ts new file mode 100644 index 00000000000..7117a2ecbc8 --- /dev/null +++ b/mobile/src/components/markdown-href-routing.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import { routeMarkdownHref } from './markdown-href-routing' + +describe('routeMarkdownHref', () => { + it('routes web and mail links to the system handler', () => { + expect(routeMarkdownHref('https://example.com/docs')).toEqual({ + kind: 'web', + url: 'https://example.com/docs' + }) + expect(routeMarkdownHref('http://localhost:3000/')).toEqual({ + kind: 'web', + url: 'http://localhost:3000/' + }) + expect(routeMarkdownHref(' mailto:dev@example.com ')).toEqual({ + kind: 'web', + url: 'mailto:dev@example.com' + }) + }) + + it('routes relative hrefs to the file opener', () => { + expect(routeMarkdownHref('src/foo.ts')).toEqual({ kind: 'file', pathText: 'src/foo.ts' }) + expect(routeMarkdownHref('./docs/plan.md')).toEqual({ + kind: 'file', + pathText: './docs/plan.md' + }) + }) + + it('carries a #L fragment as a :line suffix', () => { + expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md:42' + }) + expect(routeMarkdownHref('docs/plan.md?plain=1#line-7')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md:7' + }) + expect(routeMarkdownHref('docs/plan.md#usage')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md' + }) + }) + + it('decodes percent-encoded href paths', () => { + expect(routeMarkdownHref('docs/release%20notes.md')).toEqual({ + kind: 'file', + pathText: 'docs/release notes.md' + }) + }) + + it('routes file: URIs to the file opener', () => { + expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({ + kind: 'file', + pathText: '/Users/me/wt/src/app.tsx' + }) + expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx#L12')).toEqual({ + kind: 'file', + pathText: '/Users/me/wt/src/app.tsx:12' + }) + expect(routeMarkdownHref('file:///C:/repo/src/index.ts')).toEqual({ + kind: 'file', + pathText: 'C:/repo/src/index.ts' + }) + }) + + it('keeps Windows drive paths out of the scheme filter', () => { + expect(routeMarkdownHref(String.raw`C:\repo\src\index.ts`)).toEqual({ + kind: 'file', + pathText: String.raw`C:\repo\src\index.ts` + }) + }) + + it('drops anchors, unknown schemes, and empty hrefs', () => { + expect(routeMarkdownHref('#section')).toEqual({ kind: 'none' }) + expect(routeMarkdownHref('')).toEqual({ kind: 'none' }) + expect(routeMarkdownHref('editor://file/x.ts')).toEqual({ kind: 'none' }) + expect(routeMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'none' }) + expect(routeMarkdownHref('data:text/plain,hi')).toEqual({ kind: 'none' }) + }) +}) diff --git a/mobile/src/components/markdown-href-routing.ts b/mobile/src/components/markdown-href-routing.ts new file mode 100644 index 00000000000..61531f937a5 --- /dev/null +++ b/mobile/src/components/markdown-href-routing.ts @@ -0,0 +1,18 @@ +import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing' + +export type MarkdownHrefRoute = + | { kind: 'web'; url: string } + | { kind: 'file'; pathText: string } + | { kind: 'none' } + +function withLineSuffix(pathText: string, line: number | null): string { + return line === null ? pathText : `${pathText}:${line}` +} + +export function routeMarkdownHref(href: string): MarkdownHrefRoute { + const route = routeNativeChatHref(href) + if (route.kind !== 'file') { + return route + } + return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) } +} diff --git a/mobile/src/components/markdown-inline-token-rules.test.ts b/mobile/src/components/markdown-inline-token-rules.test.ts new file mode 100644 index 00000000000..27d44a36d9c --- /dev/null +++ b/mobile/src/components/markdown-inline-token-rules.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { + isIntrawordUnderscoreToken, + trimAutolinkTrailingPunctuation +} from './markdown-inline-token-rules' + +describe('isIntrawordUnderscoreToken', () => { + it('rejects snake_case emphasis spans', () => { + const text = 'src/foo_bar.ts and src/baz_qux.ts' + const index = text.indexOf('_') + const token = text.slice(index, text.lastIndexOf('_') + 1) + expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true) + }) + + it('keeps standalone emphasis', () => { + expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false) + expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false) + }) + + it('rejects emphasis closed against a following word', () => { + expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true) + }) + + it('rejects dunder emphasis inside a path', () => { + expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true) + expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true) + }) + + it('ignores non-underscore tokens', () => { + expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false) + }) +}) + +describe('trimAutolinkTrailingPunctuation', () => { + it('splits sentence punctuation off the URL', () => { + expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({ + url: 'https://x.com/a', + trailing: '.' + }) + expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({ + url: 'https://x.com/a', + trailing: ',' + }) + expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({ + url: 'https://x.com/a', + trailing: '?!' + }) + }) + + it('keeps balanced parens and strips unbalanced ones', () => { + expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({ + url: 'https://x.com/a_(b)', + trailing: '' + }) + expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({ + url: 'https://x.com/a', + trailing: ').' + }) + }) + + it('handles long unmatched closing-parenthesis tails', () => { + const url = 'https://x.com/a_(b)' + const trailing = ')'.repeat(4096) + expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing }) + }) + + it('leaves clean URLs untouched', () => { + expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({ + url: 'https://x.com/a', + trailing: '' + }) + }) +}) diff --git a/mobile/src/components/markdown-inline-token-rules.ts b/mobile/src/components/markdown-inline-token-rules.ts new file mode 100644 index 00000000000..1afa8477fbb --- /dev/null +++ b/mobile/src/components/markdown-inline-token-rules.ts @@ -0,0 +1,55 @@ +// Post-checks for inline markdown tokens that a single-pass tokenizer regex +// cannot express on its own. + +const INTRAWORD_FLANK_PATTERN = /[\w\\/]/ + +/** + * True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder + * tails). CommonMark treats intraword underscores as literal text; path + * separators count as flanks so dunder path segments also stay whole. + */ +export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean { + if (!token.startsWith('_')) { + return false + } + const prev = index > 0 ? text[index - 1]! : '' + const next = text[index + token.length] ?? '' + return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next) +} + +/** + * Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."), + * keeping a trailing ')' only when the URL itself opened a paren. + */ +export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } { + let end = url.length + let parenthesisCountsReady = false + let openParentheses = 0 + let closeParentheses = 0 + while (end > 0) { + const char = url[end - 1]! + if ('.,;:!?'.includes(char)) { + end-- + continue + } + if (char === ')') { + if (!parenthesisCountsReady) { + for (let index = 0; index < end; index++) { + if (url[index] === '(') { + openParentheses++ + } else if (url[index] === ')') { + closeParentheses++ + } + } + parenthesisCountsReady = true + } + if (closeParentheses > openParentheses) { + end-- + closeParentheses-- + continue + } + } + break + } + return { url: url.slice(0, end), trailing: url.slice(end) } +} 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/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts new file mode 100644 index 00000000000..2dd3cc04e24 --- /dev/null +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import { + coordinateHostStackNavigation, + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackNavigationState +} from './host-stack-navigation' + +const TARGET = { name: '[hostId]/tasks', params: { hostId: 'host/one' } } as const +const OTHER_TARGET = { + name: '[hostId]/session/[worktreeId]', + params: { hostId: 'host/one', worktreeId: 'repo::/tmp/wt' } +} as const + +// Removal is modeled for real: a no-op unsubscribe would let `setState` keep +// calling a canceled listener, testing the `active` guard instead of teardown. +function navigationHarness(initialState: HostStackNavigationState) { + const stateListeners = new Set<() => void>() + let state = initialState + const unsubscribe = vi.fn() + const navigation = { + addListener: vi.fn((_event: 'state', listener: () => void) => { + stateListeners.add(listener) + return () => { + unsubscribe() + stateListeners.delete(listener) + } + }), + dispatch: vi.fn(), + getState: () => state + } + return { + navigation, + unsubscribe, + listenerCount: () => stateListeners.size, + setState(nextState: HostStackNavigationState) { + state = nextState + for (const listener of stateListeners) { + listener() + } + } + } +} + +function committedHostState(hostIdParam: string): HostStackNavigationState { + return { + index: 0, + routes: [ + { + name: 'h', + state: { + key: '/h', + index: 0, + routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: hostIdParam } }] + } + } + ] + } +} + +describe('host stack navigation', () => { + it('matches a host committed as the encoded segment it was pushed as', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const push = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState(committedHostState(encodeURIComponent('host/one'))) + + expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: TARGET + }) + }) + + it('ignores a different host whose id merely decodes badly', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + + navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + harness.setState(committedHostState('100%')) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + }) + + it('stops listening and never replaces once canceled', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + + const controller = navigateToHostStackRoute( + harness.navigation, + { push: vi.fn() }, + 'host/one', + TARGET + ) + expect(harness.listenerCount()).toBe(1) + controller.cancel() + + expect(harness.unsubscribe).toHaveBeenCalledTimes(1) + expect(harness.listenerCount()).toBe(0) + expect(controller.isActive()).toBe(false) + + harness.setState(committedHostState('host/one')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + }) + + it('retargets a still-pending transition to the same host instead of pushing twice', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const push = vi.fn() + const router = { push } + + const pending = coordinateHostStackNavigation( + null, + harness.navigation, + router, + 'host/one', + TARGET + ) + const retargeted = coordinateHostStackNavigation( + pending, + harness.navigation, + router, + 'host/one', + OTHER_TARGET + ) + + expect(retargeted).toBe(pending) + expect(push).toHaveBeenCalledTimes(1) + expect(harness.listenerCount()).toBe(1) + + harness.setState(committedHostState('host/one')) + + expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) + expect(harness.navigation.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ payload: OTHER_TARGET }) + ) + }) +}) diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts new file mode 100644 index 00000000000..4fcdff8cac5 --- /dev/null +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -0,0 +1,171 @@ +export type HostStackNavigationState = Readonly<{ + key?: string + index: number + routes: readonly HostStackNavigationRoute[] +}> + +export type HostStackNavigationRoute = Readonly<{ + key?: string + name: string + params?: Readonly<{ hostId?: unknown }> + state?: HostStackNavigationState +}> + +/** A screen inside app/h/_layout.tsx plus the params it needs, e.g. + * `{ name: '[hostId]/session/[worktreeId]', params: { hostId, worktreeId } }`. */ +export type HostStackRouteTarget = Readonly<{ + name: string + params: Readonly> +}> + +export type HostStackReplaceAction = Readonly<{ + type: 'REPLACE' + target: string + source: string + payload: HostStackRouteTarget +}> + +export type HostStackRootNavigation = { + addListener: (event: 'state', listener: () => void) => () => void + dispatch: (action: HostStackReplaceAction) => void + getState: () => HostStackNavigationState +} + +export type HostStackHostRoute = `/h/${string}` + +export type HostStackRouter = { + push: (route: HostStackHostRoute) => void +} + +export type HostStackNavigationController = Readonly<{ + cancel: () => void + isActive: () => boolean + retarget: (target: HostStackRouteTarget) => void +}> + +export type PendingHostStackNavigation = Readonly<{ + hostId: string + controller: HostStackNavigationController +}> + +export function hostStackHostRoute(hostId: string): HostStackHostRoute { + return `/h/${encodeURIComponent(hostId)}` +} + +// Why: the host is pushed as an encoded segment, so the committed route may hold +// either form — an id with `/`, `#`, or `%` must still match its own push. +function hostParamMatches(param: unknown, expectedHostId: string): boolean { + if (typeof param !== 'string') { + return false + } + if (param === expectedHostId) { + return true + } + try { + return decodeURIComponent(param) === expectedHostId + } catch { + return false // Lone `%` — not our encoding. + } +} + +function mountedHostStack( + state: HostStackNavigationState, + expectedHostId: string +): { key: string; routeKey: string } | null { + const hostContainer = state.routes[state.index] + const hostState = hostContainer?.state + const hostRoute = hostState?.routes[hostState.index] + if ( + hostContainer?.name !== 'h' || + !hostState?.key || + hostRoute?.name !== '[hostId]/index' || + !hostRoute.key || + !hostParamMatches(hostRoute.params?.hostId, expectedHostId) + ) { + return null + } + return { key: hostState.key, routeKey: hostRoute.key } +} + +/** Opens a deep host route by mounting `/h/[hostId]` first and replacing it once + * its stack is committed. A cold push straight to a nested route resolves to the + * host index without the dynamic id, which lands on a blank host screen. */ +export function navigateToHostStackRoute( + navigation: HostStackRootNavigation, + router: HostStackRouter, + hostId: string, + target: HostStackRouteTarget +): HostStackNavigationController { + let active = true + let hostRouteSeen = false + let selectedTarget = target + let unsubscribeState = () => {} + const dispose = () => { + if (!active) { + return + } + active = false + unsubscribeState() + } + + // Why: cold Expo deep links resolve to index; target the route only after its HostStack exists. + const onState = () => { + if (!active) { + return + } + const state = navigation.getState() + const currentRoute = state.routes[state.index] + if (currentRoute?.name === 'h') { + hostRouteSeen = true + } else if (hostRouteSeen) { + dispose() + return + } + const hostStack = mountedHostStack(state, hostId) + if (!hostStack) { + return + } + dispose() + navigation.dispatch({ + type: 'REPLACE', + target: hostStack.key, + source: hostStack.routeKey, + payload: selectedTarget + }) + } + + try { + unsubscribeState = navigation.addListener('state', onState) + router.push(hostStackHostRoute(hostId)) + } catch (error) { + dispose() + throw error + } + return { + cancel: dispose, + isActive: () => active, + retarget: (nextTarget) => { + if (active) { + selectedTarget = nextTarget + } + } + } +} + +export function coordinateHostStackNavigation( + current: PendingHostStackNavigation | null, + navigation: HostStackRootNavigation, + router: HostStackRouter, + hostId: string, + target: HostStackRouteTarget +): PendingHostStackNavigation { + if (current?.hostId === hostId && current.controller.isActive()) { + current.controller.retarget(target) + return current + } + current?.controller.cancel() + return { + hostId, + controller: navigateToHostStackRoute(navigation, router, hostId, target) + } +} diff --git a/mobile/src/navigation/use-open-host-stack-route.ts b/mobile/src/navigation/use-open-host-stack-route.ts new file mode 100644 index 00000000000..674ec5c34f7 --- /dev/null +++ b/mobile/src/navigation/use-open-host-stack-route.ts @@ -0,0 +1,38 @@ +import { useCallback, useEffect } from 'react' +import { useNavigation, useRouter } from 'expo-router' +import { + coordinateHostStackNavigation, + type HostStackRootNavigation, + type HostStackRouteTarget, + type PendingHostStackNavigation +} from './host-stack-navigation' + +// Why: one root navigator means one pending transition. A per-hook ref would let a +// Resume tap and a Tasks tap arm two independent pushes that cannot cancel each other. +let pendingNavigation: PendingHostStackNavigation | null = null + +export function useOpenHostStackRoute(): (hostId: string, target: HostStackRouteTarget) => void { + const navigation = useNavigation() + const router = useRouter() + + useEffect( + () => () => { + pendingNavigation?.controller.cancel() + pendingNavigation = null + }, + [] + ) + + return useCallback( + (hostId, target) => { + pendingNavigation = coordinateHostStackNavigation( + pendingNavigation, + navigation, + router, + hostId, + target + ) + }, + [navigation, router] + ) +} 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..ba587bab616 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -1,10 +1,16 @@ +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 + /** Opens a tapped file reference (worktree-relative or absolute, optional + * :line(:col) suffix) through the shared tap-to-open flow. */ + onOpenFile: (pathText: string) => void /** Native-chat image attachments: picking adds a composer chip, and sending * rides the pending images along with the message text (desktop parity). */ images: MobileNativeChatImageAttachments @@ -22,9 +28,12 @@ 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, + onOpenFile, images, onMicPress, micActive, @@ -36,19 +45,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..f6394736528 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 @@ -272,6 +274,9 @@ export function MobileNativeChatView({ keyExtractor={(item) => item.id} renderItem={renderItem} contentContainerStyle={styles.listContent} + // Let link/file taps land while the composer keyboard is up + // instead of being swallowed by the dismiss gesture. + keyboardShouldPersistTaps="handled" onScroll={onScroll} scrollEventThrottle={32} onContentSizeChange={() => { diff --git a/mobile/src/session/mobile-terminal-file-tap-open.test.ts b/mobile/src/session/mobile-file-tap-open.test.ts similarity index 72% rename from mobile/src/session/mobile-terminal-file-tap-open.test.ts rename to mobile/src/session/mobile-file-tap-open.test.ts index a958b977260..d2a324d2c12 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.test.ts +++ b/mobile/src/session/mobile-file-tap-open.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open' +import { openMobileFileTap } from './mobile-file-tap-open' function ok(result: unknown) { return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -22,7 +22,7 @@ function activeTerminalState(activated: boolean) { } } -describe('openMobileTerminalFileTap', () => { +describe('openMobileFileTap', () => { it('opens absolute terminal artifacts through the grant-backed preview route', async () => { const client = createClient([ ok({ @@ -42,7 +42,7 @@ describe('openMobileTerminalFileTap', () => { const pushPreviewRoute = vi.fn() const triggerOpenFeedback = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -106,7 +106,7 @@ describe('openMobileTerminalFileTap', () => { const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' } const switchSessionTab = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -153,7 +153,7 @@ describe('openMobileTerminalFileTap', () => { ]) const pushPreviewRoute = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -204,7 +204,7 @@ describe('openMobileTerminalFileTap', () => { const pushPreviewRoute = vi.fn() const triggerOpenFeedback = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -258,7 +258,7 @@ describe('openMobileTerminalFileTap', () => { ]) const openBrowser = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -299,7 +299,7 @@ describe('openMobileTerminalFileTap', () => { ok({ opened: true }) ]) - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -346,7 +346,7 @@ describe('openMobileTerminalFileTap', () => { ]) const openBrowser = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -387,7 +387,7 @@ describe('openMobileTerminalFileTap', () => { let activeTerminalHandle: string | null = 'terminal-1' const pushPreviewRoute = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', @@ -431,6 +431,180 @@ describe('openMobileTerminalFileTap', () => { expect(pushPreviewRoute).not.toHaveBeenCalled() }) + it('reports a failed files.open through onOpenFailed', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }), + { ok: false, error: { message: 'nope' } } + ]) + const onOpenFailed = vi.fn() + + openMobileFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'src/index.ts', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn(), + onOpenFailed + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(onOpenFailed).toHaveBeenCalledTimes(1) + }) + + it('reports an unsupported file when files.open declines it', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'dist/app.zip', + absolutePath: '/repo/dist/app.zip', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'dist/app.zip', + absolutePath: '/repo/dist/app.zip' + } + }), + ok({ worktree: 'wt-1', relativePath: 'dist/app.zip', kind: 'binary', opened: false }) + ]) + const onOpenFailed = vi.fn() + const scheduleDelayedAction = vi.fn() + + openMobileFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'dist/app.zip', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activeTerminalState, + switchSessionTab: vi.fn(), + scheduleDelayedAction, + onOpenFailed + }) + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + + expect(onOpenFailed).toHaveBeenCalledTimes(1) + expect(scheduleDelayedAction).not.toHaveBeenCalled() + }) + + it('does not report a stale failure after a newer tap supersedes it', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: null, + exists: false, + isDirectory: false + }) + ]) + const onOpenFailed = vi.fn() + + openMobileFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'gone/missing.ts', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: (activated) => ({ + ...activeTerminalState(activated), + latestActivationSeq: 2 + }), + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn(), + onOpenFailed + }) + await Promise.resolve() + await Promise.resolve() + + expect(onOpenFailed).not.toHaveBeenCalled() + }) + + it('does not report a failure when the user left the source tab mid-resolve', async () => { + const client = createClient([ + ok({ + worktree: 'wt-1', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts', + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath: 'src/index.ts', + absolutePath: '/repo/src/index.ts' + } + }) + ]) + const onOpenFailed = vi.fn() + + openMobileFileTap({ + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pathText: 'src/index.ts', + line: null, + column: null, + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: (activated) => ({ + ...activeTerminalState(activated), + activeTerminalHandle: 'terminal-2' + }), + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn(), + onOpenFailed + }) + await Promise.resolve() + await Promise.resolve() + + expect(onOpenFailed).not.toHaveBeenCalled() + }) + it('does not activate a worktree file tab after a newer tap supersedes it', async () => { const client = createClient([ ok({ @@ -452,7 +626,7 @@ describe('openMobileTerminalFileTap', () => { const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' } const switchSessionTab = vi.fn() - openMobileTerminalFileTap({ + openMobileFileTap({ client, hostId: 'host-1', worktreeId: 'wt-1', diff --git a/mobile/src/session/mobile-terminal-file-tap-open.ts b/mobile/src/session/mobile-file-tap-open.ts similarity index 78% rename from mobile/src/session/mobile-terminal-file-tap-open.ts rename to mobile/src/session/mobile-file-tap-open.ts index 8d6e681b0f4..00b98ce214d 100644 --- a/mobile/src/session/mobile-terminal-file-tap-open.ts +++ b/mobile/src/session/mobile-file-tap-open.ts @@ -1,4 +1,7 @@ -import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' +import type { + RuntimeFileOpenResult, + RuntimeTerminalPathResolution +} from '../../../src/shared/runtime-types' import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path' import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route' import { classifyMobileArtifact } from './mobile-artifact-kind' @@ -6,12 +9,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { RpcSuccess } from '../transport/types' import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab' -type TerminalFileTapSessionTab = { +export type FileTapSessionTab = { id: string relativePath?: string } -type OpenMobileTerminalFileTapOptions = { +export type OpenMobileFileTapOptions = { client: Pick hostId: string worktreeId: string @@ -37,19 +40,34 @@ type OpenMobileTerminalFileTapOptions = { } switchSessionTab: (tab: T) => void scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown + /** Invoked when the tap cannot open anything (resolve miss, directory, or a + * failed open). Omitted on surfaces that keep the historical silent miss. */ + onOpenFailed?: () => void } -export function openMobileTerminalFileTap( - options: OpenMobileTerminalFileTapOptions +export function openMobileFileTap( + options: OpenMobileFileTapOptions ): void { - void openMobileTerminalFileTapAsync(options).catch(() => { - // Terminal file taps are best-effort: a failed host resolution should leave - // terminal focus/input untouched, matching the existing silent miss behavior. + void openMobileFileTapAsync(options).catch(() => { + // File taps are best-effort: a failed host resolution should leave terminal + // focus/input untouched. Surfaces that want feedback pass onOpenFailed. + reportOpenFailure(options) }) } -async function openMobileTerminalFileTapAsync( - options: OpenMobileTerminalFileTapOptions +function reportOpenFailure( + options: OpenMobileFileTapOptions +): void { + if ( + options.onOpenFailed && + shouldActivateOpenedMobileSessionTab(options.getActivationState(false)) + ) { + options.onOpenFailed() + } +} + +async function openMobileFileTapAsync( + options: OpenMobileFileTapOptions ): Promise { const worktree = `id:${options.worktreeId}` const response = await options.client.sendRequest( @@ -65,12 +83,15 @@ async function openMobileTerminalFileTapAsync( - options: OpenMobileTerminalFileTapOptions, +function scheduleOpenedWorktreeTabActivation( + options: OpenMobileFileTapOptions, openedPath: string ): void { let activated = false 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 e2bf001ffdf..c79878e92d1 100644 --- a/mobile/src/session/mobile-native-chat-open-file.test.ts +++ b/mobile/src/session/mobile-native-chat-open-file.test.ts @@ -1,94 +1,153 @@ import { describe, expect, it, vi } from 'vitest' -import type { RpcClient } from '../transport/rpc-client' -import { - openMobileNativeChatFile, - resolveMobileNativeChatWorktreePath -} from './mobile-native-chat-open-file' +import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file' -describe('resolveMobileNativeChatWorktreePath', () => { - it('resolves an absolute tool path to a worktree-relative open target', async () => { - const sendRequest = vi.fn().mockResolvedValue({ - ok: true, - result: { - exists: true, - isDirectory: false, - openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } - } - }) - await expect( - resolveMobileNativeChatWorktreePath({ - client: { sendRequest } as unknown as RpcClient, - worktreeId: 'worktree', - pathText: '/repo/src/app.ts', - terminal: 'terminal' - }) - ).resolves.toBe('src/app.ts') - expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', { - worktree: 'id:worktree', - pathText: '/repo/src/app.ts', - terminal: 'terminal' - }) +function ok(result: unknown) { + return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function activationState(activated: boolean) { + return { + activated, + activationSeq: 1, + latestActivationSeq: 1, + sourceTerminalHandle: 'terminal-1', + activeTerminalHandle: 'terminal-1', + activeTabType: 'terminal' + } +} + +function baseOptions(client: { sendRequest: ReturnType }) { + return { + client, + hostId: 'host-1', + worktreeId: 'wt-1', + pushPreviewRoute: vi.fn(), + openBrowser: vi.fn(), + triggerOpenFeedback: vi.fn(), + fetchSessionTabs: vi.fn(), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActivationState: activationState, + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn(), + onOpenFailed: vi.fn() + } +} + +function worktreeFileResolution(relativePath: string) { + return ok({ + worktree: 'wt-1', + relativePath, + absolutePath: `/repo/${relativePath}`, + exists: true, + isDirectory: false, + openTarget: { + kind: 'worktree-file', + provider: 'local', + relativePath, + absolutePath: `/repo/${relativePath}` + } + }) +} + +describe('openMobileNativeChatFileTap', () => { + it('resolves against the worktree root: no terminal handle and no cwd', async () => { + const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts')) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' }) + await Promise.resolve() + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: 'src/app.ts' }, + { timeoutMs: 10_000 } + ) }) - it('opens only the resolved worktree-relative target', async () => { - const sendRequest = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - result: { - worktree: 'sibling-worktree', - exists: true, - isDirectory: false, - openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } - } + it('parses a :line:col citation and opens the mobile preview route', async () => { + const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts')) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' }) + await Promise.resolve() + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: 'src/app.ts' }, + { timeoutMs: 10_000 } + ) + expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1) + expect(options.pushPreviewRoute).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/files/preview/[worktreeId]', + params: expect.objectContaining({ + source: 'worktree', + relativePath: 'src/app.ts', + line: '120', + column: '7' }) - .mockResolvedValueOnce({ ok: true, result: {} }) - - await openMobileNativeChatFile({ - client: { sendRequest } as unknown as RpcClient, - worktreeId: 'worktree', - pathText: '../repo/src/app.ts', - terminal: 'terminal' - }) - - expect(sendRequest).toHaveBeenLastCalledWith('files.open', { - worktree: 'id:sibling-worktree', - relativePath: 'src/app.ts' }) + expect(options.onOpenFailed).not.toHaveBeenCalled() }) - it('resolves null when the resolve request rejects', async () => { - const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out')) - await expect( - resolveMobileNativeChatWorktreePath({ - client: { sendRequest } as unknown as RpcClient, - worktreeId: 'worktree', - pathText: 'src/app.ts', - terminal: null + it('surfaces a resolve miss instead of a silent no-op', async () => { + const sendRequest = vi.fn(async () => + ok({ + worktree: 'wt-1', + relativePath: null, + absolutePath: null, + exists: false, + isDirectory: false }) - ).resolves.toBeNull() + ) + const options = baseOptions({ sendRequest }) + + openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' }) + await Promise.resolve() + await Promise.resolve() + + expect(options.onOpenFailed).toHaveBeenCalledTimes(1) + expect(options.pushPreviewRoute).not.toHaveBeenCalled() + expect(options.triggerOpenFeedback).not.toHaveBeenCalled() }) - it('does not reject when the open request fails', async () => { - const sendRequest = vi - .fn() - .mockResolvedValueOnce({ - ok: true, - result: { - exists: true, - isDirectory: false, - openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' } - } - }) - .mockRejectedValueOnce(new Error('connection interrupted')) + it('surfaces a rejected resolve request', async () => { + const sendRequest = vi.fn(async () => { + throw new Error('Request timed out') + }) + const options = baseOptions({ sendRequest }) - await expect( - openMobileNativeChatFile({ - client: { sendRequest } as unknown as RpcClient, - worktreeId: 'worktree', - pathText: 'src/app.ts', - terminal: null - }) - ).resolves.toBeUndefined() + openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' }) + await Promise.resolve() + await Promise.resolve() + + expect(options.onOpenFailed).toHaveBeenCalledTimes(1) + }) + + it('opens a plain path through files.open with tab activation', async () => { + const responses: unknown[] = [worktreeFileResolution('src/app.ts'), ok({ opened: true })] + const sendRequest = vi.fn(async () => responses.shift()) + const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' } + const switchSessionTab = vi.fn() + const options = { + ...baseOptions({ sendRequest }), + getSessionTabs: () => [openedTab], + getActiveSessionTabId: () => 'terminal-tab', + switchSessionTab, + scheduleDelayedAction: vi.fn((callback: () => void) => callback()) + } + + openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' }) + await Promise.resolve() + await Promise.resolve() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(sendRequest).toHaveBeenCalledWith( + 'files.open', + { worktree: 'id:wt-1', relativePath: 'src/app.ts' }, + { timeoutMs: 15_000 } + ) + expect(switchSessionTab).toHaveBeenCalledWith(openedTab) + expect(options.onOpenFailed).not.toHaveBeenCalled() }) }) diff --git a/mobile/src/session/mobile-native-chat-open-file.ts b/mobile/src/session/mobile-native-chat-open-file.ts index 6460c82849c..3de6755b4b3 100644 --- a/mobile/src/session/mobile-native-chat-open-file.ts +++ b/mobile/src/session/mobile-native-chat-open-file.ts @@ -1,67 +1,30 @@ -import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types' -import type { RpcClient } from '../transport/rpc-client' +import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection' +import { + openMobileFileTap, + type FileTapSessionTab, + type OpenMobileFileTapOptions +} from './mobile-file-tap-open' -type MobileNativeChatWorktreeTarget = { - worktreeId: string - relativePath: string -} +export type OpenMobileNativeChatFileTapOptions = Omit< + OpenMobileFileTapOptions, + 'terminalHandle' | 'cwd' | 'line' | 'column' +> -async function resolveMobileNativeChatWorktreeTarget(args: { - client: RpcClient - worktreeId: string - pathText: string - terminal: string | null -}): Promise { - try { - const response = await args.client.sendRequest('files.resolveTerminalPath', { - worktree: `id:${args.worktreeId}`, - pathText: args.pathText, - ...(args.terminal ? { terminal: args.terminal } : {}) - }) - if (!response.ok) { - return null - } - const resolved = response.result as RuntimeTerminalPathResolution - if (!resolved.exists || resolved.isDirectory) { - return null - } - const relativePath = - resolved.openTarget?.kind === 'worktree-file' - ? resolved.openTarget.relativePath - : (resolved.relativePath ?? null) - return relativePath - ? { worktreeId: resolved.worktree?.trim() || args.worktreeId, relativePath } - : null - } catch { - 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 target = await resolveMobileNativeChatWorktreeTarget(args) - if (target) { - try { - await args.client.sendRequest('files.open', { - worktree: `id:${target.worktreeId}`, - relativePath: target.relativePath - }) - } catch { - // Best-effort open; failures surface as a no-op rather than an - // unhandled rejection. - } - } +/** + * Open a file reference tapped in native chat: same haptic / preview-route / + * tab-activation flow as terminal taps, but chat paths are worktree-root + * relative (or absolute), so resolution deliberately passes no terminal handle + * and no cwd — a terminal's live cwd (e.g. `/mobile`) would misplace + * them. Agent-style `path:line(:col)` citations carry their location through. + */ +export function openMobileNativeChatFileTap( + options: OpenMobileNativeChatFileTapOptions +): void { + const { path, line, column } = splitFilePathLineSuffix(options.pathText) + openMobileFileTap({ + ...options, + pathText: path, + line, + column + }) } 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-stream-frame.test.ts b/mobile/src/session/mobile-native-chat-stream-frame.test.ts index 8d7d1e7e07a..b4ffe542266 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.test.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.test.ts @@ -32,7 +32,8 @@ describe('applyMobileNativeChatStreamFrame', () => { kind: 'messages', messages: [message('a'), message('b')], hasMore: true, - beforeOffset: 123 + beforeOffset: 123, + windowReplaced: true }) }) @@ -42,7 +43,12 @@ describe('applyMobileNativeChatStreamFrame', () => { const result = applyMobileNativeChatStreamFrame({ merger, - frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] }, + frame: { + type: 'snapshot', + messages: [message('b'), message('c'), message('d')], + hasMore: true, + beforeOffset: 20 + }, limit: 3, replaceSnapshot: false }) @@ -50,7 +56,207 @@ describe('applyMobileNativeChatStreamFrame', () => { expect(result).toMatchObject({ kind: 'messages', messages: [message('b'), message('c'), message('d')], - cursorInvalidated: true + cursorInvalidated: true, + hasMore: true + }) + }) + + it('refreshes paging metadata when a replay still starts at the retained oldest row', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('a'), message('b')]) + + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: false, + beforeOffset: 0 + }, + limit: 40, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: [message('a'), message('b')], + hasMore: false, + beforeOffset: 0 + }) + }) + + it('keeps paged-in history when a reconnect replay overlaps it', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message)) + + const result = applyMobileNativeChatStreamFrame({ + merger, + // Replayed window: the retained tail plus what arrived while disconnected. + frame: { type: 'snapshot', messages: [message('a'), message('b'), message('c')] }, + limit: 100, + replaceSnapshot: false + }) + + expect(result).toEqual({ + kind: 'messages', + messages: ['p1', 'p2', 'a', 'b', 'c'].map(message) + }) + }) + + it('ignores replay paging metadata that describes a row other than our oldest', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message)) + + // `beforeOffset` here points before 'a', not before 'p1'. Adopting it would + // make the next loadEarlier re-fetch 'p1'/'p2' and prepend them twice. + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('a'), message('b')], + hasMore: true, + beforeOffset: 500 + }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ kind: 'messages', messages: ['p1', 'p2', 'a', 'b'].map(message) }) + }) + + it('replaces the window when a reconnect replay is disjoint from history', () => { + const merger = createNativeChatMerger() + replaceList(merger, [message('old-1'), message('old-2')]) + + // A long outage (or compaction while away) cannot be stitched without a gap. + const result = applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('fresh-1'), message('fresh-2')], + hasMore: true, + beforeOffset: 900 + }, + limit: 100, + replaceSnapshot: false + }) + + expect(result).toEqual({ + kind: 'messages', + messages: [message('fresh-1'), message('fresh-2')], + hasMore: true, + beforeOffset: 900, + windowReplaced: true + }) + }) + + it('replaces a partially overlapping replay that no longer extends the retained tail', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message)) + + // `hasMore: true` so the authoritative-removal rule can't short-circuit — + // the contiguity scan itself must reject the interleaved new message. + const replay = [message('shared'), message('compacted-summary'), message('old-tail')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 9, + windowReplaced: true + }) + }) + + it('replaces a replay that repeats retained ids out of order', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['a', 'b', 'c'].map(message)) + + const replay = [message('a'), message('c'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 4 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 4, + windowReplaced: true + }) + }) + + it('replaces a replay that stops short of the retained newest row', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['a', 'b', 'c'].map(message)) + + // Merging would keep 'c', a row the replayed window no longer carries. + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 1 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: true, + beforeOffset: 1, + windowReplaced: true + }) + }) + + it('replaces retained history when replay metadata says no earlier rows remain', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message)) + + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: false, + beforeOffset: 0, + windowReplaced: true + }) + }) + + it('replaces retained history when a single row precedes an authoritative replay', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['removed-1', 'a', 'b'].map(message)) + + // Boundary of the `firstIndex > 0` rule: one dropped row is still a dropped + // row, and merging here would strand 'removed-1' the host no longer has. + const replay = [message('a'), message('b')] + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 }, + limit: 100, + replaceSnapshot: false + }) + ).toEqual({ + kind: 'messages', + messages: replay, + hasMore: false, + beforeOffset: 0, + windowReplaced: true }) }) @@ -79,7 +285,39 @@ describe('applyMobileNativeChatStreamFrame', () => { limit: 40, replaceSnapshot: false }) - ).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false }) + ).toEqual({ + kind: 'messages', + messages: [message('new')], + hasMore: false, + windowReplaced: true + }) + }) + + it('still treats the base snapshot as authoritative after a replacement frame', () => { + const merger = createNativeChatMerger() + replaceList(merger, ['a', 'b'].map(message)) + + // A replacement is not this subscription's base snapshot, so the first real + // snapshot still replaces — merging would keep 'a', which it does not carry. + expect( + applyMobileNativeChatStreamFrame({ + merger, + frame: { + type: 'snapshot', + messages: [message('b'), message('c')], + hasMore: true, + beforeOffset: 77 + }, + limit: 100, + replaceSnapshot: true + }) + ).toEqual({ + kind: 'messages', + messages: [message('b'), message('c')], + hasMore: true, + beforeOffset: 77, + windowReplaced: true + }) }) it('surfaces snapshot errors and ignores unrelated frames', () => { diff --git a/mobile/src/session/mobile-native-chat-stream-frame.ts b/mobile/src/session/mobile-native-chat-stream-frame.ts index 690994f02f3..bef3dc5ef27 100644 --- a/mobile/src/session/mobile-native-chat-stream-frame.ts +++ b/mobile/src/session/mobile-native-chat-stream-frame.ts @@ -19,10 +19,47 @@ export type AppliedMobileNativeChatFrame = hasMore?: boolean beforeOffset?: number cursorInvalidated?: boolean + /** The frame replaced the whole retained window (replacement, first + * snapshot, or a replay snapshot disjoint from local history) — the + * caller must reset its paging window/cursor to the frame's. */ + windowReplaced?: boolean } +function replayRetainedTailStart( + merger: NativeChatMerger, + messages: readonly NativeChatMessage[], + hasMore: boolean | undefined +): number | null { + const firstIndex = messages[0] ? merger.indexById.get(messages[0].id) : undefined + if (firstIndex === undefined) { + return null + } + // `hasMore: false` is authoritative: retained rows before the replay window + // were removed while disconnected, even when the newest IDs still match. + if (hasMore === false && firstIndex > 0) { + return null + } + let expectedIndex = firstIndex + let sawNewMessage = false + for (const message of messages) { + const existingIndex = merger.indexById.get(message.id) + if (existingIndex === undefined) { + sawNewMessage = true + } else if (sawNewMessage || existingIndex !== expectedIndex) { + return null + } else { + expectedIndex += 1 + } + } + return expectedIndex === merger.list.length ? firstIndex : null +} + /** Applies runtime stream frames while preserving the initial-snapshot versus - * reconnect-replay distinction owned by the session hook. */ + * reconnect-replay distinction owned by the session hook. A replay snapshot + * that extends a contiguous retained tail merges in by id — paged-in history + * survives a socket blip instead of collapsing to the replayed window. A + * discontinuous replay (long outage, compaction while away) can't be stitched + * without a gap, so it falls back to the fresh authoritative window. */ export function applyMobileNativeChatStreamFrame(args: { merger: NativeChatMerger frame: MobileNativeChatStreamFrame @@ -42,22 +79,39 @@ export function applyMobileNativeChatStreamFrame(args: { if (!Array.isArray(frame.messages)) { return { kind: 'ignored' } } - if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) { + const replayStartIndex = + frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0 + ? replayRetainedTailStart(merger, frame.messages, frame.hasMore) + : null + if (frame.type === 'replacement' || (frame.type === 'snapshot' && replayStartIndex === null)) { replaceList(merger, frame.messages) return { kind: 'messages', messages: merger.list, hasMore: frame.hasMore, + windowReplaced: true, ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) } } const previousFirstId = merger.list[0]?.id const messages = applyAppend(merger, frame.messages, limit) + const cursorInvalidated = Boolean(previousFirstId && messages[0]?.id !== previousFirstId) + const replayStillStartsAtOldest = frame.type === 'snapshot' && replayStartIndex === 0 return { kind: 'messages', messages, // Why: once the bounded live window drops its oldest row, the snapshot's // byte cursor no longer describes the oldest retained message. - ...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {}) + ...(cursorInvalidated ? { cursorInvalidated: true } : {}), + // A trimmed replay creates page-able history even if the prior window had + // none; otherwise only a replay sharing our oldest row owns its metadata. + ...(frame.type === 'snapshot' && cursorInvalidated + ? { hasMore: true } + : replayStillStartsAtOldest + ? { + ...(frame.hasMore == null ? {} : { hasMore: frame.hasMore }), + ...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }) + } + : {}) } } 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-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 79b0c1264e4..e97af9df383 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -1,19 +1,63 @@ import { readFileSync } from 'node:fs' -import { describe, expect, it } from 'vitest' -import { createMobileSessionHref } from './mobile-session-route' +import { describe, expect, it, vi } from 'vitest' +import { mobileSessionRouteTarget } from './mobile-session-route' +import { + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackNavigationState +} from '../navigation/host-stack-navigation' const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8') +function navigationHarness(initialState: HostStackNavigationState) { + const stateListeners = new Set<() => void>() + let state = initialState + const navigation = { + addListener: vi.fn((_event: 'state', listener: () => void) => { + stateListeners.add(listener) + return () => stateListeners.delete(listener) + }), + dispatch: vi.fn(), + getState: () => state + } + return { + navigation, + setState(nextState: HostStackNavigationState) { + state = nextState + for (const listener of stateListeners) { + listener() + } + } + } +} + +function mountedHostState(hostId: string): HostStackNavigationState { + return { + index: 1, + routes: [ + { name: 'index' }, + { + name: 'h', + state: { + key: '/h', + index: 0, + routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId } }] + } + } + ] + } +} + describe('mobile session route', () => { - it('keeps dynamic route identities raw for Expo Router to encode', () => { + it('keeps dynamic route identities raw for the navigator to encode', () => { expect( - createMobileSessionHref({ + mobileSessionRouteTarget({ hostId: 'host/one', worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1', name: 'Fix #1' }) ).toEqual({ - pathname: '/h/[hostId]/session/[worktreeId]', + name: '[hostId]/session/[worktreeId]', params: { hostId: 'host/one', worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1', @@ -22,14 +66,47 @@ describe('mobile session route', () => { }) }) - it('routes the home Resume card through the typed dynamic href', () => { + it('omits an absent workspace name instead of sending an empty param', () => { + expect( + mobileSessionRouteTarget({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' }).params + ).toEqual({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' }) + }) + + it('mounts the host before replacing it with the session route', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const push = vi.fn() + const target = mobileSessionRouteTarget({ + hostId: 'host/one', + worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1', + name: 'Fix #1' + }) + + navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + + expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + + harness.setState(mountedHostState('host/one')) + + expect(harness.navigation.dispatch).toHaveBeenCalledWith({ + type: 'REPLACE', + target: '/h', + source: 'host-index', + payload: target + }) + }) + + it('routes the home Resume card through the cold-navigator-safe transition', () => { const start = homeSource.indexOf('{/* ─── Resume card ─── */}') const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start) - const resumeCard = homeSource.slice(start, end) + // Assert the markers first: a renamed banner would otherwise slice garbage and + // report a missing call instead of the real cause. expect(start).toBeGreaterThanOrEqual(0) expect(end).toBeGreaterThan(start) - expect(resumeCard).toContain('createMobileSessionHref({') - expect(resumeCard).not.toContain('encodeURIComponent(resumeWorktree.worktree.worktreeId)') + + const resumeCard = homeSource.slice(start, end) + expect(resumeCard).toContain('openMobileSession({') + expect(resumeCard).not.toContain('router.push(') }) }) diff --git a/mobile/src/session/mobile-session-route.ts b/mobile/src/session/mobile-session-route.ts index 505e53bfb85..66f5c66f76f 100644 --- a/mobile/src/session/mobile-session-route.ts +++ b/mobile/src/session/mobile-session-route.ts @@ -1,17 +1,20 @@ +import type { HostStackRouteTarget } from '../navigation/host-stack-navigation' + export type MobileSessionRouteParams = { hostId: string worktreeId: string name?: string } -export type MobileSessionHref = { - pathname: '/h/[hostId]/session/[worktreeId]' - params: MobileSessionRouteParams -} - -export function createMobileSessionHref(params: MobileSessionRouteParams): MobileSessionHref { +/** Identities stay raw — the navigator owns the params, so pre-encoding a + * workspace id would reach the session screen still escaped. */ +export function mobileSessionRouteTarget({ + hostId, + worktreeId, + name +}: MobileSessionRouteParams): HostStackRouteTarget { return { - pathname: '/h/[hostId]/session/[worktreeId]', - params + name: '[hostId]/session/[worktreeId]', + params: name ? { hostId, worktreeId, name } : { hostId, worktreeId } } } diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts new file mode 100644 index 00000000000..a795d8d9ed6 --- /dev/null +++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts @@ -0,0 +1,134 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers' + +const push = vi.fn() + +vi.mock('expo-router', () => ({ useRouter: () => ({ push }) })) +vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() })) + +type Handlers = ReturnType + +function ok(result: unknown) { + return { ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +describe('useMobileFileTapHandlers', () => { + let renderer: ReactTestRenderer | null = null + let handlers: Handlers | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + push.mockClear() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + handlers = null + }) + + function createOptions(sendRequest: ReturnType) { + return { + client: { sendRequest }, + hostId: 'host-1', + worktreeId: 'wt-1', + worktreeName: 'Orca', + activeHandleRef: { current: 'terminal-1' as string | null }, + terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) }, + openBrowser: vi.fn(), + fetchSessionTabs: vi.fn(async () => {}), + getSessionTabs: () => [], + getActiveSessionTabId: () => null, + getActiveSessionTabType: () => 'terminal', + switchSessionTab: vi.fn(), + scheduleDelayedAction: vi.fn(), + reportChatTapFailure: vi.fn() + } + } + + function Harness({ options }: { options: ReturnType }): null { + handlers = useMobileFileTapHandlers(options) + return null + } + + it('keeps handler identities stable across rerenders', () => { + const options = createOptions(vi.fn()) + act(() => { + renderer = create(createElement(Harness, { options })) + }) + const first = handlers + act(() => { + renderer!.update(createElement(Harness, { options: { ...options } })) + }) + expect(handlers!.handleFileTap).toBe(first!.handleFileTap) + expect(handlers!.handleNativeChatFileTap).toBe(first!.handleNativeChatFileTap) + }) + + it('dispatches through the latest options after a rerender', () => { + const firstSendRequest = vi.fn() + const firstOptions = createOptions(firstSendRequest) + act(() => { + renderer = create(createElement(Harness, { options: firstOptions })) + }) + + const latestSendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false })) + act(() => { + renderer!.update( + createElement(Harness, { + options: { ...firstOptions, client: { sendRequest: latestSendRequest } } + }) + ) + }) + handlers!.handleFileTap('terminal-1', 'index.ts', null, null) + + expect(firstSendRequest).not.toHaveBeenCalled() + expect(latestSendRequest).toHaveBeenCalledTimes(1) + }) + + it('resolves terminal taps with the terminal handle and cwd', async () => { + const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false })) + act(() => { + renderer = create(createElement(Harness, { options: createOptions(sendRequest) })) + }) + + handlers!.handleFileTap('terminal-1', 'index.ts', null, null) + await act(async () => {}) + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'terminal-1', cwd: '/repo/sub' }, + { timeoutMs: 10_000 } + ) + }) + + it('ignores terminal taps from a non-active handle', () => { + const sendRequest = vi.fn() + act(() => { + renderer = create(createElement(Harness, { options: createOptions(sendRequest) })) + }) + + handlers!.handleFileTap('terminal-2', 'index.ts', null, null) + + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('resolves chat taps against the worktree root and reports a miss', async () => { + const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false })) + const options = createOptions(sendRequest) + act(() => { + renderer = create(createElement(Harness, { options })) + }) + + handlers!.handleNativeChatFileTap('mobile/src/x.ts:12') + await act(async () => {}) + + expect(sendRequest).toHaveBeenCalledWith( + 'files.resolveTerminalPath', + { worktree: 'id:wt-1', pathText: 'mobile/src/x.ts' }, + { timeoutMs: 10_000 } + ) + expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12") + }) +}) diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts new file mode 100644 index 00000000000..276be5b566f --- /dev/null +++ b/mobile/src/session/use-mobile-file-tap-handlers.ts @@ -0,0 +1,174 @@ +import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react' +import { useRouter } from 'expo-router' +import { triggerSelection } from '../platform/haptics' +import type { RpcClient } from '../transport/rpc-client' +import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open' +import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file' + +type MobileFileTapHandlerOptions = { + client: Pick | null + hostId: string + worktreeId: string + worktreeName?: string + activeHandleRef: MutableRefObject + terminalCwdRef: MutableRefObject> + openBrowser: (url: string) => void + fetchSessionTabs: () => Promise + getSessionTabs: () => readonly T[] + getActiveSessionTabId: () => string | null + getActiveSessionTabType: () => string | null + switchSessionTab: (tab: T) => void + scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown + reportChatTapFailure: (message: string) => void +} + +/** + * Tap-to-open handlers for file references, shared by the terminal (link taps + * with the terminal's cwd) and native chat (worktree-root-relative paths, with + * failure feedback). Handlers are identity-stable and read the latest options at + * dispatch time; the shared activation seq lets a newer tap on either surface + * supersede an in-flight one. + */ +export function useMobileFileTapHandlers( + options: MobileFileTapHandlerOptions +): { + handleFileTap: ( + handle: string, + pathText: string, + line: number | null, + column: number | null + ) => void + handleNativeChatFileTap: (pathText: string) => void +} { + const { + activeHandleRef, + client, + fetchSessionTabs, + getActiveSessionTabId, + getActiveSessionTabType, + getSessionTabs, + hostId, + openBrowser, + scheduleDelayedAction, + reportChatTapFailure, + switchSessionTab, + terminalCwdRef, + worktreeId, + worktreeName + } = options + const router = useRouter() + const routerRef = useRef(router) + const optionsRef = useRef(options) + const activationSeqRef = useRef(0) + + useLayoutEffect(() => { + routerRef.current = router + optionsRef.current = { + activeHandleRef, + client, + fetchSessionTabs, + getActiveSessionTabId, + getActiveSessionTabType, + getSessionTabs, + hostId, + openBrowser, + scheduleDelayedAction, + reportChatTapFailure, + switchSessionTab, + terminalCwdRef, + worktreeId, + worktreeName + } + }, [ + activeHandleRef, + client, + fetchSessionTabs, + getActiveSessionTabId, + getActiveSessionTabType, + getSessionTabs, + hostId, + openBrowser, + router, + scheduleDelayedAction, + reportChatTapFailure, + switchSessionTab, + terminalCwdRef, + worktreeId, + worktreeName + ]) + + const handleFileTap = useCallback( + (handle: string, pathText: string, line: number | null, column: number | null) => { + const current = optionsRef.current + if (handle !== current.activeHandleRef.current || !current.client) { + return + } + const activationSeq = ++activationSeqRef.current + openMobileFileTap({ + client: current.client, + hostId: current.hostId, + worktreeId: current.worktreeId, + worktreeName: current.worktreeName, + terminalHandle: handle, + pathText, + cwd: current.terminalCwdRef.current.get(handle) ?? null, + line, + column, + pushPreviewRoute: (href) => routerRef.current.push(href), + openBrowser: current.openBrowser, + triggerOpenFeedback: triggerSelection, + fetchSessionTabs: current.fetchSessionTabs, + getSessionTabs: current.getSessionTabs, + getActiveSessionTabId: current.getActiveSessionTabId, + getActivationState: (activated) => ({ + activated, + activationSeq, + latestActivationSeq: activationSeqRef.current, + sourceTerminalHandle: handle, + activeTerminalHandle: current.activeHandleRef.current, + activeTabType: current.getActiveSessionTabType() + }), + switchSessionTab: current.switchSessionTab, + scheduleDelayedAction: current.scheduleDelayedAction + }) + }, + [] + ) + + const handleNativeChatFileTap = useCallback((pathText: string) => { + const current = optionsRef.current + // The chat overlay rides on its backing terminal tab; that handle anchors + // the activation gate even though resolution ignores the terminal's cwd. + const sourceTerminalHandle = current.activeHandleRef.current + if (!current.client || !sourceTerminalHandle) { + return + } + const activationSeq = ++activationSeqRef.current + openMobileNativeChatFileTap({ + client: current.client, + hostId: current.hostId, + worktreeId: current.worktreeId, + worktreeName: current.worktreeName, + pathText, + pushPreviewRoute: (href) => routerRef.current.push(href), + openBrowser: current.openBrowser, + triggerOpenFeedback: triggerSelection, + fetchSessionTabs: current.fetchSessionTabs, + getSessionTabs: current.getSessionTabs, + getActiveSessionTabId: current.getActiveSessionTabId, + getActivationState: (activated) => ({ + activated, + activationSeq, + latestActivationSeq: activationSeqRef.current, + sourceTerminalHandle, + activeTerminalHandle: current.activeHandleRef.current, + activeTabType: current.getActiveSessionTabType() + }), + switchSessionTab: current.switchSessionTab, + scheduleDelayedAction: current.scheduleDelayedAction, + onOpenFailed: () => current.reportChatTapFailure(`Couldn't open ${pathText}`) + }) + }, []) + + return { handleFileTap, handleNativeChatFileTap } +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index 8f22884145e..b296ea4851b 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -11,7 +11,13 @@ import { markMobileNativeChatInputStale, resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input' +import { + acquireMobileNativeChatTerminalWrite, + releaseMobileNativeChatTerminalWrite, + resetMobileNativeChatTerminalWritesForTests +} from './mobile-native-chat-terminal-write-lock' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' type AnswerSend = ReturnType @@ -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..ad8adce189c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,10 +1,4 @@ -import { - useCallback, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' @@ -16,7 +10,6 @@ import { import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' import { detectAgentPermission } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' -import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' @@ -49,10 +42,13 @@ 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 - handleNativeChatOpenFile: (relativePath: string) => void handleNativeChatAnswerAsk: ( prompt: AskPrompt, selections: AskAnswerSelection[] @@ -124,7 +120,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 +161,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( @@ -176,21 +180,6 @@ export function useMobileNativeChatController(args: { messages: nativeChatSession.messages }) - const handleNativeChatOpenFile = useCallback( - (pathText: string) => { - if (!client) { - return - } - void openMobileNativeChatFile({ - client, - worktreeId, - pathText, - terminal: activeHandleRef.current - }) - }, - [activeHandleRef, client, worktreeId] - ) - // Every chat write gates on both: the lease proves the input floor is ours, and // `connState` collapses a render before the lease does on disconnect. const inputSendable = nativeChatInputLeaseReady && connState === 'connected' @@ -273,10 +262,11 @@ export function useMobileNativeChatController(args: { nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, + nativeChatStreamLive, + nativeChatStreamScopeKey: streamScopeKey, nativeChatPermission, nativeChatQuestion, nativeChatAsk, - handleNativeChatOpenFile, handleNativeChatAnswerAsk: answerAsk, handleNativeChatCancelAsk: cancelAsk, handleNativeChatRespondPermission: respond, 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-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 3c12630690b..844c311b827 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -174,6 +174,221 @@ describe('useMobileNativeChatSession', () => { } ) + it('keeps paged-in history across an auto-reconnect replay snapshot', async () => { + // The transport replays the subscription with its original params after an + // in-place reconnect, so the replayed snapshot is the newest initial window + // again. It must merge into the grown history, not truncate it back to 40. + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)), + hasMore: false, + beforeOffset: 40 + } + }) + const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + expect(state?.messages).toHaveLength(100) + + // Reconnect replay: the same newest-40 window. History survives untouched. + await act(async () => + emit({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 }) + ) + expect(state?.messages).toHaveLength(100) + expect(state?.messages[0]?.id).toBe('paged-0') + + // A replay carrying one message that arrived while away merges it in; the + // grown window stays bounded, so only the single oldest row trims. + await act(async () => + emit({ + type: 'snapshot', + messages: [...window, message('live-1')], + hasMore: true, + beforeOffset: 100 + }) + ) + expect(state?.messages).toHaveLength(100) + expect(state?.messages[0]?.id).toBe('paged-1') + expect(state?.messages.at(-1)?.id).toBe('live-1') + }) + + it('enables paging when a replay trims a window that previously had no earlier rows', async () => { + // Never settles: this asserts the request the replay's cursor produces. + const sendRequest = vi.fn(() => new Promise(() => {})) + const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + + await act(async () => + emit({ + type: 'snapshot', + messages: [...window.slice(1), message('live-1')], + hasMore: true, + beforeOffset: 10 + }) + ) + + expect(state?.messages[0]?.id).toBe('win-1') + expect(state?.hasMore).toBe(true) + act(() => state?.loadEarlier()) + expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + }) + + it('drops paged rows that an authoritative replay says were removed', async () => { + const sendRequest = vi.fn().mockResolvedValue({ + ok: true, + result: { + messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)), + hasMore: false, + beforeOffset: 40 + } + }) + const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + await act(async () => { + state?.loadEarlier() + await Promise.resolve() + }) + expect(state?.messages).toHaveLength(100) + + await act(async () => + emit({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 }) + ) + + expect(state?.messages).toEqual(window) + expect(state?.hasMore).toBe(false) + }) + + it('clears a stale cursor when a replacement omits paging metadata', async () => { + // Never settles: this asserts the request the cleared cursor produces. + const sendRequest = vi.fn(() => new Promise(() => {})) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + + await act(async () => + emit({ + type: 'replacement', + messages: Array.from({ length: 40 }, (_unused, index) => message(`new-${index}`)) + }) + ) + act(() => state?.loadEarlier()) + + expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', { + agent: 'claude', + sessionId: 'session', + limit: 100 + }) + }) + + it('clears hasMore when a replaced window is shorter than the initial page', async () => { + // A replaced window without paging metadata is judged by its own length: + // shorter than a full page means the whole transcript is on screen. + const sendRequest = vi.fn() + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ + type: 'snapshot', + messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)), + hasMore: true, + beforeOffset: 100 + }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + expect(state?.hasMore).toBe(true) + + await act(async () => emit({ type: 'replacement', messages: [message('only')] })) + + expect(state?.hasMore).toBe(false) + }) + + it('fences an in-flight older page when a merging replay re-cuts the byte cursor', async () => { + let resolveEarlier: (response: unknown) => void = () => {} + const sendRequest = vi.fn( + () => new Promise((resolve) => (resolveEarlier = resolve)) + ) as unknown as RpcClient['sendRequest'] + const retained = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)) + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + onData({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 100 }) + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + act(() => state?.loadEarlier()) + + // The replay merges (same rows, no trim), but the host re-cut the file, so + // it carries a new cursor. The page already in flight was addressed with the + // old offset and would write that stale cursor back over the fresh one. + await act(async () => + emit({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 250 }) + ) + await act(async () => { + resolveEarlier({ + ok: true, + result: { messages: [message('stale-page')], hasMore: true, beforeOffset: 40 } + }) + await Promise.resolve() + }) + + expect(state?.messages.some((entry) => entry.id === 'stale-page')).toBe(false) + expect(state?.loadingEarlier).toBe(false) + }) + + it('keeps the base snapshot authoritative when a live append arrives first', async () => { + const sendRequest = vi.fn() + const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => { + emit = onData + return () => {} + }) + await mount({ sendRequest, subscribe } as unknown as RpcClient) + // Only a snapshot marks the base as delivered, so an append landing first + // must not demote the real base snapshot to a reconnect replay. + await act(async () => + emit({ type: 'appended', messages: [message('early-a'), message('early-b')] }) + ) + await act(async () => + emit({ + type: 'snapshot', + messages: [message('early-b'), message('base-c')], + hasMore: true, + beforeOffset: 3 + }) + ) + + expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c']) + }) + it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => { let resolveCursorPage: (response: unknown) => void = () => {} const sendRequest = vi diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 9c739ce49ef..56db7def584 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -92,6 +92,9 @@ export function useMobileNativeChatSession(args: { const sessionIdRef = useRef(sessionId) sessionIdRef.current = sessionId const streamGenerationRef = useRef(0) + // Whether this subscription already delivered its base snapshot; later + // snapshots on the same subscription are reconnect replays, not fresh bases. + const snapshotSeenRef = useRef(false) // Replace the base list (read results are an ordered tail). Resets the merger // cache so the index is rebuilt once over the new base. @@ -107,6 +110,7 @@ export function useMobileNativeChatSession(args: { streamGenerationRef.current += 1 limitRef.current = INITIAL_LIMIT loadingEarlierRef.current = false + snapshotSeenRef.current = false setLoadingEarlier(false) setList([]) setError(undefined) @@ -133,20 +137,11 @@ export function useMobileNativeChatSession(args: { return } const frame = raw as MobileNativeChatStreamFrame - if (frame.type === 'replacement' || frame.type === 'snapshot') { - // Why: replacement and reconnect snapshots are authoritative windows; - // stale page limits/results must not constrain the fresh generation. - streamGenerationRef.current += 1 - limitRef.current = INITIAL_LIMIT - loadingEarlierRef.current = false - setLoadingEarlier(false) - } - const replaceSnapshot = frame.type === 'snapshot' const applied = applyMobileNativeChatStreamFrame({ merger: mergerRef.current, frame, limit: limitRef.current, - replaceSnapshot + replaceSnapshot: !snapshotSeenRef.current }) if (applied.kind === 'ignored') { return @@ -156,11 +151,28 @@ export function useMobileNativeChatSession(args: { setError(applied.error) return } + if (frame.type === 'snapshot') { + snapshotSeenRef.current = true + } + if (applied.windowReplaced || frame.type === 'snapshot') { + // Why: any authoritative window (and any replay merge) invalidates an + // in-flight older-page request; stale results must not land on it. + streamGenerationRef.current += 1 + loadingEarlierRef.current = false + setLoadingEarlier(false) + } + if (applied.windowReplaced) { + // Only a genuinely fresh window resets the grown read window — an + // overlapping reconnect replay keeps the paged-in history and limit. + limitRef.current = INITIAL_LIMIT + beforeOffsetRef.current = applied.beforeOffset ?? null + setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT) + } setMessages(applied.messages) - if (applied.hasMore != null) { + if (!applied.windowReplaced && applied.hasMore != null) { setHasMore(applied.hasMore) } - if (applied.beforeOffset != null) { + if (!applied.windowReplaced && applied.beforeOffset != null) { beforeOffsetRef.current = applied.beforeOffset } if (applied.cursorInvalidated) { 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/session/use-open-mobile-session.ts b/mobile/src/session/use-open-mobile-session.ts new file mode 100644 index 00000000000..249c9924cb6 --- /dev/null +++ b/mobile/src/session/use-open-mobile-session.ts @@ -0,0 +1,14 @@ +import { useCallback } from 'react' +import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import { mobileSessionRouteTarget, type MobileSessionRouteParams } from './mobile-session-route' + +export function useOpenMobileSession(): (params: MobileSessionRouteParams) => void { + const openHostStackRoute = useOpenHostStackRoute() + + return useCallback( + (params) => { + openHostStackRoute(params.hostId, mobileSessionRouteTarget(params)) + }, + [openHostStackRoute] + ) +} diff --git a/mobile/src/tasks/mobile-task-navigation.ts b/mobile/src/tasks/mobile-task-navigation.ts index 3537d517106..f1dc733878e 100644 --- a/mobile/src/tasks/mobile-task-navigation.ts +++ b/mobile/src/tasks/mobile-task-navigation.ts @@ -1,72 +1,36 @@ +import { + coordinateHostStackNavigation, + hostStackHostRoute, + navigateToHostStackRoute, + type HostStackHostRoute, + type HostStackNavigationController, + type HostStackNavigationState, + type HostStackRootNavigation, + type HostStackRouteTarget, + type HostStackRouter, + type PendingHostStackNavigation +} from '../navigation/host-stack-navigation' import type { TaskProvider } from './mobile-task-providers' -export type MobileTasksHostRoute = `/h/${string}` - -export type MobileTasksNavigationState = Readonly<{ - key?: string - index: number - routes: readonly MobileTasksNavigationRoute[] -}> - -export type MobileTasksNavigationRoute = Readonly<{ - key?: string - name: string - params?: Readonly<{ hostId?: unknown }> - state?: MobileTasksNavigationState -}> - -export type MobileTasksRootNavigation = { - addListener: (event: 'state', listener: () => void) => () => void - dispatch: (action: MobileTasksHostReplaceAction) => void - getState: () => MobileTasksNavigationState -} - -export type MobileTasksHostReplaceAction = Readonly<{ - type: 'REPLACE' - target: string - source: string - payload: Readonly<{ - name: '[hostId]/tasks' - params: Readonly<{ hostId: string; taskSource?: TaskProvider }> - }> -}> - -export type MobileTasksRouter = { - push: (route: MobileTasksHostRoute) => void -} - -export type MobileTasksNavigationController = Readonly<{ - cancel: () => void - isActive: () => boolean - selectProvider: (provider?: TaskProvider) => void -}> - -export type PendingMobileTasksNavigation = Readonly<{ - hostId: string - controller: MobileTasksNavigationController -}> +export type MobileTasksHostRoute = HostStackHostRoute +export type MobileTasksNavigationState = HostStackNavigationState +export type MobileTasksRootNavigation = HostStackRootNavigation +export type MobileTasksRouter = HostStackRouter +export type MobileTasksNavigationController = HostStackNavigationController +export type PendingMobileTasksNavigation = PendingHostStackNavigation export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute { - return `/h/${encodeURIComponent(hostId)}` + return hostStackHostRoute(hostId) } -function mountedHostStack( - state: MobileTasksNavigationState, - expectedHostId: string -): { key: string; routeKey: string } | null { - const hostContainer = state.routes[state.index] - const hostState = hostContainer?.state - const hostRoute = hostState?.routes[hostState.index] - if ( - hostContainer?.name !== 'h' || - !hostState?.key || - hostRoute?.name !== '[hostId]/index' || - !hostRoute.key || - hostRoute.params?.hostId !== expectedHostId - ) { - return null +export function mobileTasksRouteTarget( + hostId: string, + provider?: TaskProvider +): HostStackRouteTarget { + return { + name: '[hostId]/tasks', + params: provider ? { hostId, taskSource: provider } : { hostId } } - return { key: hostState.key, routeKey: hostRoute.key } } export function navigateToMobileTasks( @@ -75,64 +39,12 @@ export function navigateToMobileTasks( hostId: string, provider?: TaskProvider ): MobileTasksNavigationController { - let active = true - let hostRouteSeen = false - let selectedProvider = provider - let unsubscribeState = () => {} - const dispose = () => { - if (!active) { - return - } - active = false - unsubscribeState() - } - - // Why: cold Expo deep links resolve to index; target Tasks only after its HostStack exists. - const onState = () => { - if (!active) { - return - } - const state = navigation.getState() - const currentRoute = state.routes[state.index] - if (currentRoute?.name === 'h') { - hostRouteSeen = true - } else if (hostRouteSeen) { - dispose() - return - } - const hostStack = mountedHostStack(state, hostId) - if (!hostStack) { - return - } - dispose() - const action: MobileTasksHostReplaceAction = { - type: 'REPLACE', - target: hostStack.key, - source: hostStack.routeKey, - payload: { - name: '[hostId]/tasks', - params: selectedProvider ? { hostId, taskSource: selectedProvider } : { hostId } - } - } - navigation.dispatch(action) - } - - try { - unsubscribeState = navigation.addListener('state', onState) - router.push(mobileTasksHostRoute(hostId)) - } catch (error) { - dispose() - throw error - } - return { - cancel: dispose, - isActive: () => active, - selectProvider: (nextProvider) => { - if (active) { - selectedProvider = nextProvider - } - } - } + return navigateToHostStackRoute( + navigation, + router, + hostId, + mobileTasksRouteTarget(hostId, provider) + ) } export function coordinateMobileTasksNavigation( @@ -142,13 +54,11 @@ export function coordinateMobileTasksNavigation( hostId: string, provider?: TaskProvider ): PendingMobileTasksNavigation { - if (current?.hostId === hostId && current.controller.isActive()) { - current.controller.selectProvider(provider) - return current - } - current?.controller.cancel() - return { + return coordinateHostStackNavigation( + current, + navigation, + router, hostId, - controller: navigateToMobileTasks(navigation, router, hostId, provider) - } + mobileTasksRouteTarget(hostId, provider) + ) } diff --git a/mobile/src/tasks/use-open-mobile-tasks.ts b/mobile/src/tasks/use-open-mobile-tasks.ts index 010e914d7fd..1c6cf67946b 100644 --- a/mobile/src/tasks/use-open-mobile-tasks.ts +++ b/mobile/src/tasks/use-open-mobile-tasks.ts @@ -1,35 +1,15 @@ -import { useCallback, useEffect, useRef } from 'react' -import { useNavigation, useRouter } from 'expo-router' -import { - coordinateMobileTasksNavigation, - type MobileTasksRootNavigation, - type PendingMobileTasksNavigation -} from './mobile-task-navigation' +import { useCallback } from 'react' +import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route' +import { mobileTasksRouteTarget } from './mobile-task-navigation' import type { TaskProvider } from './mobile-task-providers' export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void { - const navigation = useNavigation() - const router = useRouter() - const pendingRef = useRef(null) - - useEffect( - () => () => { - pendingRef.current?.controller.cancel() - pendingRef.current = null - }, - [] - ) + const openHostStackRoute = useOpenHostStackRoute() return useCallback( (hostId, provider) => { - pendingRef.current = coordinateMobileTasksNavigation( - pendingRef.current, - navigation, - router, - hostId, - provider - ) + openHostStackRoute(hostId, mobileTasksRouteTarget(hostId, provider)) }, - [navigation, router] + [openHostStackRoute] ) } 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/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/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/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 9bf8fc43f29..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, @@ -1236,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() @@ -1834,6 +1852,9 @@ export class AgentHookServer { hasExplicitPrompt?: boolean promptInteractionKey?: string hookEventName?: string + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown toolUseId?: string toolAgentId?: string toolAgentType?: string @@ -1895,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 @@ -1930,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) @@ -1954,6 +2022,7 @@ export class AgentHookServer { }) const event: AgentHookEventPayload = { paneKey, + source, launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken, tabId, worktreeId, @@ -1961,6 +2030,8 @@ export class AgentHookServer { hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined, promptInteractionKey, hookEventName, + providerPromptId, + compactTrigger, toolUseId, toolAgentId, toolAgentType, diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index db008799de9..15e115221ea 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -9,14 +9,26 @@ const { translateWslOutputPathsMock, statMock, readFileMock, - resolveGitDirMock + resolveGitDirMock, + moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrashMock, + scheduleWorktreeTrashDeletionMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), gitExecFileSyncMock: vi.fn(), translateWslOutputPathsMock: vi.fn((output: string) => output), statMock: vi.fn(), readFileMock: vi.fn(), - resolveGitDirMock: vi.fn() + resolveGitDirMock: vi.fn(), + moveWorktreeDirectoryToTrashMock: vi.fn(), + restoreWorktreeDirectoryFromTrashMock: vi.fn(), + scheduleWorktreeTrashDeletionMock: vi.fn() +})) + +vi.mock('../worktree-trash', () => ({ + moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock, + restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock, + scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock })) vi.mock('./runner', () => ({ @@ -45,7 +57,8 @@ import { removeWorktree, _resetWorktreeScanCacheForTests, WORKTREE_LIST_TIMEOUT_MS, - WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS + WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } from './worktree' // Why: detectSparseCheckout on main also requires core.sparseCheckout=true in git @@ -57,6 +70,12 @@ const ENABLED_SPARSE_CHECKOUT_CONFIG = '[core]\nsparseCheckout = true\n' beforeEach(() => { clearGitCapabilityStateForTests() _resetWorktreeScanCacheForTests() + // Default: the checkout cannot be renamed aside, so removal deletes it in place. + moveWorktreeDirectoryToTrashMock.mockReset() + moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) + restoreWorktreeDirectoryFromTrashMock.mockReset() + restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true) + scheduleWorktreeTrashDeletionMock.mockReset() }) type MockResult = { @@ -276,6 +295,8 @@ branch refs/heads/main const calls = getGitCalls() expect(calls).toEqual([ 'git worktree list --porcelain -z', + // The cleanliness probe that decides whether the checkout may be renamed aside. + 'git status --porcelain --untracked-files=all', 'git worktree remove /repo-feature', 'git branch -d -- feature/test', 'git worktree prune', @@ -283,6 +304,209 @@ branch refs/heads/main ]) }) + it('renames the checkout aside and deregisters the missing path', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01') + + await removeWorktree('/repo', '/repo-feature') + + const calls = getGitCalls() + expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature') + expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01') + expect(calls).toContain('git worktree remove --force /repo-feature') + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['worktree', 'remove', '--force', '/repo-feature'], + { cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS } + ) + expect(calls).not.toContain('git worktree remove /repo-feature') + expect(calls).not.toContain('git worktree prune') + expect(calls).toContain('git branch -d -- feature/test') + }) + + it('prunes the registration when deregistering the moved checkout fails', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree remove --force /repo-feature': { + error: new Error('fatal: validation failed, cannot remove working directory') + } + }) + moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02') + + await removeWorktree('/repo', '/repo-feature') + + expect(getGitCalls()).toContain('git worktree prune') + expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], { + cwd: '/repo', + timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + }) + expect( + gitExecFileAsyncMock.mock.calls.filter( + ([args, options]) => + args.join(' ') === 'worktree list --porcelain -z' && + options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + ) + ).toHaveLength(2) + expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02') + expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled() + }) + + it('restores the moved checkout and removes in place when the registration survives pruning', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove --force /repo-feature': { + error: new Error('fatal: validation failed, cannot remove working directory') + } + }) + moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03') + + await removeWorktree('/repo', '/repo-feature') + + expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith( + '/trash/wt-3-abcdef03', + '/repo-feature' + ) + expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() + expect(getGitCalls()).toContain('git worktree remove /repo-feature') + }) + + it('removes in place when the checkout cannot be renamed aside', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + // Windows open handles and cross-volume renames both surface as an unavailable rename. + moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined) + + await removeWorktree('/repo', '/repo-feature') + + expect(getGitCalls()).toContain('git worktree remove /repo-feature') + expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled() + }) + + it('never renames a dirty checkout aside', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git status --porcelain --untracked-files=all': { stdout: ' M src/app.ts\n' }, + 'git worktree remove /repo-feature': { + error: new Error('fatal: contains modified or untracked files, use --force to delete it') + } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'contains modified or untracked files' + ) + expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() + }) + + it('deletes WSL-hosted checkouts in place inside the distro', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + + await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' }) + + expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') + expect(getGitCalls()).toContain('git worktree remove /repo-feature') + }) + + it('does not rename a WSL checkout configured for a native Windows repo', async () => { + const originalPlatform = process.platform + const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature' + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + mockGitCommands({}) + + await removeWorktree('C:\\repo', worktreePath, false, { + knownRemovedWorktree: { branch: '', head: '', locked: false } + }) + + expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled() + expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`]) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + it('passes one --force before the worktree path for dirty-file removal', async () => { mockGitCommands({ 'git worktree list --porcelain': { @@ -339,13 +563,15 @@ branch refs/heads/main expectGitCallOrder( calls, 'git worktree remove /repo-feature', - 'git status --porcelain --untracked-files=all' - ) - expectGitCallOrder( - calls, - 'git status --porcelain --untracked-files=all', 'git worktree remove --force /repo-feature' ) + // The re-proof of cleanliness between the refusal and the forced retry. + expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeGreaterThan( + calls.indexOf('git worktree remove /repo-feature') + ) + expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeLessThan( + calls.indexOf('git worktree remove --force /repo-feature') + ) expect(calls).toContain('git branch -d -- feature/test') }) @@ -420,7 +646,10 @@ branch refs/heads/feature/test 'git worktree remove failed' ) expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') - expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') + // Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness. + expect( + getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all') + ).toHaveLength(1) }) it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { diff --git a/src/main/git/worktree-deferred-removal-real-git.test.ts b/src/main/git/worktree-deferred-removal-real-git.test.ts new file mode 100644 index 00000000000..374be4ecc0f --- /dev/null +++ b/src/main/git/worktree-deferred-removal-real-git.test.ts @@ -0,0 +1,113 @@ +// Real-binary coverage for deferred worktree deletion: the mocked-runner suite cannot prove that Git +// accepts `worktree remove --force` on a path Orca just renamed away. +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { removeWorktree } from './worktree' +import { + getWorktreeTrashRoot, + isWorktreeTrashEntryName, + sweepStaleWorktreeTrash, + whenWorktreeTrashDeletionsSettled, + WORKTREE_TRASH_DIR_NAME +} from '../worktree-trash' + +const execFileAsync = promisify(execFile) + +let scratchDir = '' +let repoPath = '' +let workspaceRoot = '' +let worktreePath = '' + +async function git(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { cwd }) + return stdout +} + +beforeEach(async () => { + // realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca + // matches the worktree it is removing against Git's own list. + scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-deferred-worktree-removal-'))) + repoPath = join(scratchDir, 'repo') + workspaceRoot = join(scratchDir, 'workspaces') + worktreePath = join(workspaceRoot, 'repo', 'feature') + await mkdir(repoPath, { recursive: true }) + await mkdir(join(workspaceRoot, 'repo'), { recursive: true }) + await git(['init', '-q'], repoPath) + await git(['config', 'user.email', 'deferred@example.invalid'], repoPath) + await git(['config', 'user.name', 'Deferred Removal'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + // Committed before the worktree exists so its branch stays merged and branch cleanup can run. + await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true }) + await writeFile(join(repoPath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') + await git(['add', '-A'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath) +}) + +afterEach(async () => { + await whenWorktreeTrashDeletionsSettled() + await rm(scratchDir, { recursive: true, force: true }) +}) + +describe('deferred worktree removal against the real Git binary', () => { + it('clears the registration and deletes the renamed checkout in the background', async () => { + const trashRoot = getWorktreeTrashRoot(worktreePath) + + await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) + + // The user-visible removal is complete: nothing on disk, nothing registered. + expect(existsSync(worktreePath)).toBe(false) + expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath) + // Only the rename path creates this root, so its presence proves the deletion was deferred. + expect(existsSync(trashRoot)).toBe(true) + expect((await readdir(trashRoot)).every(isWorktreeTrashEntryName)).toBe(true) + + await whenWorktreeTrashDeletionsSettled() + expect(await readdir(trashRoot)).toEqual([]) + }) + + it('leaves sibling worktrees registered', async () => { + const siblingPath = join(workspaceRoot, 'repo', 'sibling') + await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath) + + await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) + + expect(await git(['worktree', 'list'], repoPath)).toContain(siblingPath) + expect(existsSync(siblingPath)).toBe(true) + }) + + it('deletes the branch exactly as the in-place removal did', async () => { + await removeWorktree(repoPath, worktreePath, false) + + expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('') + }) + + it('refuses to move a dirty checkout aside', async () => { + await writeFile(join(worktreePath, 'seed.txt'), 'edited\n') + + await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow() + expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true) + expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath) + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + + it('sweeps trash a previous run left behind', async () => { + const stalePath = join( + workspaceRoot, + 'repo', + WORKTREE_TRASH_DIR_NAME, + 'wt-1700000000000-deadbeef' + ) + await mkdir(join(stalePath, 'node_modules'), { recursive: true }) + + await sweepStaleWorktreeTrash([workspaceRoot]) + + expect(existsSync(stalePath)).toBe(false) + expect(existsSync(worktreePath)).toBe(true) + }) +}) diff --git a/src/main/git/worktree-removal-large-tree.bench.test.ts b/src/main/git/worktree-removal-large-tree.bench.test.ts new file mode 100644 index 00000000000..debeb6210ed --- /dev/null +++ b/src/main/git/worktree-removal-large-tree.bench.test.ts @@ -0,0 +1,85 @@ +// Manual benchmark for the `worktree.remove.git_remove` stage on a large checkout. +// Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm exec vitest run \ +// --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { removeWorktree } from './worktree' +import { getWorktreeTrashRoot, whenWorktreeTrashDeletionsSettled } from '../worktree-trash' + +const execFileAsync = promisify(execFile) +const describeBench = process.env.ORCA_WORKTREE_REMOVAL_BENCH ? describe : describe.skip + +const FIXTURE_DIRECTORIES = 200 +const FIXTURE_FILES_PER_DIRECTORY = 500 + +describeBench('worktree removal on a large checkout', () => { + let scratchDir = '' + let repoPath = '' + let worktreePath = '' + + async function git(args: string[], cwd: string): Promise { + await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 }) + } + + beforeAll(async () => { + scratchDir = await mkdtemp(join(tmpdir(), 'orca-worktree-removal-bench-')) + repoPath = join(scratchDir, 'repo') + worktreePath = join(scratchDir, 'workspaces', 'repo', 'bench') + await mkdir(repoPath, { recursive: true }) + await git(['init', '-q', '-b', 'main'], repoPath) + await git(['config', 'user.email', 'bench@example.invalid'], repoPath) + await git(['config', 'user.name', 'Bench'], repoPath) + await writeFile(join(repoPath, 'seed.txt'), 'seed\n') + await git(['add', 'seed.txt'], repoPath) + await git(['commit', '-qm', 'seed'], repoPath) + await mkdir(join(scratchDir, 'workspaces', 'repo'), { recursive: true }) + await git(['worktree', 'add', '-q', worktreePath, '-b', 'bench'], repoPath) + + // A committed synthetic node_modules-shaped tree: the removal must be clean-checked and deleted. + for (let dirIndex = 0; dirIndex < FIXTURE_DIRECTORIES; dirIndex += 1) { + const dir = join(worktreePath, 'node_modules', `pkg-${dirIndex}`) + await mkdir(dir, { recursive: true }) + await Promise.all( + Array.from({ length: FIXTURE_FILES_PER_DIRECTORY }, (_unused, fileIndex) => + writeFile(join(dir, `file-${fileIndex}.js`), `module.exports = ${fileIndex}\n`) + ) + ) + } + await git(['add', '-A'], worktreePath) + await git(['commit', '-qm', 'large tree'], worktreePath) + }, 900_000) + + afterAll(async () => { + await whenWorktreeTrashDeletionsSettled() + if (scratchDir) { + await rm(scratchDir, { recursive: true, force: true }) + } + }) + + it('returns from removeWorktree without waiting for the recursive delete', async () => { + const startedAt = Date.now() + await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false }) + const userVisibleMs = Date.now() - startedAt + + const trashRoot = getWorktreeTrashRoot(worktreePath) + console.log( + `[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY} removeWorktree=${userVisibleMs}ms` + ) + expect(existsSync(worktreePath)).toBe(false) + const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoPath }) + expect(stdout).not.toContain(worktreePath) + + const backgroundStartedAt = Date.now() + await whenWorktreeTrashDeletionsSettled() + console.log( + `[bench] background deletion=${Date.now() - backgroundStartedAt}ms (trash root ${trashRoot})` + ) + expect(existsSync(trashRoot)).toBe(true) + expect(await readdir(trashRoot)).toEqual([]) + }, 900_000) +}) diff --git a/src/main/git/worktree.test.ts b/src/main/git/worktree.test.ts index 493beb1d91f..ff72ce6ea2c 100644 --- a/src/main/git/worktree.test.ts +++ b/src/main/git/worktree.test.ts @@ -4,13 +4,17 @@ without a meaningful boundary. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output) - }) -) +const { + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + moveWorktreeDirectoryToTrashMock +} = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output), + moveWorktreeDirectoryToTrashMock: vi.fn() +})) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, @@ -18,6 +22,13 @@ vi.mock('./runner', () => ({ translateWslOutputPaths: translateWslOutputPathsMock })) +// Default: the checkout cannot be renamed aside, so removal deletes it in place. +vi.mock('../worktree-trash', () => ({ + moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined), + restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true), + scheduleWorktreeTrashDeletion: vi.fn() +})) + import { clearGitCapabilityStateForTests } from './git-capability-state' import { @@ -1792,6 +1803,7 @@ describe('removeWorktree', () => { it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove // Git refuses to delete an unmerged branch with `-d`. gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d @@ -1808,6 +1820,7 @@ describe('removeWorktree', () => { it('deletes the branch when `branch -d` succeeds (fully merged)', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -1822,6 +1835,7 @@ describe('removeWorktree', () => { }) it('reuses known removed worktree metadata instead of relisting before removal', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds @@ -1833,6 +1847,7 @@ describe('removeWorktree', () => { }) expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ + ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'] ]) @@ -1840,6 +1855,7 @@ describe('removeWorktree', () => { it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => { gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before + gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove gitExecFileAsyncMock.mockRejectedValueOnce( new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'") @@ -1851,6 +1867,7 @@ describe('removeWorktree', () => { expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([ ['worktree', 'list', '--porcelain', '-z'], + ['status', '--porcelain', '--untracked-files=all'], ['worktree', 'remove', '/repo-feature'], ['branch', '-d', '--', 'feature/test'], ['worktree', 'prune'], diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 4ea08ef687b..064eb0887fd 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -7,6 +7,13 @@ import { } from '../../shared/git-branch-cleanup' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { withSpan } from '../observability/tracer' +import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' +import { + moveWorktreeDirectoryToTrash, + restoreWorktreeDirectoryFromTrash, + scheduleWorktreeTrashDeletion +} from '../worktree-trash' +import { parseWslPath } from '../wsl' import type { GitWorktreeInfo, LocalBaseRefRefreshResult, @@ -87,6 +94,7 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 // Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225). export const WORKTREE_ADD_TIMEOUT_MS = 180_000 export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000 +export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000 // Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list. export const WORKTREE_LIST_TIMEOUT_MS = 30_000 @@ -1131,23 +1139,27 @@ async function performRemoveWorktree( // Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects. assertWorktreeUnlockedForRemoval(removedWorktree) - const args = ['worktree', 'remove'] - if (force) { - args.push('--force') - } - args.push(worktreePath) - try { - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) - } catch (error) { - if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { - throw error + if ( + !(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options)) + ) { + const args = ['worktree', 'remove'] + if (force) { + args.push('--force') + } + args.push(worktreePath) + try { + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, options) + ) } - // Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force. - await assertWorktreeCleanForRemoval(worktreePath, false, options) - await gitExecFileAsync( - ['worktree', 'remove', '--force', worktreePath], - gitExecOptions(repoPath, options) - ) } if (!branchName) { @@ -1164,6 +1176,82 @@ async function performRemoveWorktree( ) } +/** + * Rename the checkout into a sibling trash directory and clear Git's registration for the + * now-missing path, so the multi-GB recursive delete runs after this removal has returned. + * Returns false when the caller must let `git worktree remove` delete the directory inline. + */ +async function tryRemoveWorktreeWithDeferredDirectoryDeletion( + repoPath: string, + worktreePath: string, + force: boolean, + options: RemoveWorktreeOptions +): Promise { + // Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them. + if (options.wslDistro || parseWslPath(worktreePath)) { + return false + } + if (!force) { + try { + // Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + } catch { + return false + } + } + + const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () => + moveWorktreeDirectoryToTrash(worktreePath) + ) + if (!trashPath) { + return false + } + try { + await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options) + } catch (error) { + // Why: put the checkout back so the in-place removal below still sees the worktree Git registered. + if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) { + return false + } + throw error + } + scheduleWorktreeTrashDeletion(trashPath) + return true +} + +async function clearGitRegistrationForMissingWorktree( + repoPath: string, + worktreePath: string, + options: RemoveWorktreeOptions +): Promise { + const registrationOptions = { + ...options, + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + // Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry. + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, registrationOptions) + ) + return + } catch (error) { + console.warn( + `[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`, + error + ) + } + + await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions)) + // Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone. + const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some( + (worktree) => areWorktreePathsEqual(worktree.path, worktreePath) + ) + if (stillRegistered) { + throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`) + } +} + async function deleteBranchAfterWorktreeRemoval( repoPath: string, branchName: string, diff --git a/src/main/index.ts b/src/main/index.ts index 6e769cd39d6..87a6b3fc345 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -221,6 +221,7 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/cod import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path' import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume' import { getDefaultWslDistro } from './wsl' +import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash' import { ClaudeAccountService } from './claude-accounts/service' import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service' import { @@ -2627,6 +2628,13 @@ void app.whenReady().then(async () => { // Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace. const emulatorBridge = new EmulatorBridge() runtimeService.setEmulatorBridge(emulatorBridge) + // Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or + // crash mid-delete can leave the moved directory on disk. + void sweepStaleWorktreeTrash( + collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings()) + ).catch((error) => { + console.warn('[worktrees] Failed to sweep leftover worktree directories:', error) + }) nativeTheme.themeSource = store.getSettings().theme ?? 'system' if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) { // Why: establish capability before managed-hook reconciliation so an diff --git a/src/main/ipc/filesystem.test.ts b/src/main/ipc/filesystem.test.ts index e712a2938fc..78b950fe5a9 100644 --- a/src/main/ipc/filesystem.test.ts +++ b/src/main/ipc/filesystem.test.ts @@ -2419,6 +2419,111 @@ describe('registerFilesystemHandlers', () => { ) }) + it('discovers models from an exact repo-less folder workspace root', async () => { + const folderPath = path.resolve('/outside-workspace/folder-project') + const folderStore = { + ...store, + getFolderWorkspaces: () => [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath, + connectionId: null + } + ] + } + discoverCommitMessageModelsLocalMock.mockResolvedValue({ + success: true, + models: [{ id: 'sonnet', label: 'Sonnet' }], + defaultModelId: 'sonnet' + }) + + registerFilesystemHandlers(folderStore as never) + + await handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'claude', + worktreePath: folderPath + }) + + expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith( + 'claude', + undefined, + undefined, + { cwd: folderPath } + ) + }) + + it('does not authorize remote-only folder roots as local discovery paths', async () => { + const folderPath = path.resolve('/remote-only/folder-project') + const folderStore = { + ...store, + getFolderWorkspaces: () => [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath, + connectionId: 'ssh-1' + } + ] + } + + registerFilesystemHandlers(folderStore as never) + + await expect( + handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'claude', + worktreePath: folderPath + }) + ).rejects.toThrow('Access denied') + expect(discoverCommitMessageModelsLocalMock).not.toHaveBeenCalled() + }) + + it('routes a repo-less WSL folder workspace discovery through its distro', async () => { + await withPlatform('win32', async () => { + const folderPath = '\\\\wsl.localhost\\Ubuntu\\home\\tester\\folder-project' + const prepareForClaudeLaunch = vi.fn().mockResolvedValue({ + configDir: '\\\\wsl.localhost\\Ubuntu\\home\\tester\\.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/home/tester/.claude' }, + stripAuthEnv: true, + provenance: 'managed:account-1' + }) + const folderStore = { + ...store, + getFolderWorkspaces: () => [ + { + id: 'folder-1', + projectGroupId: 'group-1', + folderPath, + connectionId: null + } + ] + } + discoverCommitMessageModelsLocalMock.mockResolvedValue({ + success: true, + models: [{ id: 'sonnet', label: 'Sonnet' }], + defaultModelId: 'sonnet' + }) + + registerFilesystemHandlers(folderStore as never, { prepareForClaudeLaunch }) + + await handlers.get('git:discoverCommitMessageModels')!(null, { + agentId: 'claude', + worktreePath: folderPath + }) + + expect(prepareForClaudeLaunch).toHaveBeenCalledWith({ + runtime: 'wsl', + wslDistro: 'Ubuntu' + }) + expect(discoverCommitMessageModelsLocalMock).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ CLAUDE_CONFIG_DIR: '/home/tester/.claude' }), + undefined, + { cwd: path.resolve(folderPath), wslDistro: 'Ubuntu' } + ) + }) + }) + it('routes local WSL project model discovery through the project runtime target', async () => { await withPlatform('win32', async () => { discoverCommitMessageModelsLocalMock.mockResolvedValue({ diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index 466166cfcad..48ae7f8f6f2 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -409,6 +409,26 @@ function getLocalAgentRuntimeTarget( : { runtime: 'host' } } +async function resolveModelDiscoveryLocalPath( + store: Store, + requestedPath: string +): Promise { + try { + return await resolveRegisteredWorktreePath(requestedPath, store) + } catch (error) { + const folderWorkspaces = + typeof store.getFolderWorkspaces === 'function' ? store.getFolderWorkspaces() : [] + const isFolderWorkspaceRoot = folderWorkspaces.some( + (workspace) => + comparableLocalPath(workspace.folderPath) === comparableLocalPath(requestedPath) + ) + if (!isFolderWorkspaceRoot) { + throw error + } + return resolveAuthorizedPath(requestedPath, store) + } +} + function getLocalTextGenerationTarget( worktreePath: string, gitOptions: LocalProjectWorktreeGitOptions, @@ -1517,16 +1537,17 @@ export function registerFilesystemHandlers( let localRuntimeTarget: CommitMessageAgentRuntimeTarget = { runtime: 'host' } let localDiscoveryOptions: Parameters[3] if (args.worktreePath) { - const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const worktreePath = await resolveModelDiscoveryLocalPath(store, args.worktreePath) const gitOptions = getLocalGitOptionsForRegisteredWorktree( store, args.worktreePath, worktreePath ) - localRuntimeTarget = getLocalAgentRuntimeTarget(gitOptions) - localDiscoveryOptions = gitOptions.wslDistro - ? { cwd: worktreePath, wslDistro: gitOptions.wslDistro } - : { cwd: worktreePath } + const wslDistro = gitOptions.wslDistro ?? parseWslPath(args.worktreePath)?.distro + localRuntimeTarget = wslDistro + ? { runtime: 'wsl', wslDistro } + : getLocalAgentRuntimeTarget(gitOptions) + localDiscoveryOptions = wslDistro ? { cwd: worktreePath, wslDistro } : { cwd: worktreePath } } const localEnv = await prepareLocalCommitMessageAgentEnv( agentId, diff --git a/src/main/ipc/localhost-worktree-labels.ts b/src/main/ipc/localhost-worktree-labels.ts index 382072f6c05..77c3c3d6503 100644 --- a/src/main/ipc/localhost-worktree-labels.ts +++ b/src/main/ipc/localhost-worktree-labels.ts @@ -42,7 +42,11 @@ async function assertAllowedTarget(store: Store, targetUrl: string): Promise { if (String(port.port) !== targetPort) { return false diff --git a/src/main/ipc/workspace-ports.test.ts b/src/main/ipc/workspace-ports.test.ts index 841c046092f..f5afe03628b 100644 --- a/src/main/ipc/workspace-ports.test.ts +++ b/src/main/ipc/workspace-ports.test.ts @@ -102,14 +102,18 @@ describe('registerWorkspacePortHandlers', () => { worktrees: [{ id: 'attacker', path: '/tmp/not-authorized', repoId: 'local-repo' }] }) - expect(scanWorkspacePortsMock).toHaveBeenCalledWith([ - { - id: 'local-repo::/workspace/repo', - repoId: 'local-repo', - displayName: 'Primary', - path: '/workspace/repo' - } - ]) + expect(scanWorkspacePortsMock).toHaveBeenCalledWith( + [ + { + id: 'local-repo::/workspace/repo', + repoId: 'local-repo', + displayName: 'Primary', + path: '/workspace/repo' + } + ], + undefined, + undefined + ) }) it('deduplicates concurrent scans for the same store-derived probe set', async () => { @@ -139,20 +143,24 @@ describe('registerWorkspacePortHandlers', () => { await handlers.get('workspacePorts:scan')?.(null, undefined) - expect(scanWorkspacePortsMock).toHaveBeenCalledWith([ - { - id: 'local-repo::/workspace/repo', - repoId: 'local-repo', - displayName: 'Primary', - path: '/workspace/repo' - }, - { - id: 'other-repo::/workspace/other', - repoId: 'other-repo', - displayName: 'Other', - path: '/workspace/other' - } - ]) + expect(scanWorkspacePortsMock).toHaveBeenCalledWith( + [ + { + id: 'local-repo::/workspace/repo', + repoId: 'local-repo', + displayName: 'Primary', + path: '/workspace/repo' + }, + { + id: 'other-repo::/workspace/other', + repoId: 'other-repo', + displayName: 'Other', + path: '/workspace/other' + } + ], + undefined, + undefined + ) }) it('stops a process only after the current scan proves the pid owns a workspace port', async () => { diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index d4ab5d4e497..369312476f2 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -214,6 +214,7 @@ export type WorktreeRemoveStage = | 'git_remove' | 'metadata_purge' | 'pty_sweep' + | 'trash_rename' | 'watcher_gate' /** Wrap one stage of a worktree removal. Children share the parent's `kind` so `kind`-filtered diff --git a/src/main/ports/local-workspace-port-scanner.test.ts b/src/main/ports/local-workspace-port-scanner.test.ts index a6eeb21e5ee..132eba1950e 100644 --- a/src/main/ports/local-workspace-port-scanner.test.ts +++ b/src/main/ports/local-workspace-port-scanner.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi, type Mock } from 'vitest' import path from 'node:path' import { attributePortToWorkspace, @@ -9,13 +9,21 @@ import { resetWorkspacePortScanTimeoutBackoffForTests, scanWorkspacePorts } from './local-workspace-port-scanner' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' -const execFileMock = vi.hoisted(() => vi.fn()) +const runPortScanCommandMock = vi.hoisted(() => vi.fn()) -vi.mock('child_process', () => ({ - execFile: execFileMock +vi.mock('./port-scan-command-client', () => ({ + runPortScanCommand: runPortScanCommandMock, + isPortScanWorkerUnavailableError: () => false })) +const LSOF_LISTEN_OUTPUT = ['p123', 'cnode', 'n127.0.0.1:5173'].join('\n') + +function urlWatcherStub(): { lookup: () => undefined; reconcileScan: Mock } { + return { lookup: () => undefined, reconcileScan: vi.fn() } +} + const worktrees = [ { id: 'repo::/repo', @@ -177,52 +185,42 @@ describe('scanWorkspacePorts attribution work', () => { afterEach(() => { resetWorkspacePortScanTimeoutBackoffForTests() vi.restoreAllMocks() - execFileMock.mockReset() + runPortScanCommandMock.mockReset() }) it('normalizes worktree paths once per scan instead of once per port phase', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const win32ResolveSpy = vi.spyOn(path.win32, 'resolve') const posixResolveSpy = vi.spyOn(path.posix, 'resolve') - const invokeCallback = (callback: unknown, stdout: string): void => { - if (typeof callback !== 'function') { - throw new Error('missing execFile callback') - } - const execCallback = callback as (error: Error | null, stdout: string) => void - execCallback(null, stdout) - } - execFileMock.mockImplementation( - (command: string, args: string[], _options: unknown, callback: unknown) => { - if (command === 'lsof' && args.includes('-iTCP')) { - invokeCallback( - callback, - ['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join('\n') - ) - } else if (command === 'lsof') { - invokeCallback( - callback, - ['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n') - ) - } else if (command === 'ps') { - invokeCallback( - callback, - [ - '123 node /repo/service/server.js', - '124 node /repo/worktrees/feature/app/server.js' - ].join('\n') - ) - } else { - invokeCallback(callback, '') + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { + stdout: ['p123', 'cnode', 'n127.0.0.1:3000', 'p124', 'cnode', 'n127.0.0.1:3001'].join( + '\n' + ), + spawnMs: 5 } - return { kill: vi.fn() } } - ) - - const scan = await scanWorkspacePorts(worktrees, { - lookup: () => undefined, - reconcileScan: vi.fn() + if (command === 'lsof') { + return { + stdout: ['p123', 'n/repo/service', 'p124', 'n/repo/worktrees/feature/app'].join('\n'), + spawnMs: 5 + } + } + if (command === 'ps') { + return { + stdout: [ + '123 node /repo/service/server.js', + '124 node /repo/worktrees/feature/app/server.js' + ].join('\n'), + spawnMs: 5 + } + } + return { stdout: '', spawnMs: 5 } }) + const scan = await scanWorkspacePorts(worktrees, urlWatcherStub()) + expect(scan.ports.filter((port) => port.kind === 'workspace')).toHaveLength(2) const win32WorktreePathResolveCalls = win32ResolveSpy.mock.calls.filter( ([input]) => input === '/repo' || input === '/repo/worktrees/feature' @@ -240,62 +238,39 @@ describe('scanWorkspacePorts command timeout', () => { vi.useRealTimers() resetWorkspacePortScanTimeoutBackoffForTests() vi.restoreAllMocks() - execFileMock.mockReset() + runPortScanCommandMock.mockReset() }) it('returns an unavailable scan when lsof never reports completion', async () => { - vi.useFakeTimers() vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + runPortScanCommandMock.mockRejectedValue( + new PortScanCommandTimeoutError('lsof timed out after 4000ms') + ) - let settled = false - const scanPromise = scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }).then((scan) => { - settled = true - return scan - }) - - await vi.advanceTimersByTimeAsync(4_000) - - expect(settled).toBe(true) - await expect(scanPromise).resolves.toMatchObject({ + await expect(scanWorkspacePorts([], urlWatcherStub())).resolves.toMatchObject({ platform: 'darwin', ports: [], unavailableReason: 'Port scanning is unavailable on darwin.' }) - expect(killMock).toHaveBeenCalled() }) it('backs off after a command timeout instead of launching lsof on every scan tick', async () => { vi.useFakeTimers() vi.setSystemTime(1_000) vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') - const killMock = vi.fn() - execFileMock.mockImplementation(() => ({ kill: killMock })) + runPortScanCommandMock.mockRejectedValue( + new PortScanCommandTimeoutError('lsof timed out after 4000ms') + ) - const firstScanPromise = scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) - - await vi.advanceTimersByTimeAsync(4_000) - await expect(firstScanPromise).resolves.toMatchObject({ + await expect(scanWorkspacePorts([], urlWatcherStub())).resolves.toMatchObject({ platform: 'darwin', ports: [], unavailableReason: 'Port scanning is unavailable on darwin.' }) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) const cooldownScans = await Promise.all( - Array.from({ length: 10 }, () => - scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) - ) + Array.from({ length: 10 }, () => scanWorkspacePorts([], urlWatcherStub())) ) expect(cooldownScans).toHaveLength(10) @@ -306,25 +281,172 @@ describe('scanWorkspacePorts command timeout', () => { expect( cooldownScans.every((scan) => scan.unavailableReason?.includes('temporarily paused')) ).toBe(true) - expect(execFileMock).toHaveBeenCalledTimes(1) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) vi.setSystemTime(65_001) await vi.advanceTimersByTimeAsync(0) - execFileMock.mockImplementation( - (_command: string, args: string[], _options: unknown, callback: unknown) => { - const execCallback = callback as (error: Error | null, stdout: string) => void - const output = args.includes('-iTCP') ? 'p123\ncnode\nn127.0.0.1:3000' : '' - execCallback(null, output) - return { kill: vi.fn() } - } - ) + runPortScanCommandMock.mockImplementation(async (_command: string, args: string[]) => ({ + stdout: args.includes('-iTCP') ? 'p123\ncnode\nn127.0.0.1:3000' : '', + spawnMs: 5 + })) - const recoveredScan = await scanWorkspacePorts([], { - lookup: () => undefined, - reconcileScan: vi.fn() - }) + const recoveredScan = await scanWorkspacePorts([], urlWatcherStub()) expect(recoveredScan.unavailableReason).toBeUndefined() - expect(execFileMock).toHaveBeenCalledTimes(4) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(4) }) }) + +describe('scanWorkspacePorts with delayed process creation', () => { + afterEach(() => { + resetWorkspacePortScanTimeoutBackoffForTests() + vi.restoreAllMocks() + runPortScanCommandMock.mockReset() + }) + + // Regression for #11161: an endpoint-security hook makes CreateProcessW take + // seconds, so the command's own budget must not be charged for the spawn. + it('does not report a command timeout when only process creation was delayed', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + + const first = await scanWorkspacePorts([], urlWatcherStub()) + const second = await scanWorkspacePorts([], urlWatcherStub()) + + expect(first.unavailableReason).toBeUndefined() + expect(second.unavailableReason).toBeUndefined() + expect(first.ports).toHaveLength(1) + }) + + it('skips the optional metadata commands for one cycle after a stalled spawn', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + + const scan = await scanWorkspacePorts([], urlWatcherStub()) + + expect(runPortScanCommandMock).toHaveBeenCalledTimes(1) + expect(scan.ports).toHaveLength(1) + }) + + // Regression for #11161 review: a metadata-less scan must not be reconciled as + // "the listener vanished" — that evicts advertised URLs only a PTY can restore. + it('does not reconcile advertised URLs for a scan that skipped metadata', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockResolvedValue({ stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 }) + const watcher = urlWatcherStub() + + await scanWorkspacePorts(worktrees, watcher) + + expect(watcher.reconcileScan).not.toHaveBeenCalled() + }) + + it('re-probes metadata on the scan after a skip instead of degrading forever', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 } + } + if (command === 'lsof') { + return { stdout: ['p123', 'n/repo'].join('\n'), spawnMs: 4_200 } + } + return { stdout: '123 node /repo/server.js', spawnMs: 4_200 } + }) + const watcher = urlWatcherStub() + + const skipped = await scanWorkspacePorts(worktrees, watcher) + const recovered = await scanWorkspacePorts(worktrees, watcher) + + expect(skipped.ports[0]?.kind).toBe('external') + expect(recovered.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(4) + expect(watcher.reconcileScan).toHaveBeenCalledTimes(worktrees.length) + }) + + it('still collects process metadata when process creation was fast', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + runPortScanCommandMock.mockImplementation(async (_command: string, args: string[]) => ({ + stdout: args.includes('-iTCP') ? LSOF_LISTEN_OUTPUT : '', + spawnMs: 5 + })) + + await scanWorkspacePorts([], urlWatcherStub()) + + expect(runPortScanCommandMock).toHaveBeenCalledTimes(3) + }) + + // Regression for #11161 review: the skip parity is driven by the 30s poller, + // so a one-shot user action would otherwise land on a random parity. + it('keeps probing metadata for callers that require attribution', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mockStalledDarwinScan() + + const scan = await scanWorkspacePorts(worktrees, urlWatcherStub(), { requireMetadata: true }) + + expect(scan.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(runPortScanCommandMock).toHaveBeenCalledTimes(3) + }) + + it('does not let a required-metadata scan reset the background skip parity', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + mockStalledDarwinScan() + + await scanWorkspacePorts(worktrees, urlWatcherStub()) + await scanWorkspacePorts(worktrees, urlWatcherStub(), { requireMetadata: true }) + await scanWorkspacePorts(worktrees, urlWatcherStub()) + + // 1 skipped + 3 required + 3 recovered; a reset parity would skip twice. + expect(runPortScanCommandMock).toHaveBeenCalledTimes(7) + }) + + // Regression for #11161 review: without carry-forward the panel moves every + // workspace port into External on each skipped cycle. + it('carries the previous cycle attribution through a skipped scan', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + let listenSpawnMs = 5 + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: listenSpawnMs } + } + return { stdout: command === 'lsof' ? ['p123', 'n/repo'].join('\n') : '', spawnMs: 5 } + }) + + const full = await scanWorkspacePorts(worktrees, urlWatcherStub()) + listenSpawnMs = 4_200 + const skipped = await scanWorkspacePorts(worktrees, urlWatcherStub()) + + expect(full.ports[0]).toMatchObject({ kind: 'workspace' }) + expect(skipped.ports[0]).toMatchObject({ kind: 'workspace', processName: 'node' }) + }) + + it('does not hand carried-forward metadata to a different listener', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + let listenOutput = LSOF_LISTEN_OUTPUT + let listenSpawnMs = 5 + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: listenOutput, spawnMs: listenSpawnMs } + } + return { stdout: command === 'lsof' ? ['p123', 'n/repo'].join('\n') : '', spawnMs: 5 } + }) + + await scanWorkspacePorts(worktrees, urlWatcherStub()) + listenOutput = ['p123', 'cnode', 'n127.0.0.1:9999'].join('\n') + listenSpawnMs = 4_200 + const skipped = await scanWorkspacePorts(worktrees, urlWatcherStub()) + + expect(skipped.ports[0]?.kind).toBe('external') + }) +}) + +/** Darwin scan where every spawn stalls past the metadata-skip threshold. */ +function mockStalledDarwinScan(): void { + runPortScanCommandMock.mockImplementation(async (command: string, args: string[]) => { + if (command === 'lsof' && args.includes('-iTCP')) { + return { stdout: LSOF_LISTEN_OUTPUT, spawnMs: 4_200 } + } + if (command === 'lsof') { + return { stdout: ['p123', 'n/repo'].join('\n'), spawnMs: 4_200 } + } + return { stdout: '123 node /repo/server.js', spawnMs: 4_200 } + }) +} diff --git a/src/main/ports/local-workspace-port-scanner.ts b/src/main/ports/local-workspace-port-scanner.ts index 1d8f4dbf891..ce2b7b7683c 100644 --- a/src/main/ports/local-workspace-port-scanner.ts +++ b/src/main/ports/local-workspace-port-scanner.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: the platform-specific scan paths share parsing, attribution, and normalization rules that must stay in lockstep. */ -import { execFile } from 'node:child_process' import { readFile, readdir, readlink } from 'node:fs/promises' import path from 'node:path' import type { @@ -11,14 +10,36 @@ import type { } from '../../shared/workspace-ports' import { getProcessOutputFields } from '../../shared/process-output-field-scanner' import { advertisedUrlWatcher, type AdvertisedUrlWatcher } from './advertised-url-watcher' +import { isPortScanWorkerUnavailableError, runPortScanCommand } from './port-scan-command-client' +import { PortScanCommandTimeoutError } from './port-scan-command-protocol' import { WorkspacePortScanTimeoutBackoff } from './workspace-port-scan-timeout-backoff' -const COMMAND_TIMEOUT_MS = 4_000 +// Why (#11161): on an EDR-hooked host process creation alone can take seconds. +// Past this, skip the scan's optional metadata commands for one cycle so a scan +// costs roughly one stall instead of three. +const SLOW_SPAWN_SKIP_METADATA_MS = 2_000 const MAX_PORTS = 200 const HTTP_PORTS = new Set([80, 3000, 3001, 4200, 5000, 5173, 5174, 8000, 8080, 8888]) const HTTPS_PORTS = new Set([443, 8443]) const commandTimeoutBackoff = new WorkspacePortScanTimeoutBackoff() +let loggedWorkerUnavailable = false +// Why (#11161): a hooked host stalls every spawn, so gating only on the current +// scan would drop metadata forever. Never skip twice running, so attribution — +// and the Stop action and advertised-URL matching that ride on it — recovers on +// the next tick. +let skippedMetadataOnLastScan = false +// Why (#11161): a skipped cycle would otherwise report every listener as +// external, so the panel flip-flops and the Stop action loses its owner. Carry +// the previous cycle's metadata forward, keyed tightly enough that a recycled +// pid cannot inherit it. +let lastListenerMetadata = new Map() + +export type WorkspacePortScanOptions = { + /** Set by attribution-dependent callers (Stop, the localhost-label allowlist) + * that must never trade owner metadata for scan latency. */ + requireMetadata?: boolean +} type RawListeningPort = { host: string @@ -40,9 +61,16 @@ type NormalizedWorkspacePortProbe = { normalizedPath: string } +type PlatformListeningPortScan = { + ports: RawListeningPort[] + /** False when a stalled spawn made the scan skip its cwd/command-line probes. */ + metadataAvailable: boolean +} + export async function scanWorkspacePorts( worktrees: WorkspacePortProbe[], - urlWatcher: Pick = advertisedUrlWatcher + urlWatcher: Pick = advertisedUrlWatcher, + options: WorkspacePortScanOptions = {} ): Promise { const cooldown = commandTimeoutBackoff.snapshot() if (cooldown.isCoolingDown) { @@ -54,10 +82,15 @@ export async function scanWorkspacePorts( } try { - const rawPorts = await scanPlatformListeningPorts() + const { ports: rawPorts, metadataAvailable } = await scanPlatformListeningPorts(options) commandTimeoutBackoff.recordSuccess() const normalizedWorktrees = normalizeWorkspacePortProbes(worktrees) - reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher) + // Why (#11161): without cwd/command-line every port looks unattributed, and + // reconciling that would read as "the listener vanished" and evict cached + // advertised URLs that only live PTY output can ever restore. + if (metadataAvailable) { + reconcileAdvertisedUrls(rawPorts, normalizedWorktrees, urlWatcher) + } const ports = rawPorts .map((port) => enrichPort(port, normalizedWorktrees, urlWatcher)) .sort(compareWorkspacePorts) @@ -67,13 +100,66 @@ export async function scanWorkspacePorts( if (isCommandTimeoutError(error)) { commandTimeoutBackoff.recordTimeout() } - console.warn('[workspace-ports] scan failed', error) + warnScanFailure(error) return makeUnavailableScan(`Port scanning is unavailable on ${process.platform}.`) } } export function resetWorkspacePortScanTimeoutBackoffForTests(): void { commandTimeoutBackoff.reset() + loggedWorkerUnavailable = false + skippedMetadataOnLastScan = false + lastListenerMetadata = new Map() +} + +function shouldSkipMetadataCommands(spawnMs: number, opts: WorkspacePortScanOptions): boolean { + if (opts.requireMetadata) { + // Leave the skip parity alone: it belongs to the background scan cadence, + // and a one-shot user action must not shift which tick degrades. + return false + } + const skip = spawnMs > SLOW_SPAWN_SKIP_METADATA_MS && !skippedMetadataOnLastScan + skippedMetadataOnLastScan = skip + return skip +} + +/** Identity tight enough that a recycled pid cannot inherit stale metadata. */ +function listenerMetadataKey(port: RawListeningPort): string { + return `${port.pid ?? 'unknown'}:${port.host}:${port.port}` +} + +function rememberListenerMetadata(ports: readonly RawListeningPort[]): void { + lastListenerMetadata = new Map( + ports.map((port) => [ + listenerMetadataKey(port), + { processName: port.processName, commandLine: port.commandLine, cwd: port.cwd } + ]) + ) +} + +function recallListenerMetadata(port: RawListeningPort): RawListeningPort { + const remembered = lastListenerMetadata.get(listenerMetadataKey(port)) + if (!remembered) { + return port + } + return { + ...port, + processName: port.processName ?? remembered.processName, + commandLine: port.commandLine ?? remembered.commandLine, + cwd: port.cwd ?? remembered.cwd + } +} + +// Why: a mispackaged probe worker fails identically forever, so logging it on +// every 30s scan tick is pure noise. +function warnScanFailure(error: unknown): void { + if (isPortScanWorkerUnavailableError(error)) { + if (loggedWorkerUnavailable) { + return + } + loggedWorkerUnavailable = true + } + console.warn('[workspace-ports] scan failed', error) } function makeUnavailableScan(reason: string): WorkspacePortScanResult { @@ -194,38 +280,73 @@ export function parseProcNetTcp(content: string): { host: string; port: number; return results } -async function scanPlatformListeningPorts(): Promise { +async function scanPlatformListeningPorts( + options: WorkspacePortScanOptions +): Promise { + const scan = await dispatchPlatformListeningPortScan(options) + if (scan.metadataAvailable) { + rememberListenerMetadata(scan.ports) + return scan + } + return { ...scan, ports: scan.ports.map(recallListenerMetadata) } +} + +async function dispatchPlatformListeningPortScan( + options: WorkspacePortScanOptions +): Promise { if (process.platform === 'linux') { return scanLinuxProcPorts() } if (process.platform === 'darwin') { - return scanDarwinLsofPorts() + return scanDarwinLsofPorts(options) } if (process.platform === 'win32') { - return scanWindowsNetstatPorts() + return scanWindowsNetstatPorts(options) } throw new Error(`Port scanning is not supported on ${process.platform}`) } -async function scanDarwinLsofPorts(): Promise { - const { stdout } = await runCommand('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-F', 'pcn']) +async function scanDarwinLsofPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('lsof', [ + '-nP', + '-iTCP', + '-sTCP:LISTEN', + '-F', + 'pcn' + ]) const ports = parseLsofListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } const metadata = await loadDarwinProcessMetadata( new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) ) - return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } } -async function scanWindowsNetstatPorts(): Promise { - const { stdout } = await runCommand('netstat', ['-ano', '-p', 'tcp']) +async function scanWindowsNetstatPorts( + options: WorkspacePortScanOptions +): Promise { + const { stdout, spawnMs } = await runPortScanCommand('netstat', ['-ano', '-p', 'tcp']) const ports = parseNetstatListeningOutput(stdout) + if (shouldSkipMetadataCommands(spawnMs, options)) { + return { ports, metadataAvailable: false } + } const metadata = await loadWindowsProcessMetadata( new Set(ports.flatMap((p) => (p.pid ? [p.pid] : []))) ) - return ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })) + return { + ports: ports.map((port) => ({ ...metadata.get(port.pid ?? -1), ...port })), + metadataAvailable: true + } } -async function scanLinuxProcPorts(): Promise { +async function scanLinuxProcPorts(): Promise { const [tcp4, tcp6] = await Promise.all([ readProcNet('/proc/net/tcp'), readProcNet('/proc/net/tcp6') @@ -248,7 +369,7 @@ async function scanLinuxProcPorts(): Promise { }) } - return dedupeRawPorts(rawPorts) + return { ports: dedupeRawPorts(rawPorts), metadataAvailable: true } } async function readProcNet( @@ -321,10 +442,24 @@ async function loadDarwinProcessMetadata(pids: Set): Promise null), - runCommand('ps', ['-p', pidList, '-o', 'pid=', '-o', 'command=']).catch(() => null) - ]) + // Why (#11161): sequential, not Promise.all — the probe worker dispatches one + // command at a time, so issuing both at once would only queue the second. + const cwdOutput = await runPortScanCommand('lsof', [ + '-a', + '-p', + pidList, + '-d', + 'cwd', + '-Fn' + ]).catch(() => null) + const commandOutput = await runPortScanCommand('ps', [ + '-p', + pidList, + '-o', + 'pid=', + '-o', + 'command=' + ]).catch(() => null) let currentPid: number | null = null for (const line of cwdOutput?.stdout.split('\n') ?? []) { @@ -360,7 +495,7 @@ async function loadWindowsProcessMetadata( .filter(Number.isFinite) .map((pid) => `ProcessId=${pid}`) .join(' OR ') - const { stdout } = await runCommand('powershell.exe', [ + const { stdout } = await runPortScanCommand('powershell.exe', [ '-NoProfile', '-Command', `Get-CimInstance Win32_Process -Filter "${pidFilter}" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress` @@ -382,62 +517,8 @@ async function loadWindowsProcessMetadata( return result } -async function runCommand(command: string, args: string[]): Promise<{ stdout: string }> { - return await new Promise((resolve, reject) => { - let settled = false - let child: ReturnType | undefined - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - child?.kill() - reject(new CommandTimeoutError(command, COMMAND_TIMEOUT_MS)) - }, COMMAND_TIMEOUT_MS) - - const settle = (callback: () => void): void => { - if (settled) { - return - } - settled = true - clearTimeout(timer) - callback() - } - - // Why: Node's execFile timeout only signals the child; if the callback - // never arrives, the workspace port scan would otherwise hang forever. - try { - child = execFile( - command, - args, - { - timeout: COMMAND_TIMEOUT_MS, - maxBuffer: 2 * 1024 * 1024, - windowsHide: true - }, - (error, stdout) => { - if (error) { - settle(() => reject(error)) - return - } - settle(() => resolve({ stdout: String(stdout) })) - } - ) - } catch (error) { - settle(() => reject(error)) - } - }) -} - -class CommandTimeoutError extends Error { - constructor(command: string, timeoutMs: number) { - super(`${command} timed out after ${timeoutMs}ms`) - this.name = 'CommandTimeoutError' - } -} - function isCommandTimeoutError(error: unknown): boolean { - return error instanceof CommandTimeoutError + return error instanceof PortScanCommandTimeoutError } async function readTextIfAvailable(filePath: string): Promise { diff --git a/src/main/ports/port-scan-command-client.test.ts b/src/main/ports/port-scan-command-client.test.ts new file mode 100644 index 00000000000..d1a15d29774 --- /dev/null +++ b/src/main/ports/port-scan-command-client.test.ts @@ -0,0 +1,300 @@ +import { readFileSync } from 'node:fs' +import { join, sep } from 'node:path' +import { Worker } from 'node:worker_threads' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + CALL_DEADLINE_MS, + MAX_QUEUED_CALLS, + PortScanCommandClient, + isPortScanWorkerUnavailableError, + resolveWorkerEntryPath +} from './port-scan-command-client' +import { + PortScanCommandTimeoutError, + type PortScanCommandRequest +} from './port-scan-command-protocol' + +type PortScanCommandResponseBody = + | { ok: true; stdout: string; spawnMs: number } + | { ok: false; timedOut: boolean; error: string } + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +// A worker_threads stand-in the tests drive directly: it records posted requests +// and lets a test emit message/error/exit without a built worker bundle. +class FakeWorker { + postedRequests: PortScanCommandRequest[] = [] + terminated = false + private listeners = new Map void>>() + + on(event: string, listener: (arg?: unknown) => void): this { + const set = this.listeners.get(event) ?? new Set() + set.add(listener) + this.listeners.set(event, set) + return this + } + + off(event: string, listener: (arg?: unknown) => void): this { + this.listeners.get(event)?.delete(listener) + return this + } + + removeAllListeners(): void { + this.listeners.clear() + } + + unref(): void {} + + async terminate(): Promise { + this.terminated = true + return 1 + } + + postMessage(request: PortScanCommandRequest): void { + this.postedRequests.push(request) + } + + emit(event: string, arg?: unknown): void { + // Copy first: the client removes its listeners synchronously during a fault. + for (const listener of Array.from(this.listeners.get(event) ?? [])) { + listener(arg) + } + } + + respond(body: PortScanCommandResponseBody): void { + const last = this.postedRequests.at(-1) + if (!last) { + throw new Error('no request posted to fake worker') + } + this.emit('message', { id: last.id, ...body }) + } +} + +function makeFactory(workers: FakeWorker[]): () => Worker { + return () => { + const worker = new FakeWorker() + workers.push(worker) + return worker as unknown as Worker + } +} + +function makeClient(workers: FakeWorker[]): PortScanCommandClient { + return new PortScanCommandClient({ workerFactory: makeFactory(workers), log() {} }) +} + +describe('PortScanCommandClient', () => { + afterEach(() => { + vi.useRealTimers() + execFileMock.mockReset() + }) + + it('dispatches one command at a time so a stalled spawn cannot fan out', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const first = client.run('lsof', ['-nP']) + const second = client.run('ps', ['-p', '1']) + await Promise.resolve() + + // Why (#11161): a second in-flight request would have its deadline armed + // while the worker's loop is still blocked inside the first uv_spawn. + expect(workers[0].postedRequests.map((request) => request.command)).toEqual(['lsof']) + + workers[0].respond({ ok: true, stdout: 'lsof-out', spawnMs: 12 }) + await expect(first).resolves.toEqual({ stdout: 'lsof-out', spawnMs: 12 }) + expect(workers[0].postedRequests.map((request) => request.command)).toEqual(['lsof', 'ps']) + + workers[0].respond({ ok: true, stdout: 'ps-out', spawnMs: 9 }) + await expect(second).resolves.toEqual({ stdout: 'ps-out', spawnMs: 9 }) + }) + + it('ignores responses that do not correlate with the active request', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].emit('message', { id: 999, ok: true, stdout: 'stale', spawnMs: 1 }) + workers[0].respond({ ok: true, stdout: 'fresh', spawnMs: 3 }) + + await expect(pending).resolves.toMatchObject({ stdout: 'fresh' }) + }) + + it('rehydrates a worker-side command timeout so the scan can back off', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].respond({ ok: false, timedOut: true, error: 'lsof timed out after 4000ms' }) + + await expect(pending).rejects.toBeInstanceOf(PortScanCommandTimeoutError) + }) + + it('reports a worker crash as a non-timeout error and respawns for the next call', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('lsof', []) + await Promise.resolve() + workers[0].emit('error', new Error('worker blew up')) + + const error = await pending.catch((err: unknown) => err) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + expect(workers[0].terminated).toBe(true) + + const next = client.run('ps', []) + await Promise.resolve() + expect(workers).toHaveLength(2) + workers[1].respond({ ok: true, stdout: 'ps-out', spawnMs: 2 }) + await expect(next).resolves.toMatchObject({ stdout: 'ps-out' }) + }) + + it('terminates a silent worker at the call deadline without arming the backoff', async () => { + vi.useFakeTimers() + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const pending = client.run('netstat', ['-ano']) + const settled = pending.catch((err: unknown) => err) + await vi.advanceTimersByTimeAsync(CALL_DEADLINE_MS) + + const error = await settled + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + expect(workers[0].terminated).toBe(true) + }) + + it('rejects overflow instead of growing the queue without bound', async () => { + const workers: FakeWorker[] = [] + const client = makeClient(workers) + + const accepted = Array.from({ length: MAX_QUEUED_CALLS + 1 }, () => client.run('lsof', [])) + const overflow = client.run('lsof', []) + + const error = await overflow.catch((err: unknown) => err) + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + + for (let i = 0; i < accepted.length; i++) { + workers[0].respond({ ok: true, stdout: 'drained', spawnMs: 1 }) + } + await expect(Promise.all(accepted)).resolves.toHaveLength(MAX_QUEUED_CALLS + 1) + }) + + it('fails closed instead of spawning the command on this thread', async () => { + const client = new PortScanCommandClient({ + workerFactory: () => { + throw new Error('worker entry not found') + }, + log() {} + }) + + const error = await client.run('lsof', []).catch((err: unknown) => err) + + expect(isPortScanWorkerUnavailableError(error)).toBe(true) + expect(execFileMock).not.toHaveBeenCalled() + }) +}) + +// Why: the packaged branch never runs in dev or e2e (both take the __dirname +// path), so it is pinned here at the path-construction level. Whether Electron's +// asar shim can load a Worker entry from inside app.asar is not testable here — +// that still needs a packaged smoke test. +describe('resolveWorkerEntryPath', () => { + const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' + + it('resolves a packaged build under resourcesPath/app.asar/out/main', () => { + const resourcesPath = join(sep, 'Applications', 'Orca.app', 'Contents', 'Resources') + + const resolved = resolveWorkerEntryPath({ + isPackaged: true, + resourcesPath, + moduleDir: join(sep, 'unpackaged', 'out', 'main') + }) + + expect(resolved.startsWith(`${resourcesPath}${sep}`)).toBe(true) + expect(resolved.slice(resourcesPath.length + 1).split(sep)).toEqual([ + 'app.asar', + 'out', + 'main', + WORKER_ENTRY_FILENAME + ]) + }) + + it('ignores resourcesPath when the app is not packaged', () => { + const moduleDir = join(sep, 'repo', 'out', 'main') + + const resolved = resolveWorkerEntryPath({ + isPackaged: false, + resourcesPath: join(sep, 'Applications', 'Orca.app', 'Contents', 'Resources'), + moduleDir + }) + + expect(resolved).toBe(join(moduleDir, WORKER_ENTRY_FILENAME)) + expect(resolved).not.toContain('app.asar') + }) + + // A rename in the build config would leave both branches pointing at a file + // that is never emitted, and only the packaged one fails silently. + it('names the entry the main build actually emits', () => { + const config = readFileSync( + join(import.meta.dirname, '..', '..', '..', 'electron.vite.config.ts'), + 'utf8' + ) + + expect(config).toContain("'port-scan-command-worker-entry': resolve(") + }) +}) + +const REAL_WORKER_BLOCK_MS = 800 + +// Mirrors the worker entry's protocol but blocks its own thread first, standing +// in for the endpoint-security hook that makes CreateProcessW take seconds. +const BLOCKING_WORKER_SCRIPT = ` +const { parentPort } = require('node:worker_threads') +const { execFile } = require('node:child_process') +parentPort.on('message', (request) => { + const startedAt = Date.now() + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${REAL_WORKER_BLOCK_MS}) + execFile(process.execPath, ['-e', ''], () => { + parentPort.postMessage({ + id: request.id, + ok: true, + stdout: 'ok', + spawnMs: Date.now() - startedAt + }) + }) +}) +` + +describe('PortScanCommandClient on a real worker thread', () => { + it('keeps the calling event loop responsive while a spawn stalls', async () => { + const client = new PortScanCommandClient({ + workerFactory: () => new Worker(BLOCKING_WORKER_SCRIPT, { eval: true }), + log() {} + }) + + let last = Date.now() + let maxStallMs = 0 + const probe = setInterval(() => { + const now = Date.now() + maxStallMs = Math.max(maxStallMs, now - last - 10) + last = now + }, 10) + try { + const results = await Promise.all([client.run('lsof', []), client.run('ps', [])]) + + expect(results.map((result) => result.stdout)).toEqual(['ok', 'ok']) + expect(results[0].spawnMs).toBeGreaterThanOrEqual(REAL_WORKER_BLOCK_MS - 100) + expect(maxStallMs).toBeLessThan(400) + } finally { + clearInterval(probe) + } + }, 30_000) +}) diff --git a/src/main/ports/port-scan-command-client.ts b/src/main/ports/port-scan-command-client.ts new file mode 100644 index 00000000000..9748a8cfc2b --- /dev/null +++ b/src/main/ports/port-scan-command-client.ts @@ -0,0 +1,364 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { Worker } from 'node:worker_threads' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + type PortScanCommandRequest, + type PortScanCommandResponse +} from './port-scan-command-protocol' + +// Why (#11161): a lazily-spawned, unref'd worker runs the port scan's probe +// spawns off the Electron main-process event loop, because libuv performs +// process creation inline on the calling thread. Lifecycle (FIFO one-at-a-time +// dispatch, per-call deadlines, respawn-on-fault, idle teardown, fail-closed) +// mirrors src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts; +// the duplicated ~150 lines are cheaper than a premature shared abstraction, so +// a third adopter should extract one. +// +// This module contains the literal text require('electron'), so it must never +// become reachable from a plain-Node fork entry (build-plugins/ +// plain-node-entry-guard.ts fails the build on that text, try/catch or not). + +// Why: the worker's own loop absorbs the spawn stall, so the client only needs +// a backstop for a wedged thread. Kept at 30s because a scan sits on the +// user-blocking localhost-label allowlist path (src/main/ipc/ +// localhost-worktree-labels.ts). +export const WORKER_STALL_GRACE_MS = 26_000 +export const CALL_DEADLINE_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WORKER_STALL_GRACE_MS +// Deliberately far longer than the 30s scan cadence so a visible window does not +// re-create the worker every tick; the renderer stops the interval when hidden, +// so this is effectively the hidden-window teardown. +export const IDLE_TEARDOWN_MS = 5 * 60_000 +export const MAX_CONSECUTIVE_DEATHS = 3 +// One scan issues at most three commands; anything beyond this is pile-up. +export const MAX_QUEUED_CALLS = 8 + +export type PortScanCommandResult = { stdout: string; spawnMs: number } +export type PortScanWorkerFactory = () => Worker + +// Distinguishes "no worker at all" from a timeout or crash so the scanner can +// log it once and callers never mistake it for a command timeout. +class PortScanWorkerUnavailableError extends Error {} + +/** True when a scan failed because the probe worker could not be started. */ +export function isPortScanWorkerUnavailableError(error: unknown): boolean { + return error instanceof PortScanWorkerUnavailableError +} + +type PendingCall = { + request: PortScanCommandRequest + resolve: (value: PortScanCommandResult) => void + reject: (error: Error) => void + timer: NodeJS.Timeout | null +} + +/** + * Main-thread bridge that runs port-scan probe commands on a persistent worker + * thread. Dispatches one command at a time (FIFO), times each call out from + * dispatch, respawns after faults (capped by `MAX_CONSECUTIVE_DEATHS`), tears + * the worker down after `IDLE_TEARDOWN_MS`, and fails closed when no worker can + * be spawned rather than moving process creation back onto the main thread. + */ +export class PortScanCommandClient { + private worker: Worker | null = null + private active: PendingCall | null = null + private queue: PendingCall[] = [] + private idleTimer: NodeJS.Timeout | null = null + private consecutiveDeaths = 0 + private nextId = 1 + private loggedWorkerUnavailable = false + private cleanupWorkerListeners: (() => void) | null = null + private readonly workerFactory: PortScanWorkerFactory + private readonly log: (message: string) => void + + constructor(options: { workerFactory: PortScanWorkerFactory; log?: (message: string) => void }) { + this.workerFactory = options.workerFactory + this.log = options.log ?? ((message) => console.warn(message)) + } + + /** + * Run one probe command on the worker. + * @param command - Executable name (lsof, ps, netstat, powershell.exe). + * @param args - Argument vector passed verbatim to execFile. + * @returns The command's stdout plus its measured process-creation latency. + */ + run(command: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + if (this.queue.length >= MAX_QUEUED_CALLS) { + reject(new Error(`Port scan command queue is full; dropped ${command}.`)) + return + } + // A fresh burst from full idle starts a new scan: clear any death count + // carried from a prior scan so the respawn cap can't drain this scan early. + if (!this.active && this.queue.length === 0) { + this.consecutiveDeaths = 0 + } + this.queue.push({ + request: { id: this.nextId++, command, args }, + resolve, + reject, + timer: null + }) + this.pump() + }) + } + + private pump(): void { + if (this.active || this.queue.length === 0) { + return + } + const worker = this.ensureWorker() + if (!worker) { + this.failQueuedAsUnavailable() + return + } + const call = this.queue.shift() + if (!call) { + return + } + this.active = call + this.clearIdleTimer() + // Why (#11161): one at a time. uv_spawn blocks the worker's own loop, so a + // second concurrent request would have its deadline armed while the first + // spawn is still stalling the thread, producing a false timeout. + call.timer = setTimeout(() => this.onDeadline(call), CALL_DEADLINE_MS) + call.timer.unref?.() + worker.postMessage(call.request) + } + + private ensureWorker(): Worker | null { + if (this.worker) { + return this.worker + } + try { + const worker = this.workerFactory() + const onMessage = (response: PortScanCommandResponse): void => this.onMessage(response) + const onError = (error: Error): void => this.onWorkerFault(error) + const onExit = (code: number): void => this.onWorkerExit(code) + worker.on('message', onMessage) + worker.on('error', onError) + worker.on('exit', onExit) + this.cleanupWorkerListeners = () => { + worker.off('message', onMessage) + worker.off('error', onError) + worker.off('exit', onExit) + } + // Never keep the app alive for a port scan. + worker.unref?.() + this.worker = worker + return worker + } catch (err) { + // Why (#11161): never fall back to in-process execFile here; a missing + // bundle must report port scanning as unavailable rather than reintroduce + // the main-thread freeze this worker boundary exists to prevent. + if (!this.loggedWorkerUnavailable) { + this.loggedWorkerUnavailable = true + this.log(`[workspace-ports] probe worker unavailable. ${errorMessage(err)}`) + } + return null + } + } + + private onMessage(response: PortScanCommandResponse): void { + const call = this.active + if (!call || call.request.id !== response.id) { + return + } + this.consecutiveDeaths = 0 + if (response.ok) { + this.settle(call, () => call.resolve({ stdout: response.stdout, spawnMs: response.spawnMs })) + } else { + const error = response.timedOut + ? new PortScanCommandTimeoutError(response.error) + : new Error(response.error) + this.settle(call, () => call.reject(error)) + } + this.afterSettle() + } + + private onDeadline(call: PendingCall): void { + if (this.active !== call) { + return + } + // Plain Error on purpose: a wedged worker is not a command timeout and must + // never feed the scanner's timeout backoff. + this.onWorkerFault(new Error(`Port scan probe worker stalled after ${CALL_DEADLINE_MS}ms`)) + } + + private onWorkerExit(code: number): void { + // A clean self-exit is not a death, but the stale handle must be dropped or + // the next dispatch would post into a dead worker and stall to its deadline. + if (code === 0 && !this.active && this.queue.length === 0) { + this.destroyWorker() + return + } + this.onWorkerFault(new Error(`Port scan probe worker exited with code ${code}`)) + } + + private onWorkerFault(error: Error): void { + const failed = this.active + this.destroyWorker() + this.consecutiveDeaths++ + if (failed) { + this.settle(failed, () => failed.reject(error)) + } + if (this.consecutiveDeaths >= MAX_CONSECUTIVE_DEATHS) { + this.drainQueueAfterCrashLoop(error) + return + } + if (this.queue.length > 0) { + this.pump() + } + } + + private drainQueueAfterCrashLoop(error: Error): void { + const pending = this.queue + this.queue = [] + this.consecutiveDeaths = 0 + const drainError = new Error(`Port scan probe worker crashed repeatedly (${error.message})`) + for (const call of pending) { + this.settle(call, () => call.reject(drainError)) + } + } + + private failQueuedAsUnavailable(): void { + const pending = this.queue + this.queue = [] + for (const call of pending) { + this.settle(call, () => + call.reject(new PortScanWorkerUnavailableError('port scan probe worker spawn failed')) + ) + } + } + + private settle(call: PendingCall, run: () => void): void { + if (call.timer) { + clearTimeout(call.timer) + call.timer = null + } + if (this.active === call) { + this.active = null + } + run() + } + + private afterSettle(): void { + if (this.queue.length > 0) { + this.pump() + } else { + this.scheduleIdleTeardown() + } + } + + private scheduleIdleTeardown(): void { + this.clearIdleTimer() + if (!this.worker) { + return + } + this.idleTimer = setTimeout(() => this.teardownIfIdle(), IDLE_TEARDOWN_MS) + this.idleTimer.unref?.() + } + + private teardownIfIdle(): void { + this.idleTimer = null + // Only tear down with nothing active AND nothing queued: a request arriving + // as the timer fires must never be lost to a self-exiting worker. + if (this.active || this.queue.length > 0) { + return + } + this.destroyWorker() + } + + private clearIdleTimer(): void { + if (this.idleTimer) { + clearTimeout(this.idleTimer) + this.idleTimer = null + } + } + + private destroyWorker(): void { + this.clearIdleTimer() + const worker = this.worker + this.worker = null + if (!worker) { + return + } + this.cleanupWorkerListeners?.() + this.cleanupWorkerListeners = null + worker.removeAllListeners() + // Terminating can orphan a probe child mid-spawn; the worker reaps what it + // can on exit, and every probe here is short-lived. + void worker.terminate().catch(() => undefined) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +const WORKER_ENTRY_FILENAME = 'port-scan-command-worker-entry.js' + +/** Where the built worker entry can live: packaged resources or the build dir. */ +export type WorkerEntryLayout = { + isPackaged: boolean + resourcesPath: string + moduleDir: string +} + +/** + * Resolve the built worker entry for one runtime layout. + * @param layout - Packaged flag plus both candidate roots. + * @returns Path passed to `new Worker()`. + */ +export function resolveWorkerEntryPath(layout: WorkerEntryLayout): string { + // Packaged builds leave this entry inside app.asar — only forked child + // processes are asarUnpack'd — so it resolves off resourcesPath rather than + // the bundler's __dirname, matching the shipped stt/warp/opencode workers. + // Split out from the electron read so the packaged branch is testable without + // a packaged build. + if (layout.isPackaged) { + return join(layout.resourcesPath, 'app.asar', 'out', 'main', WORKER_ENTRY_FILENAME) + } + return join(layout.moduleDir, WORKER_ENTRY_FILENAME) +} + +function currentWorkerEntryLayout(): WorkerEntryLayout { + let app: { isPackaged: boolean } | null = null + try { + app = require('electron').app ?? null + } catch { + app = null + } + return { + isPackaged: app?.isPackaged === true, + resourcesPath: process.resourcesPath, + moduleDir: __dirname + } +} + +function defaultWorkerFactory(): Worker { + const workerPath = resolveWorkerEntryPath(currentWorkerEntryLayout()) + // Why: a missing built entry must throw synchronously so the client can fail + // closed before it waits on a worker that can never post a result. + if (!existsSync(workerPath)) { + throw new Error(`Port scan command worker entry not found: ${workerPath}`) + } + return new Worker(workerPath) +} + +let sharedClient: PortScanCommandClient | null = null + +/** + * Run a port-scan probe command through the process-wide worker client. + * @param command - Executable name (lsof, ps, netstat, powershell.exe). + * @param args - Argument vector passed verbatim to execFile. + * @returns The command's stdout plus its measured process-creation latency. + */ +export function runPortScanCommand( + command: string, + args: string[] +): Promise { + sharedClient ??= new PortScanCommandClient({ workerFactory: defaultWorkerFactory }) + return sharedClient.run(command, args) +} diff --git a/src/main/ports/port-scan-command-execution.test.ts b/src/main/ports/port-scan-command-execution.test.ts new file mode 100644 index 00000000000..15e97d4a3a9 --- /dev/null +++ b/src/main/ports/port-scan-command-execution.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { runPortScanCommandInProcess } from './port-scan-command-execution' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + WATCHDOG_GRACE_MS +} from './port-scan-command-protocol' + +const execFileMock = vi.hoisted(() => vi.fn()) + +vi.mock('child_process', () => ({ + execFile: execFileMock +})) + +// Why (#11161): must outlast the whole watchdog budget, otherwise a watchdog +// armed before execFile still survives the stall and the ordering goes unpinned. +const SPAWN_STALL_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WATCHDOG_GRACE_MS + 200 +const LSOF_OUTPUT = ['p123', 'cnode', 'n127.0.0.1:5173'].join('\n') + +/** Emulates a hooked CreateProcessW: blocks the calling thread inside uv_spawn. */ +function blockCallingThread(ms: number): void { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) +} + +describe('runPortScanCommandInProcess', () => { + afterEach(() => { + vi.useRealTimers() + execFileMock.mockReset() + }) + + it('arms the watchdog only after process creation returns', async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + blockCallingThread(SPAWN_STALL_MS) + // The command itself is healthy once it finally starts. + setTimeout(() => (callback as (e: null, out: string) => void)(null, LSOF_OUTPUT), 5) + return { kill: vi.fn() } + } + ) + + const result = await runPortScanCommandInProcess('lsof', ['-nP', '-iTCP']) + + expect(result.stdout).toBe(LSOF_OUTPUT) + expect(result.spawnMs).toBeGreaterThanOrEqual(SPAWN_STALL_MS - 100) + }) + + it('kills the child and times out when the callback never arrives', async () => { + vi.useFakeTimers() + const killMock = vi.fn() + execFileMock.mockImplementation(() => ({ kill: killMock })) + + let settled = false + const promise = runPortScanCommandInProcess('lsof', []).catch((error: unknown) => { + settled = true + return error + }) + + await vi.advanceTimersByTimeAsync(PORT_SCAN_COMMAND_TIMEOUT_MS) + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(WATCHDOG_GRACE_MS) + + expect(await promise).toBeInstanceOf(PortScanCommandTimeoutError) + expect(killMock).toHaveBeenCalled() + }) + + it("classifies Node's own execFile timeout kill as a command timeout", async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + const killed = Object.assign(new Error('Command failed: lsof'), { + killed: true, + signal: 'SIGTERM' + }) + setTimeout(() => (callback as (e: Error) => void)(killed), 0) + return { kill: vi.fn() } + } + ) + + await expect(runPortScanCommandInProcess('lsof', [])).rejects.toBeInstanceOf( + PortScanCommandTimeoutError + ) + }) + + it('leaves a genuine command failure unclassified so the scan does not back off', async () => { + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + setTimeout(() => (callback as (e: Error) => void)(new Error('spawn ENOENT')), 0) + return { kill: vi.fn() } + } + ) + + const error = await runPortScanCommandInProcess('lsof', []).catch((err: unknown) => err) + + expect(error).toBeInstanceOf(Error) + expect(error).not.toBeInstanceOf(PortScanCommandTimeoutError) + }) +}) diff --git a/src/main/ports/port-scan-command-execution.ts b/src/main/ports/port-scan-command-execution.ts new file mode 100644 index 00000000000..9c63fc9aaa3 --- /dev/null +++ b/src/main/ports/port-scan-command-execution.ts @@ -0,0 +1,115 @@ +import { execFile, type ChildProcess } from 'node:child_process' +import { + PORT_SCAN_COMMAND_TIMEOUT_MS, + PortScanCommandTimeoutError, + WATCHDOG_GRACE_MS, + portScanCommandTimeoutMessage +} from './port-scan-command-protocol' + +// Why (#11161): libuv runs uv_spawn inline on whichever event loop calls it, so +// an endpoint-security hook on CreateProcessW stalls that thread for the whole +// spawn. This module is only ever entered from the worker thread +// (port-scan-command-worker-entry.ts); nothing on the main thread may import it. + +const WATCHDOG_TIMEOUT_MS = PORT_SCAN_COMMAND_TIMEOUT_MS + WATCHDOG_GRACE_MS + +const activeChildren = new Set() + +/** + * Run a port-scan probe command and report how long process creation took. + * @param command - Executable name resolved against the worker's PATH. + * @param args - Argument vector passed verbatim, never shell-interpolated. + * @returns The command's stdout plus `spawnMs`, the measured process-creation + * latency callers use to skip optional follow-up commands on a stalled host. + * @throws PortScanCommandTimeoutError when the command itself outran its budget. + */ +export async function runPortScanCommandInProcess( + command: string, + args: string[] +): Promise<{ stdout: string; spawnMs: number }> { + return await new Promise((resolve, reject) => { + let settled = false + let timer: NodeJS.Timeout | null = null + let spawnMs = 0 + let child: ChildProcess | undefined + + const settle = (callback: () => void): void => { + if (settled) { + return + } + settled = true + if (timer) { + clearTimeout(timer) + } + if (child) { + activeChildren.delete(child) + } + callback() + } + + const startedAt = Date.now() + try { + child = execFile( + command, + args, + { + timeout: PORT_SCAN_COMMAND_TIMEOUT_MS, + maxBuffer: 2 * 1024 * 1024, + windowsHide: true + }, + (error, stdout) => { + if (error) { + // Node kills its own execFile timeout with a signal, which surfaces + // as killed:true — the only way to tell a timeout from a real error. + const timedOut = (error as { killed?: boolean }).killed === true + settle(() => + reject( + timedOut + ? new PortScanCommandTimeoutError( + portScanCommandTimeoutMessage(command, PORT_SCAN_COMMAND_TIMEOUT_MS) + ) + : error + ) + ) + return + } + settle(() => resolve({ stdout: String(stdout), spawnMs })) + } + ) + } catch (error) { + settle(() => reject(error)) + return + } + + // Why (#11161): measured after execFile returns, because that call blocks + // for the whole of process creation. Arming the watchdog earlier would + // charge the spawn stall against the command's budget and fire immediately. + spawnMs = Date.now() - startedAt + if (settled || !child) { + return + } + activeChildren.add(child) + timer = setTimeout(() => { + settle(() => { + child?.kill() + reject( + new PortScanCommandTimeoutError( + portScanCommandTimeoutMessage(command, WATCHDOG_TIMEOUT_MS) + ) + ) + }) + }, WATCHDOG_TIMEOUT_MS) + }) +} + +/** Best-effort reap so a terminated worker does not orphan an in-flight probe. */ +export function killActivePortScanCommands(): void { + for (const child of activeChildren) { + try { + child.kill() + } catch { + // Already exited; nothing to reap. + } + } + activeChildren.clear() +} diff --git a/src/main/ports/port-scan-command-import-boundary.test.ts b/src/main/ports/port-scan-command-import-boundary.test.ts new file mode 100644 index 00000000000..bd9b9d05fb8 --- /dev/null +++ b/src/main/ports/port-scan-command-import-boundary.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why (#11161): libuv runs process creation inline on the calling event loop, +// so the port scan only stays off CrBrowserMain while these main-thread modules +// spawn nothing themselves. Spawning belongs to port-scan-command-execution.ts, +// which only the worker entry imports. +const MAIN_THREAD_MODULES = ['local-workspace-port-scanner.ts', 'port-scan-command-client.ts'] + +describe('port scan main-thread spawn boundary', () => { + it.each(MAIN_THREAD_MODULES)('keeps %s free of child_process', (fileName) => { + const source = readFileSync(join(import.meta.dirname, fileName), 'utf8') + + expect(source).not.toMatch(/from\s+['"](node:)?child_process['"]/) + expect(source).not.toMatch(/require\(\s*['"](node:)?child_process['"]\s*\)/) + }) +}) diff --git a/src/main/ports/port-scan-command-protocol.ts b/src/main/ports/port-scan-command-protocol.ts new file mode 100644 index 00000000000..02beeb982a8 --- /dev/null +++ b/src/main/ports/port-scan-command-protocol.ts @@ -0,0 +1,36 @@ +// Why (#11161): request/response shapes plus the timeout error shared by the +// port-scan worker entry and its main-thread client. Kept free of Electron, +// node:worker_threads and node:child_process so importing it from either side +// can never drag the other side's dependencies across the boundary. + +export const PORT_SCAN_COMMAND_TIMEOUT_MS = 4_000 +// Node's own execFile timeout is the primary killer; the manual watchdog only +// covers "the callback never arrived", so it must fire strictly later. +export const WATCHDOG_GRACE_MS = 1_000 + +export type PortScanCommandRequest = { + id: number + command: string + args: string[] +} + +export type PortScanCommandResponse = + | { id: number; ok: true; stdout: string; spawnMs: number } + | { id: number; ok: false; timedOut: boolean; error: string } + +/** + * Raised when a port-scan command was killed after exceeding its budget. Errors + * do not survive structured clone as subclasses, so the worker reports a + * `timedOut` flag and the client reconstructs this type from it. + */ +export class PortScanCommandTimeoutError extends Error { + constructor(message: string) { + super(message) + this.name = 'PortScanCommandTimeoutError' + } +} + +/** Shared wording so worker-side and client-side timeouts read identically. */ +export function portScanCommandTimeoutMessage(command: string, timeoutMs: number): string { + return `${command} timed out after ${timeoutMs}ms` +} diff --git a/src/main/ports/port-scan-command-worker-entry.ts b/src/main/ports/port-scan-command-worker-entry.ts new file mode 100644 index 00000000000..b5cb5ad5a3a --- /dev/null +++ b/src/main/ports/port-scan-command-worker-entry.ts @@ -0,0 +1,53 @@ +import { parentPort } from 'node:worker_threads' +import { + killActivePortScanCommands, + runPortScanCommandInProcess +} from './port-scan-command-execution' +import { + PortScanCommandTimeoutError, + type PortScanCommandRequest, + type PortScanCommandResponse +} from './port-scan-command-protocol' + +// Why (#11161): process creation blocks the event loop that issues it. Running +// the port scan's probe commands on this worker thread keeps an EDR-hooked +// CreateProcessW off CrBrowserMain. The client dispatches one request at a +// time, so this loop stays serial; imports must remain electron-free. + +if (!parentPort) { + throw new Error('Port scan command worker must run with a parent port.') +} +const port = parentPort + +process.once('exit', killActivePortScanCommands) + +async function handleRequest(request: PortScanCommandRequest): Promise { + try { + const { stdout, spawnMs } = await runPortScanCommandInProcess(request.command, request.args) + return { id: request.id, ok: true, stdout, spawnMs } + } catch (err) { + return { + id: request.id, + ok: false, + timedOut: err instanceof PortScanCommandTimeoutError, + error: err instanceof Error ? err.message : String(err) + } + } +} + +port.on('message', (request: PortScanCommandRequest) => { + void handleRequest(request).then((response) => { + try { + port.postMessage(response) + } catch { + // A non-cloneable result would otherwise post nothing and leave the client + // waiting out its deadline; fail that request fast instead. + port.postMessage({ + id: request.id, + ok: false, + timedOut: false, + error: 'Port scan command result could not be serialized.' + }) + } + }) +}) diff --git a/src/main/ports/workspace-port-ownership.test.ts b/src/main/ports/workspace-port-ownership.test.ts new file mode 100644 index 00000000000..b367fcbf29c --- /dev/null +++ b/src/main/ports/workspace-port-ownership.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { killWorkspacePort } from './workspace-port-ownership' + +const scanWorkspacePortsMock = vi.hoisted(() => vi.fn()) + +vi.mock('./local-workspace-port-scanner', () => ({ + scanWorkspacePorts: scanWorkspacePortsMock +})) + +const worktrees = [{ id: 'repo::/repo', repoId: 'repo', displayName: 'main', path: '/repo' }] + +describe('killWorkspacePort', () => { + afterEach(() => { + scanWorkspacePortsMock.mockReset() + vi.restoreAllMocks() + }) + + // Regression for #11161 review: on an EDR-hooked host the background poller + // alternates the metadata skip, so an unscoped skip would fail Stop with + // "Only workspace-owned local processes can be stopped here" every other try. + it('requires owner metadata from the authorizing re-scan', async () => { + scanWorkspacePortsMock.mockResolvedValue({ platform: 'darwin', scannedAt: 0, ports: [] }) + + await killWorkspacePort(worktrees, { pid: 123, port: 5173 }) + + expect(scanWorkspacePortsMock).toHaveBeenCalledWith(worktrees, undefined, { + requireMetadata: true + }) + }) + + it('refuses a pid the re-scan does not attribute to a workspace', async () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true) + scanWorkspacePortsMock.mockResolvedValue({ + platform: 'darwin', + scannedAt: 0, + ports: [ + { + id: '127.0.0.1:5173:123', + bindHost: '127.0.0.1', + connectHost: '127.0.0.1', + port: 5173, + pid: 123, + protocol: 'http', + kind: 'external' + } + ] + }) + + const result = await killWorkspacePort(worktrees, { pid: 123, port: 5173 }) + + expect(result).toEqual({ + ok: false, + reason: 'Only workspace-owned local processes can be stopped here.' + }) + expect(killSpy).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ports/workspace-port-ownership.ts b/src/main/ports/workspace-port-ownership.ts index 9ae5bf6582a..9f7df2465cb 100644 --- a/src/main/ports/workspace-port-ownership.ts +++ b/src/main/ports/workspace-port-ownership.ts @@ -8,7 +8,7 @@ import type { WorkspacePortProbe, WorkspacePortScanResult } from '../../shared/workspace-ports' -import { scanWorkspacePorts } from './local-workspace-port-scanner' +import { scanWorkspacePorts, type WorkspacePortScanOptions } from './local-workspace-port-scanner' export type WorkspacePortProbeInput = WorkspacePortProbe & { connectionId?: string | null @@ -82,7 +82,9 @@ export async function killWorkspacePort( return { ok: false, reason: 'Invalid process or port.' } } - const scan = await scanWorkspacePorts([...worktrees]) + // Why (#11161): this re-scan is the authorization check for SIGTERM, so it + // must never land on a cycle that skipped the owner-attribution metadata. + const scan = await scanWorkspacePorts([...worktrees], undefined, { requireMetadata: true }) const port = scan.ports.find( (candidate) => candidate.pid === args.pid && candidate.port === args.port ) @@ -113,7 +115,8 @@ export async function killWorkspacePort( } export async function scanWorkspacePortProbes( - worktrees: readonly WorkspacePortProbe[] + worktrees: readonly WorkspacePortProbe[], + options?: WorkspacePortScanOptions ): Promise { - return scanWorkspacePorts([...worktrees]) + return scanWorkspacePorts([...worktrees], undefined, options) } diff --git a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts index f92b72b09a8..1a384a32f72 100644 --- a/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts +++ b/src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts @@ -40,6 +40,8 @@ const { SshRelaySession } = await import('./ssh-relay-session') const SSH_LEAF_ID = '11111111-1111-4111-8111-111111111111' const REPLAY_LEAF_ID = '22222222-2222-4222-8222-222222222222' const BAD_LEAF_ID = '33333333-3333-4333-8333-333333333333' +const COMPACT_PROMPT_ID = '44444444-4444-4444-8444-444444444444' +const PREVIOUS_PROMPT_ID = '55555555-5555-4555-8555-555555555555' type CapturedStatus = { paneKey: string @@ -295,6 +297,68 @@ describe('SshRelaySession agent hooks over a fake relay transport', () => { }) }) + it('stamps SSH ownership and settles only the exact manual compact identity', async () => { + relay = createFakeRelay() + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: relay.transport, + serverBuildId: 'test-relay-build', + platform: 'linux-x64' + }) + const events: CapturedStatus[] = [] + captureAgentStatuses(events) + session = createSession('conn-compact') + await session.establish({} as SshConnection) + + const compactEnvelope = ( + hookEventName: 'UserPromptSubmit' | 'PreCompact' | 'PostCompact', + state: 'working' | 'done' + ): AgentHookRelayEnvelope => + makeEnvelope({ + source: 'claude', + hookEventName, + providerPromptId: + hookEventName === 'UserPromptSubmit' ? PREVIOUS_PROMPT_ID : COMPACT_PROMPT_ID, + compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : 'manual', + providerSession: { key: 'session_id', id: 'claude-session' }, + hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined, + payload: { + state, + prompt: 'work before compact', + agentType: 'claude' + } + }) + + relay.notifyAgentHook(compactEnvelope('UserPromptSubmit', 'working')) + relay.notifyAgentHook(compactEnvelope('PreCompact', 'working')) + await waitForStatusCount(events, 2) + relay.notifyAgentHook({ + ...compactEnvelope('PostCompact', 'done'), + providerPromptId: undefined + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(2) + + relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) + await waitForStatusCount(events, 3) + + expect(events.at(-1)).toMatchObject({ + connectionId: 'conn-compact', + payload: { state: 'done', prompt: 'work before compact', agentType: 'claude' } + }) + expect( + agentHookServer._getStateForTests().lastStatusByPaneKey.values().next().value + ).toMatchObject({ + source: 'claude', + providerPromptId: COMPACT_PROMPT_ID, + compactTrigger: undefined, + connectionId: 'conn-compact' + }) + + relay.notifyAgentHook(compactEnvelope('PostCompact', 'done')) + await new Promise((resolve) => setImmediate(resolve)) + expect(events).toHaveLength(3) + }) + it('clears stamped status on reconnect loss but not final shutdown', async () => { const initialRelay = createFakeRelay() relay = createFakeRelay() diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 3f1c5105d29..34bdf498843 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1342,6 +1342,9 @@ export class SshRelaySession { hasExplicitPrompt?: unknown promptInteractionKey?: unknown hookEventName?: unknown + source?: unknown + providerPromptId?: unknown + compactTrigger?: unknown toolUseId?: unknown toolAgentId?: unknown toolAgentType?: unknown @@ -1371,6 +1374,9 @@ export class SshRelaySession { : undefined, hookEventName: typeof envelope.hookEventName === 'string' ? envelope.hookEventName : undefined, + source: envelope.source, + providerPromptId: envelope.providerPromptId, + compactTrigger: envelope.compactTrigger, toolUseId: typeof envelope.toolUseId === 'string' ? envelope.toolUseId : undefined, toolAgentId: typeof envelope.toolAgentId === 'string' ? envelope.toolAgentId : undefined, toolAgentType: diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 512467eb724..eaf9f42aeec 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -77,6 +77,66 @@ describe('patchPackagedProcessPath', () => { expect(segments).toContain(join('/Users/tester', 'bin')) }) + it('omits Linux-only snap/Linuxbrew dirs but keeps Nix on packaged darwin runs', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('darwin') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + process.env.HOME = '/Users/tester' + process.env.PATH = '/usr/bin:/bin' + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(':') + // Why: neither has a macOS installer, so both are phantom PATH entries. + expect(segments).not.toContain('/snap/bin') + expect(segments).not.toContain('/home/linuxbrew/.linuxbrew/bin') + // Why: Nix does ship a macOS default profile, so it stays seeded. + expect(segments).toContain('/nix/var/nix/profiles/default/bin') + expect(segments).toContain('/opt/homebrew/bin') + expect(segments).toContain('/usr/local/bin') + }) + + it('keeps snap, Linuxbrew, and Nix dirs for packaged linux runs', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('linux') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + process.env.HOME = '/home/tester' + process.env.PATH = '/usr/bin:/bin' + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(':') + expect(segments).toContain('/snap/bin') + expect(segments).toContain('/home/linuxbrew/.linuxbrew/bin') + expect(segments).toContain('/nix/var/nix/profiles/default/bin') + expect(segments.indexOf('/usr/local/sbin')).toBeLessThan(segments.indexOf('/snap/bin')) + expect(segments.indexOf('/home/linuxbrew/.linuxbrew/bin')).toBeLessThan( + segments.indexOf('/nix/var/nix/profiles/default/bin') + ) + }) + + it('omits snap/Linuxbrew but keeps Nix on non-Linux POSIX platforms', async () => { + const { app } = await import('electron') + const { patchPackagedProcessPath } = await import('./configure-process') + + setPlatform('freebsd') + Object.defineProperty(app, 'isPackaged', { configurable: true, value: true }) + process.env.HOME = '/home/tester' + process.env.PATH = '/usr/bin:/bin' + + patchPackagedProcessPath() + + const segments = (process.env.PATH ?? '').split(':') + expect(segments).not.toContain('/snap/bin') + expect(segments).not.toContain('/home/linuxbrew/.linuxbrew/bin') + expect(segments).toContain('/nix/var/nix/profiles/default/bin') + expect(segments).toContain('/usr/local/bin') + }) + it('leaves PATH untouched when the app is not packaged', async () => { const { app } = await import('electron') const { patchPackagedProcessPath } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 292f6195258..e545d89da9c 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -99,15 +99,14 @@ export function patchPackagedProcessPath(): void { const extraPaths: string[] = [] if (process.platform !== 'win32') { - extraPaths.push( - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/bin', - '/usr/local/sbin', - '/snap/bin', - '/home/linuxbrew/.linuxbrew/bin', - '/nix/var/nix/profiles/default/bin' - ) + extraPaths.push('/opt/homebrew/bin', '/opt/homebrew/sbin', '/usr/local/bin', '/usr/local/sbin') + + if (process.platform === 'linux') { + // Why: snap and Linuxbrew ship on Linux only, so seeding them elsewhere adds phantom PATH entries every spawn must stat. + extraPaths.push('/snap/bin', '/home/linuxbrew/.linuxbrew/bin') + } + + extraPaths.push('/nix/var/nix/profiles/default/bin') if (home) { extraPaths.push( diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 3d435ee9477..3048b2d0f87 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -311,6 +311,7 @@ describe('discoverCommitMessageModelsLocal', () => { expect(result).toMatchObject({ success: true, + catalogOrigin: 'spec', defaultModelId: 'smart' }) expect(spawnMock).not.toHaveBeenCalled() @@ -348,6 +349,94 @@ describe('discoverCommitMessageModelsLocal', () => { ) }) + it('writes the Claude list_models request to stdin and parses the control response', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { on: vi.fn(), end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('claude', undefined) + + listeners.get('stdout:data')?.( + Buffer.from( + `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { value: 'default', displayName: 'Default (recommended)' }, + { + value: 'opus[1m]', + displayName: 'Opus (1M context)', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { value: 'sonnet', displayName: 'Sonnet' }, + { value: 'haiku', displayName: 'Haiku' } + ] + } + } + })}\n` + ) + ) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + catalogOrigin: 'probe', + defaultModelId: 'sonnet', + models: [ + { id: 'opus[1m]', label: 'Opus (1M context)' }, + { id: 'sonnet', label: 'Sonnet' }, + { id: 'haiku', label: 'Haiku' } + ] + }) + expect(spawnMock).toHaveBeenCalledWith( + 'claude', + ['-p', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose'], + expect.objectContaining({ windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }) + ) + expect(child.stdin.end).toHaveBeenCalledWith(expect.stringContaining('"list_models"')) + }) + + it('falls back to the Claude seed models when the CLI lacks list_models', async () => { + const listeners = new Map void>() + const child = { + pid: 123, + kill: vi.fn(), + stdout: { on: vi.fn((event, callback) => listeners.set(`stdout:${event}`, callback)) }, + stderr: { on: vi.fn((event, callback) => listeners.set(`stderr:${event}`, callback)) }, + stdin: { on: vi.fn(), end: vi.fn() }, + on: vi.fn((event, callback) => listeners.set(event, callback)) + } + spawnMock.mockReturnValue(child as never) + + const pending = discoverCommitMessageModelsLocal('claude', undefined) + + // Captured from claude 2.1.100: the unsupported subtype still exits 0. + listeners.get('stdout:data')?.( + Buffer.from( + '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n' + ) + ) + listeners.get('close')?.(0) + + await expect(pending).resolves.toMatchObject({ + success: true, + catalogOrigin: 'spec', + defaultModelId: 'sonnet', + models: [{ id: 'haiku' }, { id: 'sonnet' }, { id: 'opus' }] + }) + }) + it('discovers dynamic models through the configured agent command override', async () => { const listeners = new Map void>() const child = { diff --git a/src/main/text-generation/commit-message-text-generation.ts b/src/main/text-generation/commit-message-text-generation.ts index 98314f6975b..7115ce9d3f4 100644 --- a/src/main/text-generation/commit-message-text-generation.ts +++ b/src/main/text-generation/commit-message-text-generation.ts @@ -75,6 +75,7 @@ export type DiscoverCommitMessageModelsResult = capability: CommitMessageAgentCapability models: CommitMessageModelCapability[] defaultModelId: string + catalogOrigin: 'probe' | 'spec' } | { success: false; error: string } @@ -229,7 +230,8 @@ function userFacingUnsafeWindowsBatchArgs(label: string): string { function toModelDiscoveryCapability( spec: NonNullable>, models = spec.models, - defaultModelId = spec.defaultModelId + defaultModelId = spec.defaultModelId, + catalogOrigin: 'probe' | 'spec' = 'spec' ): Extract { return { success: true, @@ -241,7 +243,8 @@ function toModelDiscoveryCapability( models }, models, - defaultModelId + defaultModelId, + catalogOrigin } } @@ -281,7 +284,7 @@ function finalizeModelDiscoveryOutput( const defaultModelId = models.some((model) => model.id === spec.defaultModelId) ? spec.defaultModelId : models[0].id - return toModelDiscoveryCapability(spec, models, defaultModelId) + return toModelDiscoveryCapability(spec, models, defaultModelId, 'probe') } function planModelDiscovery( @@ -301,7 +304,7 @@ function planModelDiscovery( plan: { binary: command.binary, args: [...command.prefixArgs, ...modelDiscovery.args], - stdinPayload: null, + stdinPayload: modelDiscovery.stdinPayload ?? null, label: spec.label } } @@ -330,6 +333,7 @@ export async function discoverCommitMessageModelsLocal( const result = new Promise((resolve) => { let child: ChildProcess const spawnEnv = env ?? process.env + let discoveryStdin: string | null = null try { const planned = planModelDiscovery(spec, agentCommandOverride) if (!planned.ok) { @@ -337,11 +341,13 @@ export async function discoverCommitMessageModelsLocal( resolve({ success: false, error: planned.error }) return } + discoveryStdin = planned.plan.stdinPayload + const stdinMode = discoveryStdin === null ? 'ignore' : 'pipe' if (process.platform === 'win32' && options.wslDistro) { child = wslAwareSpawn(planned.plan.binary, planned.plan.args, { cwd: options.cwd, env: buildWslLauncherEnv(env), - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [stdinMode, 'pipe', 'pipe'], windowsHide: true, wslDistro: options.wslDistro, useWslLoginShell: true @@ -356,10 +362,16 @@ export async function discoverCommitMessageModelsLocal( const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(resolvedBinary, planned.plan.args) child = spawn(spawnCmd, spawnArgs, { env: spawnEnv, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [stdinMode, 'pipe', 'pipe'], windowsHide: true }) } + if (discoveryStdin !== null) { + // Why: a CLI that rejects the args exits before reading stdin; the + // resulting EPIPE must surface as exit-code fallback, not a crash. + child.stdin?.on?.('error', () => {}) + child.stdin?.end(discoveryStdin) + } } catch (error) { markProcessClosed() console.error('[commit-message] Failed to spawn model discovery:', error) diff --git a/src/main/updater.install-failure-cause.test.ts b/src/main/updater.install-failure-cause.test.ts new file mode 100644 index 00000000000..5e61ac19554 --- /dev/null +++ b/src/main/updater.install-failure-cause.test.ts @@ -0,0 +1,333 @@ +import os from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as TracerModule from './observability/tracer' +import type * as UpdaterModule from './updater' + +const { + appMock, + browserWindowMock, + nativeUpdaterMock, + autoUpdaterMock, + isMock, + killAllPtyMock, + recordUpdaterLifecycleMock +} = vi.hoisted(() => { + const appEventHandlers = new Map void)[]>() + const eventHandlers = new Map void)[]>() + + const appOn = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = appEventHandlers.get(event) ?? [] + handlers.push(handler) + appEventHandlers.set(event, handlers) + return appMock + }) + + const on = vi.fn((event: string, handler: (...args: unknown[]) => void) => { + const handlers = eventHandlers.get(event) ?? [] + handlers.push(handler) + eventHandlers.set(event, handlers) + return autoUpdaterMock + }) + + const emit = (event: string, ...args: unknown[]) => { + for (const handler of eventHandlers.get(event) ?? []) { + handler(...args) + } + } + + const reset = () => { + appEventHandlers.clear() + appOn.mockClear() + eventHandlers.clear() + on.mockClear() + autoUpdaterMock.checkForUpdates.mockReset() + autoUpdaterMock.downloadUpdate.mockReset() + autoUpdaterMock.quitAndInstall.mockReset() + autoUpdaterMock.setFeedURL.mockClear() + } + + const autoUpdaterMock = { + autoDownload: false, + autoInstallOnAppQuit: false, + on, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + quitAndInstall: vi.fn(), + setFeedURL: vi.fn(), + emit, + reset + } + + return { + appMock: { + isPackaged: true, + getVersion: vi.fn(() => '1.4.162'), + on: appOn, + quit: vi.fn(), + exit: vi.fn() + }, + browserWindowMock: { getAllWindows: vi.fn(() => []) }, + nativeUpdaterMock: { on: vi.fn() }, + autoUpdaterMock, + isMock: { dev: false }, + killAllPtyMock: vi.fn(), + recordUpdaterLifecycleMock: vi.fn() + } +}) + +vi.mock('electron', () => ({ + app: appMock, + BrowserWindow: browserWindowMock, + autoUpdater: nativeUpdaterMock, + powerMonitor: { on: vi.fn() }, + shell: { openExternal: vi.fn() }, + net: { fetch: vi.fn() } +})) + +vi.mock('electron-updater', () => ({ autoUpdater: autoUpdaterMock })) +vi.mock('./electron-updater-loader', () => ({ + loadElectronAutoUpdater: () => autoUpdaterMock +})) +vi.mock('@electron-toolkit/utils', () => ({ is: isMock })) +vi.mock('./ipc/pty', () => ({ killAllPty: killAllPtyMock })) +vi.mock('./updater-changelog', () => ({ + fetchChangelog: vi.fn().mockResolvedValue(null) +})) +vi.mock('./updater-nudge', () => ({ + fetchNudge: vi.fn().mockResolvedValue(null), + shouldApplyNudge: vi.fn().mockReturnValue(false) +})) +vi.mock('./updater-lifecycle-diagnostics', () => ({ + recordUpdaterLifecycle: recordUpdaterLifecycleMock +})) + +// The real electron-updater DebUpdater failure text when elevation is impossible. +const DEB_ELEVATION_ERROR = + 'Error: Command failed: /usr/bin/pkexec --disable-internal-agent "/bin/bash" "-c" "dpkg -i \'/home/u/.cache/orca-updater/pending/orca-ide_1.4.163_amd64.deb\'"\npkexec must be setuid root' + +// electron-updater's ERR_UPDATER_INVALID_SIGNATURE text, which drives its own card in UpdateCard. +const WINDOWS_SIGNATURE_MISMATCH_ERROR = + 'New version 1.4.163 is not signed by the application owner: publisherNames: Orca, Inc.' + +type CapturedSpan = { + readonly name: string + readonly exit: { readonly _tag: string; readonly cause?: string } +} + +const originalPlatform = process.platform + +let spans: CapturedSpan[] +let tracer: typeof TracerModule | null = null + +function capturingSink(): TracerModule.TracerSink { + return { + push(record) { + spans.push(record as CapturedSpan) + }, + flush() { + /* no-op */ + }, + close() { + /* no-op */ + } + } +} + +function installSpan(): CapturedSpan | undefined { + return spans.find((span) => span.name === 'updater.install') +} + +/** Drives the updater to `downloaded`, the only state `quitAndInstall` acts on. */ +async function reachDownloaded(): Promise { + const mainWindow = { webContents: { send: vi.fn() } } + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + // Why: resetModules gives each test a fresh module graph, so the sink must be installed on the + // same tracer instance updater.ts will import. + tracer = await import('./observability/tracer') + tracer.setActiveSink(capturingSink()) + const updater = await import('./updater') + + updater.setupAutoUpdater(mainWindow as never) + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '1.4.163' }) + await new Promise((resolve) => setTimeout(resolve, 0)) + autoUpdaterMock.emit('update-downloaded', { version: '1.4.163' }) + expect(updater.getUpdateStatus().state).toBe('downloaded') + return updater +} + +/** + * On a `.deb` Linux host electron-updater's `install()` catches the failed elevation and + * re-dispatches it through the 'error' event *synchronously* inside `quitAndInstall()`. Orca + * recovers the app state, so the payload has to survive on the status and the span has to exit + * Failure — otherwise the only record of why the install never ran is destroyed (#11906). + */ +describe('quitAndInstall failure carries the updater cause', () => { + beforeEach(() => { + vi.resetModules() + autoUpdaterMock.reset() + nativeUpdaterMock.on.mockReset() + browserWindowMock.getAllWindows.mockReset() + browserWindowMock.getAllWindows.mockReturnValue([]) + appMock.getVersion.mockReset() + appMock.getVersion.mockReturnValue('1.4.162') + appMock.quit.mockReset() + appMock.isPackaged = true + isMock.dev = false + killAllPtyMock.mockReset() + recordUpdaterLifecycleMock.mockReset() + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true + }) + vi.useRealTimers() + spans = [] + }) + + afterEach(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true + }) + tracer?._resetTracerForTests() + tracer = null + }) + + it('reports the underlying elevation failure rather than a generic restart message', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + expect(status.state).toBe('error') + // The only place the real cause exists is this event payload; dropping it leaves nothing + // in the logs and nothing actionable in the client's remote-server update dialog. + const message = status.state === 'error' ? status.message : '' + expect(message).toContain('pkexec') + expect(message).toContain('Could not start the update installer.') + }) + + it('keeps the durable breadcrumb to classification only', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + expect(recordUpdaterLifecycleMock).toHaveBeenCalledWith( + 'quit_and_install_failed_via_event', + { errorType: 'Error' }, + expect.anything() + ) + }) + + it('exits the install span Failure with the cause', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(DEB_ELEVATION_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(installSpan()).toBeDefined() + }) + + expect(installSpan()?.exit._tag).toBe('Failure') + expect(installSpan()?.exit.cause).toContain('pkexec') + }) + + it('exits the install span Success when the installer takes over', async () => { + const { quitAndInstall } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + // The installer runs and the old process is left to exit; no 'error' comes back. + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(installSpan()).toBeDefined() + }) + + expect(installSpan()?.exit._tag).toBe('Success') + }) + + it('keeps a Windows signature verdict unprefixed so its own card still renders', async () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }) + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit('error', new Error(WINDOWS_SIGNATURE_MISMATCH_ERROR)) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + // Prefixing this would put two contradictory instructions on one card. + expect(status.state === 'error' ? status.message : '').toBe(WINDOWS_SIGNATURE_MISMATCH_ERROR) + }) + + it('redacts the cause the same way the retained-package card does', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + const home = os.homedir() + const escape = String.fromCharCode(27) + + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + autoUpdaterMock.emit( + 'error', + new Error(`${escape}[31mdpkg -i '${home}/.cache/orca-updater/pending/orca.deb'${escape}[0m`) + ) + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) + }) + + const status = getUpdateStatus() + const message = status.state === 'error' ? status.message : '' + expect(message).not.toContain(home) + expect(message).not.toContain(escape) + expect(message).toContain("/.cache/orca-updater/pending/orca.deb'") + }) + + it('carries the cause when quitAndInstall throws instead of dispatching an error event', async () => { + const { quitAndInstall, getUpdateStatus } = await reachDownloaded() + + // Squirrel/NSIS can reject the request by throwing out of the native call; the event path never runs. + autoUpdaterMock.quitAndInstall.mockImplementation(() => { + throw new Error('Squirrel.framework is missing from the app bundle') + }) + + quitAndInstall() + await vi.waitFor(() => { + expect(getUpdateStatus().state).toBe('error') + }) + + const status = getUpdateStatus() + const message = status.state === 'error' ? status.message : '' + expect(message).toContain('Squirrel.framework is missing from the app bundle') + expect(message).toContain('Could not start the update installer.') + }) +}) diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index a0ab479343d..dce8b39a7b6 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -159,6 +159,12 @@ const { getLinuxRootPackageTypeMock, recordUpdaterLifecycleMock } = vi.hoisted(( recordUpdaterLifecycleMock: vi.fn() })) +// Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. +const PRE_COMMIT_INSTALL_FAILURE = + process.platform === 'darwin' + ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' + : 'Could not start the update installer. Orca remains open.' + // Why: only the marker resolver is faked so the real artifact capture/redaction path stays under test. vi.mock('./linux-update-package-type', () => ({ getLinuxRootPackageType: getLinuxRootPackageTypeMock @@ -1829,10 +1835,8 @@ describe('updater', () => { state: 'error', // Why: a pre-commit install failure is not fixed by restarting, so the copy must not // suggest it — except on macOS, where quitting does re-stage a Squirrel update. - message: - process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' + // The updater's own text is appended because it is the only record of why the install never ran. + message: `${PRE_COMMIT_INSTALL_FAILURE} (No update filepath provided, can't quit and install)` }) ) }) @@ -3911,11 +3915,7 @@ describe('updater', () => { const lastStatus = (send: ReturnType): UpdateStatus | undefined => send.mock.calls.findLast(([channel]) => channel === 'updater:status')?.[1] - // Why: macOS keeps the restart advice because quitting does re-stage a Squirrel update. - const PRE_COMMIT_FAILURE_MESSAGE = - process.platform === 'darwin' - ? 'Could not restart to install the update. Quit and reopen Orca, then try again.' - : 'Could not start the update installer. Orca remains open.' + const PRE_COMMIT_FAILURE_MESSAGE = PRE_COMMIT_INSTALL_FAILURE const AGENT_STDERR = 'pkexec: Error executing command as another user: No authentication agent found.' @@ -4081,7 +4081,7 @@ describe('updater', () => { expect(send).toHaveBeenCalledWith('updater:status', { state: 'error', - message: PRE_COMMIT_FAILURE_MESSAGE + message: `${PRE_COMMIT_FAILURE_MESSAGE} (${EXIT_127})` }) expect(recordUpdaterLifecycleMock).not.toHaveBeenCalledWith( 'linux_package_install_failed', diff --git a/src/main/updater.ts b/src/main/updater.ts index 302591286f3..96bf163e54e 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -13,7 +13,10 @@ import type { RemoteServerUpdaterSnapshot, RemoteServerUpdateSupport } from '../shared/remote-server-update' -import { isWindowsSignatureCheckUnavailableFailure } from '../shared/updater-windows-signature-check' +import { + isWindowsSignatureCheckUnavailableFailure, + isWindowsSignatureMismatchFailure +} from '../shared/updater-windows-signature-check' import { killAllPty } from './ipc/pty' import { withUpdaterSpan } from './observability/instrumentation' import { loadElectronAutoUpdater, type ElectronAutoUpdater } from './electron-updater-loader' @@ -763,6 +766,8 @@ async function performQuitAndInstall(): Promise { true ) resetQuitForUpdateState() + // Why: a bare return would exit this span Success and hide the aborted install from tracing. + span.fail('Could not persist the supervised serve update handoff') return } @@ -790,6 +795,12 @@ async function performQuitAndInstall(): Promise { // Why: quitAndInstall can synchronously clear quitAndInstallInProgress via recovery (Win/Linux dispatchError); skip destructive prep if it already ran. if (!quitAndInstallInProgress) { + // Why: recovery already wrote the reason to currentStatus; a bare return would exit this span Success. + span.fail( + currentStatus.state === 'error' + ? currentStatus.message + : 'quitAndInstall returned without invoking the installer' + ) return } @@ -853,8 +864,9 @@ async function performQuitAndInstall(): Promise { recoveryStatus ?? { state: 'error', // Why: past the native invoke this is the same pre-commit failure the event path reports, so it gets the same copy; only a pre-native exception can be helped by a restart. + // A synchronous throw out of quitAndInstall carries the same installer text the 'error' event would have. message: quitAndInstallNativeInvokedBeforeReset - ? getPreCommitInstallFailureMessage() + ? withInstallFailureCause(getPreCommitInstallFailureMessage(), error) : 'Could not restart to install the update. Quit and reopen Orca, then try again.' } ) @@ -890,6 +902,33 @@ function sendInstallFailureStatus(status: UpdateStatus): void { sendStatus(status, { force: true }) } +const INSTALL_FAILURE_CAUSE_MAX_LENGTH = 200 + +/** + * Appends the updater's own text to the generic install-failure copy. Without it the only record of + * why the install never started is destroyed — on Linux that text carries the exact `dpkg -i ` + * command the user has to run by hand, and remote clients get nothing but "it didn't come back". + */ +function withInstallFailureCause(baseMessage: string, error: unknown): string { + const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : '' + // Why: the retained-package card runs its text through this same sanitizer, so a home directory, + // user name, or terminal escape must not reach the card merely because no artifact was tracked. + const redacted = + redactLinuxPackageInstallText(raw, getTrackedLinuxPackageArtifact()?.path ?? null) ?? '' + const cause = redacted.slice(0, INSTALL_FAILURE_CAUSE_MAX_LENGTH) + if (!cause || cause === 'Unknown error') { + return baseMessage + } + // Why: UpdateCard picks the whole card off this string, so a signature verdict must not be prefixed by contradictory restart advice. + if ( + isWindowsSignatureCheckUnavailableFailure(cause) || + isWindowsSignatureMismatchFailure(cause) + ) { + return cause + } + return `${baseMessage} (${cause})` +} + /** * The recovery status for a failed `.deb`/`.rpm` install, or null when no retained package can * recover it. Must run before `resetQuitForUpdateState()` clears the attempt diagnostic. @@ -945,14 +984,19 @@ function handleQuitAndInstallFailure(error?: unknown): boolean { const recoveryStatus = buildLinuxPackageInstallFailureStatus(error) failServeUpdateHandoff('The native updater rejected the install request.') resetQuitForUpdateState() - recordUpdaterLifecycle('quit_and_install_failed_via_event', undefined, { - level: 'warn', - message: 'Update install could not start; recovered app state' - }) + // Durable data carries classification only — the cause text stays on the status the user can read. + recordUpdaterLifecycle( + 'quit_and_install_failed_via_event', + { errorType: error instanceof Error ? error.name : typeof error }, + { + level: 'warn', + message: 'Update install could not start; recovered app state' + } + ) sendInstallFailureStatus( recoveryStatus ?? { state: 'error', - message: getPreCommitInstallFailureMessage() + message: withInstallFailureCause(getPreCommitInstallFailureMessage(), error) } ) return true diff --git a/src/main/worktree-trash.test.ts b/src/main/worktree-trash.test.ts new file mode 100644 index 00000000000..49a8af75a1d --- /dev/null +++ b/src/main/worktree-trash.test.ts @@ -0,0 +1,220 @@ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { Repo } from '../shared/types' +import { + collectWorktreeTrashSweepRoots, + getWorktreeTrashRoot, + isWorktreeTrashEntryName, + moveWorktreeDirectoryToTrash, + restoreWorktreeDirectoryFromTrash, + scheduleWorktreeTrashDeletion, + sweepStaleWorktreeTrash, + whenWorktreeTrashDeletionsSettled, + WORKTREE_TRASH_DIR_NAME +} from './worktree-trash' + +let scratchDir = '' + +beforeEach(async () => { + scratchDir = await mkdtemp(join(tmpdir(), 'orca-worktree-trash-')) +}) + +afterEach(async () => { + await whenWorktreeTrashDeletionsSettled() + await rm(scratchDir, { recursive: true, force: true }) +}) + +async function createWorktreeDirectory(worktreePath: string): Promise { + await mkdir(join(worktreePath, 'node_modules', 'pkg'), { recursive: true }) + await writeFile(join(worktreePath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n') +} + +describe('moveWorktreeDirectoryToTrash', () => { + it('renames the checkout into a hidden sibling trash root', async () => { + const worktreePath = join(scratchDir, 'repo', 'feature') + await createWorktreeDirectory(worktreePath) + + const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) + + expect(trashPath).toBeDefined() + expect(existsSync(worktreePath)).toBe(false) + expect(trashPath!.startsWith(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toBe(true) + expect(existsSync(join(trashPath!, 'node_modules', 'pkg', 'index.js'))).toBe(true) + }) + + it('generates sweepable, collision-free entry names', async () => { + const first = await moveWorktreeDirectoryToTrash(await seededWorktree('one')) + const second = await moveWorktreeDirectoryToTrash(await seededWorktree('two')) + + expect(first).not.toEqual(second) + expect(await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))).toHaveLength(2) + for (const entry of await readdir(join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME))) { + expect(isWorktreeTrashEntryName(entry)).toBe(true) + } + }) + + it('reports the rename as unavailable and leaves no trash root when the checkout is missing', async () => { + const worktreePath = join(scratchDir, 'repo', 'gone') + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false) + }) + + it.skipIf(process.platform === 'win32')('refuses a symlinked trash root', async () => { + const worktreePath = join(scratchDir, 'repo', 'feature') + const externalRoot = join(scratchDir, 'external') + await createWorktreeDirectory(worktreePath) + await mkdir(externalRoot) + await symlink(externalRoot, getWorktreeTrashRoot(worktreePath)) + + expect(await moveWorktreeDirectoryToTrash(worktreePath)).toBeUndefined() + expect(existsSync(worktreePath)).toBe(true) + expect(await readdir(externalRoot)).toEqual([]) + }) + + async function seededWorktree(name: string): Promise { + const worktreePath = join(scratchDir, 'repo', name) + await createWorktreeDirectory(worktreePath) + return worktreePath + } +}) + +describe('restoreWorktreeDirectoryFromTrash', () => { + it('puts the checkout back where Git registered it', async () => { + const worktreePath = join(scratchDir, 'repo', 'feature') + await createWorktreeDirectory(worktreePath) + const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) + + expect(await restoreWorktreeDirectoryFromTrash(trashPath!, worktreePath)).toBe(true) + expect(existsSync(join(worktreePath, 'node_modules', 'pkg', 'index.js'))).toBe(true) + expect(existsSync(trashPath!)).toBe(false) + }) + + it('reports failure instead of throwing when the trashed path is gone', async () => { + const trashRoot = getWorktreeTrashRoot(join(scratchDir, 'repo', 'feature')) + expect( + await restoreWorktreeDirectoryFromTrash( + join(trashRoot, 'wt-1-abcdef01'), + join(scratchDir, 'repo', 'feature') + ) + ).toBe(false) + }) +}) + +describe('scheduleWorktreeTrashDeletion', () => { + it('deletes trashed checkouts in the background', async () => { + const worktreePath = join(scratchDir, 'repo', 'feature') + await createWorktreeDirectory(worktreePath) + const trashPath = await moveWorktreeDirectoryToTrash(worktreePath) + + scheduleWorktreeTrashDeletion(trashPath!) + await whenWorktreeTrashDeletionsSettled() + + expect(existsSync(trashPath!)).toBe(false) + expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(true) + }) +}) + +describe('sweepStaleWorktreeTrash', () => { + it('removes leftover entries from both workspace layouts and nothing else', async () => { + const nestedTrashRoot = join(scratchDir, 'repo', WORKTREE_TRASH_DIR_NAME) + const flatTrashRoot = join(scratchDir, WORKTREE_TRASH_DIR_NAME) + await mkdir(join(nestedTrashRoot, 'wt-1700000000000-abcdef01', 'src'), { recursive: true }) + await mkdir(join(flatTrashRoot, 'wt-1700000000001-abcdef02'), { recursive: true }) + // Not generated by Orca: a stray directory and file inside the trash root must survive. + await mkdir(join(nestedTrashRoot, 'unrelated-directory'), { recursive: true }) + await writeFile(join(nestedTrashRoot, 'wt-notes.txt'), 'keep me\n') + const liveWorktree = join(scratchDir, 'repo', 'feature') + await createWorktreeDirectory(liveWorktree) + + const { removed } = await sweepStaleWorktreeTrash([scratchDir]) + + expect(removed).toBe(2) + expect(existsSync(join(nestedTrashRoot, 'wt-1700000000000-abcdef01'))).toBe(false) + expect(existsSync(join(flatTrashRoot, 'wt-1700000000001-abcdef02'))).toBe(false) + expect(existsSync(join(nestedTrashRoot, 'unrelated-directory'))).toBe(true) + expect(existsSync(join(nestedTrashRoot, 'wt-notes.txt'))).toBe(true) + expect(existsSync(join(liveWorktree, 'node_modules', 'pkg', 'index.js'))).toBe(true) + }) + + it('ignores workspace roots that do not exist', async () => { + expect(await sweepStaleWorktreeTrash([join(scratchDir, 'missing')])).toEqual({ removed: 0 }) + }) + + it('never descends past the trash roots beside worktrees', async () => { + const deepTrashRoot = join(scratchDir, 'repo', 'feature', WORKTREE_TRASH_DIR_NAME) + await mkdir(join(deepTrashRoot, 'wt-1700000000002-abcdef03'), { recursive: true }) + + expect(await sweepStaleWorktreeTrash([scratchDir])).toEqual({ removed: 0 }) + expect(existsSync(join(deepTrashRoot, 'wt-1700000000002-abcdef03'))).toBe(true) + }) + + it.skipIf(process.platform === 'win32')( + 'does not sweep through a symlinked trash root', + async () => { + const externalEntry = join(scratchDir, 'external', 'wt-1700000000003-abcdef04') + await mkdir(externalEntry, { recursive: true }) + await symlink(join(scratchDir, 'external'), join(scratchDir, WORKTREE_TRASH_DIR_NAME)) + + expect(await sweepStaleWorktreeTrash([scratchDir])).toEqual({ removed: 0 }) + expect(existsSync(externalEntry)).toBe(true) + } + ) +}) + +describe('collectWorktreeTrashSweepRoots', () => { + const settings = { workspaceDir: '/home/dev/orca/workspaces', nestWorkspaces: true } + + function repo(overrides: Partial): Repo { + return { id: 'repo-1', path: '/home/dev/code/orca', ...overrides } as unknown as Repo + } + + it('collects one root per local git repo', () => { + expect( + collectWorktreeTrashSweepRoots( + [repo({}), repo({ id: 'repo-2', path: '/code/other' })], + settings + ) + ).toEqual(['/home/dev/orca/workspaces']) + }) + + it('honours a repo-specific worktree base path', () => { + expect( + collectWorktreeTrashSweepRoots([repo({ worktreeBasePath: '/volumes/fast/trees' })], settings) + ).toEqual(['/volumes/fast/trees']) + }) + + it('skips SSH repos and folder workspaces', () => { + expect( + collectWorktreeTrashSweepRoots( + [repo({ connectionId: 'ssh-1' }), repo({ id: 'repo-3', kind: 'folder' })], + settings + ) + ).toEqual([]) + }) + + it('skips WSL repos that cannot create host trash', () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + expect( + collectWorktreeTrashSweepRoots( + [ + repo({ path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\orca' }), + repo({ + id: 'repo-2', + path: 'C:\\code\\orca', + worktreeBasePath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\trees' + }) + ], + settings + ) + ).toEqual([]) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) +}) diff --git a/src/main/worktree-trash.ts b/src/main/worktree-trash.ts new file mode 100644 index 00000000000..5c0aa32be2b --- /dev/null +++ b/src/main/worktree-trash.ts @@ -0,0 +1,175 @@ +// Why: `git worktree remove` deletes the whole checkout (usually a multi-GB node_modules) inside the +// remove IPC, so the UI sat on a spinner for 8-35s. Renaming the directory into a sibling trash root +// is a metadata operation, and the recursive delete then runs after the IPC has already returned. + +import { randomBytes } from 'node:crypto' +import { lstat, mkdir, readdir, rename, rmdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { removeHostTree } from './host-tree-removal' +import { isFolderRepo } from '../shared/repo-kind' +import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' +import type { GlobalSettings, Repo } from '../shared/types' +import { parseWslPath } from './wsl' + +export const WORKTREE_TRASH_DIR_NAME = '.orca-worktree-trash' + +// `-`: the nonce keeps concurrent removals of same-named worktrees from colliding. +const TRASH_ENTRY_PATTERN = /^wt-\d+-[0-9a-f]{8}$/ + +// Why: the sweep must stay cheap on a workspace root holding many repo containers. +const TRASH_SWEEP_MAX_CONTAINERS = 200 + +/** Trash root for a worktree: a hidden sibling, so the rename always stays on one volume. */ +export function getWorktreeTrashRoot(worktreePath: string): string { + return join(dirname(worktreePath), WORKTREE_TRASH_DIR_NAME) +} + +export function isWorktreeTrashEntryName(entryName: string): boolean { + return TRASH_ENTRY_PATTERN.test(entryName) +} + +/** + * Move a worktree directory aside so the caller can return before it is deleted. + * Returns the trash path, or `undefined` when the rename is unavailable (a different + * volume, or Windows open handles) and the caller must delete in place instead. + */ +export async function moveWorktreeDirectoryToTrash( + worktreePath: string +): Promise { + const trashRoot = getWorktreeTrashRoot(worktreePath) + const trashPath = join(trashRoot, `wt-${Date.now()}-${randomBytes(4).toString('hex')}`) + try { + await mkdir(trashRoot, { recursive: true }) + const trashRootStat = await lstat(trashRoot) + if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { + throw new Error(`Refusing non-directory worktree trash root: ${trashRoot}`) + } + await rename(worktreePath, trashPath) + return trashPath + } catch (error) { + console.warn( + `[worktrees] Deferred deletion unavailable for ${worktreePath}; deleting in place`, + error + ) + // Leave no empty trash root behind when the rename never happened; rmdir keeps queued entries. + await rmdir(trashRoot).catch(() => {}) + return undefined + } +} + +/** Undo a trash rename so a failed registration cleanup leaves the worktree exactly as it was. */ +export async function restoreWorktreeDirectoryFromTrash( + trashPath: string, + worktreePath: string +): Promise { + try { + await rename(trashPath, worktreePath) + return true + } catch (error) { + console.warn(`[worktrees] Failed to restore ${worktreePath} from ${trashPath}`, error) + return false + } +} + +// Why serialized: one background delete at a time keeps a burst of removals from saturating disk I/O +// while the user keeps working. +let queuedTrashDeletions: Promise = Promise.resolve() + +export function scheduleWorktreeTrashDeletion(trashPath: string): void { + queuedTrashDeletions = queuedTrashDeletions.then(async () => { + try { + await removeHostTree(trashPath) + } catch (error) { + // Why only a warning: the directory is already invisible to the user, and the startup sweep retries it. + console.warn(`[worktrees] Failed to delete trashed worktree at ${trashPath}`, error) + } + }) +} + +/** Test/shutdown hook: resolves once every queued background deletion has settled. */ +export function whenWorktreeTrashDeletionsSettled(): Promise { + return queuedTrashDeletions +} + +/** + * Delete trash entries left behind by a previous run (a crash or a kill during background deletion). + * Only entries matching the generated name pattern inside a trash root are removed. + */ +export async function sweepStaleWorktreeTrash( + workspaceRoots: readonly string[] +): Promise<{ removed: number }> { + let removed = 0 + for (const trashRoot of await collectExistingTrashRoots(workspaceRoots)) { + let entries: string[] + try { + const trashRootStat = await lstat(trashRoot) + if (!trashRootStat.isDirectory() || trashRootStat.isSymbolicLink()) { + continue + } + entries = await readdir(trashRoot) + } catch { + continue + } + for (const entry of entries) { + if (!isWorktreeTrashEntryName(entry)) { + continue + } + try { + await removeHostTree(join(trashRoot, entry)) + removed += 1 + } catch (error) { + console.warn( + `[worktrees] Failed to sweep leftover worktree at ${trashRoot}/${entry}`, + error + ) + } + } + } + if (removed > 0) { + console.log(`[worktrees] Swept ${removed} leftover worktree director(ies) from a previous run`) + } + return { removed } +} + +/** Trash roots live beside worktrees, so they sit at the workspace root (flat) or one level in (nested). */ +async function collectExistingTrashRoots(workspaceRoots: readonly string[]): Promise { + const trashRoots = new Set() + for (const workspaceRoot of new Set(workspaceRoots)) { + trashRoots.add(join(workspaceRoot, WORKTREE_TRASH_DIR_NAME)) + let containers: string[] = [] + try { + containers = (await readdir(workspaceRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && entry.name !== WORKTREE_TRASH_DIR_NAME) + .slice(0, TRASH_SWEEP_MAX_CONTAINERS) + .map((entry) => entry.name) + } catch { + continue + } + for (const container of containers) { + trashRoots.add(join(workspaceRoot, container, WORKTREE_TRASH_DIR_NAME)) + } + } + return [...trashRoots] +} + +/** Workspace roots of local git repos — the only places Orca creates worktree trash. */ +export function collectWorktreeTrashSweepRoots( + repos: readonly Repo[], + settings: Pick +): string[] { + const roots = new Set() + for (const repo of repos) { + if (repo.connectionId || isFolderRepo(repo) || parseWslPath(repo.path)) { + continue + } + try { + const workspaceRoot = computeWorkspaceRoot(repo.path, getWorktreePathSettings(repo, settings)) + if (!parseWslPath(workspaceRoot)) { + roots.add(workspaceRoot) + } + } catch { + // A repo with an unusable configured base path simply has no trash root to sweep. + } + } + return [...roots] +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c0c57731e1e..93b4a2e4d23 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -3019,6 +3019,7 @@ export type PreloadApi = { capability: CommitMessageAgentCapability models: CommitMessageModelCapability[] defaultModelId: string + catalogOrigin: 'probe' | 'spec' } | { success: false; error: string } > diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index a89b9d60c2b..3166c70a2f5 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -20,6 +20,7 @@ import { normalizeHookPayload, preparePendingGrokResultDiscovery, readRequestBody, + resolveCachedClaudeCompactOwnership, resolveHookSource, writeEndpointFile, type AgentHookEventPayload, @@ -275,7 +276,10 @@ export class RelayAgentHookServer { res.end() return } - const event = normalizeHookPayload(this.state, source, body, this.env) + const event = normalizeHookPayload(this.state, source, body, this.env, { + allowUnanchoredPreCompact: true, + allowUnanchoredPostCompact: true + }) if (event) { // TODO: once normalizeHookPayload returns validated env/version, drop bodyEnv/bodyVersion and source them from the listener result. const env = this.bodyEnv(body) @@ -313,6 +317,8 @@ export class RelayAgentHookServer { hasExplicitPrompt: event.hasExplicitPrompt, promptInteractionKey: event.promptInteractionKey, hookEventName: event.hookEventName, + providerPromptId: event.providerPromptId, + compactTrigger: event.compactTrigger, toolUseId: event.toolUseId, toolAgentId: event.toolAgentId, toolAgentType: event.toolAgentType, @@ -336,9 +342,11 @@ export class RelayAgentHookServer { if (event.payload.state !== 'done' || event.payload.lastAssistantMessage) { this.clearAssistantMessageRetry(event.paneKey) } + const previous = this.state.lastStatusByPaneKey.get(event.paneKey) + const cachedEvent = resolveCachedClaudeCompactOwnership(previous, event) // Why: delete-then-set makes Map insertion order = recency, so the cap below evicts the longest-idle pane. this.state.lastStatusByPaneKey.delete(event.paneKey) - this.state.lastStatusByPaneKey.set(event.paneKey, event) + this.state.lastStatusByPaneKey.set(event.paneKey, cachedEvent) this.lastEnvelopeMetaByPaneKey.delete(event.paneKey) this.lastEnvelopeMetaByPaneKey.set(event.paneKey, { source, env, version }) while (this.state.lastStatusByPaneKey.size > MAX_CACHED_PANES) { diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index dd1ccb4f2d7..969765c4dca 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -48,6 +48,7 @@ import type { } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' type JiraIssueWorkspaceProps = { issue: JiraIssue | null @@ -56,22 +57,8 @@ type JiraIssueWorkspaceProps = { sourceContext?: TaskSourceContext | null } -const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - function formatRelativeTime(input: string): string { - const date = new Date(input) - if (Number.isNaN(date.getTime())) { - return 'recently' - } - const diffMinutes = Math.round((date.getTime() - Date.now()) / 60_000) - if (Math.abs(diffMinutes) < 60) { - return relativeFormatter.format(diffMinutes, 'minute') - } - const diffHours = Math.round(diffMinutes / 60) - if (Math.abs(diffHours) < 24) { - return relativeFormatter.format(diffHours, 'hour') - } - return relativeFormatter.format(Math.round(diffHours / 24), 'day') + return formatUiRelativeTimeFromDate(input) } function buildJiraBranchName(issue: JiraIssue): string { diff --git a/src/renderer/src/components/LinearItemDrawer.tsx b/src/renderer/src/components/LinearItemDrawer.tsx index 25ee1ffef16..719aecbf4b1 100644 --- a/src/renderer/src/components/LinearItemDrawer.tsx +++ b/src/renderer/src/components/LinearItemDrawer.tsx @@ -50,6 +50,7 @@ import { linearUpdateIssue } from '@/runtime/runtime-linear-client' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' function LinearIcon({ className }: { className?: string }): React.JSX.Element { return ( @@ -101,22 +102,7 @@ function LinearEditChipAdornment({ } 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) } type LinearItemDrawerProps = { diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 72ad520f2de..6bea00d9906 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -66,6 +66,8 @@ import type { TaskSourceContext } from '../../../shared/task-source-context' import type { RuntimeStatus } from '../../../shared/runtime-types' import { unwrapRuntimeRpcResult } from '@/runtime/runtime-rpc-client' import { translate } from '@/i18n/i18n' +import { withUiConnectTimeout } from '@/ssh/ssh-connect-ui-timeout' +import { isSshConnectInFlight, trackSshConnect } from '@/ssh/ssh-connect-in-flight' type RepoOption = React.ComponentProps['repos'][number] type EphemeralVmRecipeOption = NonNullable[number] @@ -210,33 +212,6 @@ function getSshStatusLabel(status: SshConnectionStatus): string { return SSH_STATUS_LABELS[status] ?? status } -// Why: bound how long the run-target picker waits on a host connect so a stalled backend -// connect can't leave the row's disabled/spinner state stuck forever. The backend keeps going. -const RUN_TARGET_CONNECT_UI_TIMEOUT_MS = 20_000 - -async function withUiConnectTimeout(promise: Promise): Promise { - let timer: ReturnType | undefined - const timeout = new Promise((_, reject) => { - timer = setTimeout(() => { - reject( - new Error( - translate( - 'auto.components.NewWorkspaceComposerCard.connectTimedOut', - 'Connection timed out. It may still be connecting in the background.' - ) - ) - ) - }, RUN_TARGET_CONNECT_UI_TIMEOUT_MS) - }) - try { - return await Promise.race([promise, timeout]) - } finally { - if (timer) { - clearTimeout(timer) - } - } -} - function SetupCommandPreview({ setupConfig }: { setupConfig: SetupConfig }): React.JSX.Element { // Why: just the script in a quiet monochrome card — the source label (orca.yaml / local) and // the run-setup toggle live in the section header above, so the card carries no chrome of its @@ -534,10 +509,18 @@ export default function NewWorkspaceComposerCard({ } try { if (action.kind === 'ssh') { - // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave the - // row's spinner/disabled state stuck forever. Bound the UI wait — the backend keeps - // connecting and the picker updates from store SSH state if it later succeeds. - await withUiConnectTimeout(window.api.ssh.connect({ targetId: action.targetId })) + if (isSshConnectInFlight(action.targetId)) { + return + } + // Why: ssh.connect has no built-in timeout; a stalled connect would otherwise leave + // the row's spinner/disabled state stuck forever. Bound the UI wait — the backend + // keeps connecting and the picker updates from store SSH state if it later succeeds. + // The shared registry tracks that backend request (not this bounded wait), so the + // sidebar card control and terminal overlay for this host stay locked until it + // settles — a second dial on a passphrase-gated target means a second prompt. + await withUiConnectTimeout( + trackSshConnect(action.targetId, window.api.ssh.connect({ targetId: action.targetId })) + ) return } diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index b430b0c33a3..0f2fa5c12fb 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -359,6 +359,7 @@ import { resolveVisibleTaskProvider } from '../../../shared/task-providers' import { translate } from '@/i18n/i18n' +import { formatUiRelativeTimeFromDate } from '@/i18n/relative-time-format' import { getGitHubModeButtons, getGitHubTaskKindPresets, @@ -585,29 +586,8 @@ function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { return `${inferredKind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` } -// Why: Intl.RelativeTimeFormat allocation is non-trivial; hoist to module scope so all rows share one instance instead of allocating per render. -const relativeTimeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) - 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) - - 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) } type LinearProjectTab = 'overview' | 'issues' diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index 0e41c46f07b..4e4aec4d2b5 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -127,6 +127,19 @@ describe('UpdateCard Windows signature failures', () => { 'true' ) }) + + // An install failure now carries the updater's own text, so it can reach these branches too. + it('routes a signature verdict raised during install to the security-stop card', () => { + const message = + 'New version 1.4.200 is not signed by the application owner: publisherNames: Orca' + renderAfterAvailableStatus() + + act(() => useAppStore.getState().setUpdateStatus({ state: 'error', message })) + + expect(screen.getByText("Update Wasn't Installed")).toBeTruthy() + // The generic restart advice must not be prefixed onto a security stop. + expect(screen.queryByText(/Quit and reopen Orca/)).toBeNull() + }) }) describe('UpdateCard hourly builds', () => { @@ -260,6 +273,18 @@ describe('UpdateCard Linux package-install recovery', () => { expect(download).toHaveBeenCalledTimes(1) }) + it('shows the appended install cause behind the generic card details', () => { + const message = + 'Could not start the update installer. Orca remains open. (Command failed: pkexec must be setuid root)' + renderAfterAvailableStatus() + + act(() => useAppStore.getState().setUpdateStatus({ state: 'error', message })) + + expect(screen.getByText('Update Error')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Show details' })) + expect(screen.getByText(message)).toBeTruthy() + }) + it('leaves the HTTP/1.1 compatibility branch untouched', () => { renderAfterAvailableStatus() 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-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/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/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/NativeChatComposer.test.tsx b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx index bef6151532a..4eac2b5739c 100644 --- a/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatComposer.test.tsx @@ -8,6 +8,7 @@ import type { } from '../../../../shared/native-chat-session-options' import type * as nativeChatAgentProfiles from '../../../../shared/native-chat-agent-profiles' import { clearNativeChatSessionOptionCacheForTests } from './native-chat-session-option-cache' +import { clearNativeChatModelEnrichmentForTests } from './native-chat-session-option-enrichment' const mocks = vi.hoisted(() => ({ cancelPendingSends: vi.fn(), @@ -28,6 +29,7 @@ const mocks = vi.hoisted(() => ({ dispose: ReturnType } | null, createClaudeModelSwitchConfirmationObserver: vi.fn(), + discoverCommitMessageModels: vi.fn(), getMainBufferSnapshot: vi.fn(), sendHandle: { cancel: vi.fn(), settleAfterMs: 500 }, sendNativeChatMessage: vi.fn(), @@ -137,6 +139,7 @@ describe('NativeChatComposer', () => { beforeEach(() => { vi.clearAllMocks() clearNativeChatSessionOptionCacheForTests() + clearNativeChatModelEnrichmentForTests() mocks.fieldProps = null mocks.modelSwitchOutcome = 'applied' mocks.draftScopeKeys.length = 0 @@ -153,12 +156,36 @@ describe('NativeChatComposer', () => { return observer }) mocks.getMainBufferSnapshot.mockResolvedValue(null) + mocks.discoverCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { + id: 'opus', + label: 'Opus', + thinkingLevels: [ + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } + ] + }, + { + id: 'sonnet', + label: 'Sonnet', + thinkingLevels: [ + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' } + ] + }, + { id: 'fable', label: 'Fable' } + ] + }) mocks.sendNativeChatMessage.mockReturnValue(mocks.sendHandle) mocks.sendNativeChatMessageVerified.mockResolvedValue(true) mocks.sendHandle.settleAfterMs = 500 Object.defineProperty(window, 'api', { configurable: true, value: { + git: { discoverCommitMessageModels: mocks.discoverCommitMessageModels }, pty: { getMainBufferSnapshot: mocks.getMainBufferSnapshot }, ui: { onFileDrop: () => vi.fn() } } @@ -293,6 +320,59 @@ describe('NativeChatComposer', () => { expect(mocks.setDraft).not.toHaveBeenCalled() }) + it('renders the Claude model picker while host discovery is still pending', () => { + mocks.discoverCommitMessageModels.mockReturnValue(new Promise(() => {})) + render( + null} + /> + ) + + expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({ + id: 'model', + kind: { + choices: expect.arrayContaining([ + expect.objectContaining({ value: 'opus', label: 'Opus' }), + expect.objectContaining({ value: 'sonnet', label: 'Sonnet' }) + ]) + } + }) + }) + + it('keeps the Claude model picker when an older remote runtime omits the catalog origin', async () => { + mocks.discoverCommitMessageModels.mockResolvedValue({ + success: true, + defaultModelId: 'sonnet', + models: [{ id: 'sonnet', label: 'Sonnet' }] + }) + render( + null} + /> + ) + + await waitFor(() => expect(mocks.discoverCommitMessageModels).toHaveBeenCalled()) + await act(async () => undefined) + + expect(mocks.fieldProps?.sessionOptionsSnapshot?.[0]).toMatchObject({ + id: 'model', + kind: { + choices: expect.arrayContaining([ + expect.objectContaining({ value: 'fable', label: 'Fable' }), + expect.objectContaining({ value: 'haiku', label: 'Haiku' }) + ]) + } + }) + }) + it('shows the model already selected in the Claude TUI when chat opens', async () => { mocks.getMainBufferSnapshot.mockResolvedValue({ data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing', @@ -398,7 +478,7 @@ describe('NativeChatComposer', () => { expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ ptyId: 'pty-1', settings: {}, - expectedModelLabel: 'Opus 4.8' + expectedModelLabel: 'Opus' }) expect(onSwitchToTerminal).not.toHaveBeenCalled() }) @@ -429,7 +509,7 @@ describe('NativeChatComposer', () => { expect(mocks.createClaudeModelSwitchConfirmationObserver).toHaveBeenCalledWith({ ptyId: 'pty-1', settings: {}, - expectedModelLabel: 'Fable 5' + expectedModelLabel: 'Fable' }) expect(mocks.confirmationObserver?.arm).toHaveBeenCalledOnce() expect(mocks.confirmationObserver?.arm.mock.invocationCallOrder[0]).toBeLessThan( diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts index e57f42e2efe..ed4d9f725b0 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.test.ts @@ -40,6 +40,56 @@ describe('Claude model switch confirmation detection', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) + it('matches the resolved-model echo when the picker label carries no version', async () => { + // Why: the CLI echoes what the alias resolved to ("Opus 5 (1M context)") + // while discovered picker labels read "Opus (1M context)"; the family word + // must bridge the two so verified switches do not report as unverifiable. + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus (1M context)', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + dataObserver.current('Set model to Opus 5 (1M context) and saved as your default') + + await expect(observer.result).resolves.toBe('applied') + }) + + it('does not confirm a different context variant from the same model family', async () => { + vi.useFakeTimers() + try { + const dataObserver = { current: (_data: string): void => {} } + const observer = createClaudeModelSwitchConfirmationObserver({ + ptyId: 'pty-1', + settings: {}, + expectedModelLabel: 'Opus (1M context)', + subscribeToData: (watcher) => { + dataObserver.current = watcher + return vi.fn(() => {}) + }, + timeoutMs: 100 + }) + + await observer.ready + observer.arm() + observer.startDetection() + dataObserver.current('Set model to Opus 5 and saved as your default') + await vi.advanceTimersByTimeAsync(100) + + await expect(observer.result).resolves.toBe('unknown') + } finally { + vi.useRealTimers() + } + }) + it('accepts the exact cached-history confirmation once and keeps observing', async () => { const dataObserver = { current: (_data: string): void => {} } const submitConfirmation = vi.fn() diff --git a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts index 64828018181..b96d3fad358 100644 --- a/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts +++ b/src/renderer/src/components/native-chat/claude-model-switch-confirmation.ts @@ -36,7 +36,26 @@ function compactTerminalText(buffer: string): string { function hasClaudeModelSwitchSuccess(buffer: string, modelLabel: string): boolean { const text = compactTerminalText(buffer) const marker = `setmodelto${modelLabel.replace(/\s+/g, '').toLowerCase()}` - return text.includes(marker) + if (text.includes(marker)) { + return true + } + // Why: resolved echoes insert a version ("Opus 5 (1M context)"); retaining + // every picker token prevents one context variant from confirming another. + const labelTokens = modelLabel.toLowerCase().match(/[a-z]+|\d+[a-z]*/g) ?? [] + const successStart = text.lastIndexOf('setmodelto') + if (successStart < 0 || labelTokens.length === 0) { + return false + } + const successText = text.slice(successStart) + let tokenEnd = 0 + for (const token of labelTokens) { + const tokenStart = successText.indexOf(token, tokenEnd) + if (tokenStart < 0) { + return false + } + tokenEnd = tokenStart + token.length + } + return true } function hasClaudeModelSwitchRejection(buffer: string): boolean { diff --git a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts index e095adf1132..a41d62eeb7a 100644 --- a/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts +++ b/src/renderer/src/components/native-chat/claude-terminal-session-options.test.ts @@ -37,6 +37,19 @@ describe('Claude terminal session option detection', () => { }) }) + it('matches headers from newer CLIs where the alias resolves to another version', () => { + // Why: family labels must keep matching as `opus` moves across releases. + const screen = + 'Claude Code v2.1.220\r\n' + + 'Opus 5 (1M context) with xhigh effort · API Usage Billing\r\n' + + '~/repo' + + expect(readClaudeSessionOptionsFromTerminalScreen(screen)).toEqual({ + model: 'opus', + effort: 'xhigh' + }) + }) + it('reports an option-less Haiku model without inventing effort', () => { expect( readClaudeSessionOptionsFromTerminalScreen( diff --git a/src/renderer/src/components/native-chat/native-chat-file-link.ts b/src/renderer/src/components/native-chat/native-chat-file-link.ts index f6531adaf35..eee08ceae2b 100644 --- a/src/renderer/src/components/native-chat/native-chat-file-link.ts +++ b/src/renderer/src/components/native-chat/native-chat-file-link.ts @@ -1,5 +1,4 @@ -import { fileUriToFilesystemPath } from '../../../../shared/file-uri-path' -import { isWindowsAbsolutePathLike } from '../../../../shared/cross-platform-path' +import { routeNativeChatHref } from '../../../../shared/native-chat-href-routing' import type { Worktree } from '../../../../shared/types' import { parseExplicitFileLinkTarget, @@ -82,50 +81,6 @@ export function resolveNativeChatFileLinkContext( } } -function maybeDecodeHrefPath(value: string): string { - try { - return decodeURIComponent(value) - } catch { - return value - } -} - -function stripQueryAndHash(value: string): { pathText: string; line: number | null } { - const hashIndex = value.indexOf('#') - const queryIndex = value.indexOf('?') - const suffixIndex = - hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) - const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) - const hash = - hashIndex === -1 - ? '' - : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) - const line = parseLineFragment(hash) - return { pathText, line } -} - -function parseLineFragment(hash: string): number | null { - if (!hash) { - return null - } - let decoded = hash - try { - decoded = decodeURIComponent(hash) - } catch { - decoded = hash - } - const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) - return match ? Number.parseInt(match[1], 10) : null -} - -function hasNonFileUriProtocol(value: string): boolean { - if (isWindowsAbsolutePathLike(value)) { - return false - } - const match = /^[A-Za-z][A-Za-z0-9+.-]*:/.exec(value) - return Boolean(match && match[0].toLowerCase() !== 'file:') -} - function resolvePathText( pathText: string, fallbackLine: number | null, @@ -148,43 +103,16 @@ function resolvePathText( } } -function resolveFileUriLink( - href: string, - context: NativeChatFileLinkContext -): NativeChatResolvedFileLink | null { - let url: URL - try { - url = new URL(href) - } catch { - return null - } - if (url.protocol !== 'file:') { - return null - } - const filePath = fileUriToFilesystemPath(url) - if (!filePath) { - return null - } - return resolvePathText(filePath, parseLineFragment(url.hash.replace(/^#/, '')), context) -} - export function resolveNativeChatFileLink( href: string | undefined, context: NativeChatFileLinkContext | null ): NativeChatResolvedFileLink | null { - const rawHref = href?.trim() - if (!rawHref || rawHref.startsWith('#') || !context) { + if (!context) { return null } - - if (rawHref.toLowerCase().startsWith('file:')) { - return resolveFileUriLink(rawHref, context) - } - if (hasNonFileUriProtocol(rawHref)) { + const route = routeNativeChatHref(href) + if (route.kind !== 'file') { return null } - - const { pathText, line } = stripQueryAndHash(rawHref) - const decodedPathText = maybeDecodeHrefPath(pathText) - return resolvePathText(decodedPathText, line, context) + return resolvePathText(route.pathText, route.line, context) } diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts index 8475167186f..c4de028b244 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.test.ts @@ -9,17 +9,36 @@ import { createNativeChatPtySessionOptions } from './native-chat-pty-session-opt describe('native chat PTY session options', () => { beforeEach(() => clearNativeChatSessionOptionCacheForTests()) - it('starts attached sessions unknown and hides model-scoped options', () => { + it('renders nothing when no model list exists at all', () => { + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + initialModels: [], + mode: 'live', + dispatchCommand: vi.fn() + })! + expect(surface.getSnapshot()).toEqual([]) + }) + + it('renders the version-neutral seed picker before any host catalog arrives', () => { const surface = createNativeChatPtySessionOptions({ agent: 'claude', scopeKey: 'pty-1', mode: 'live', dispatchCommand: vi.fn() })! - expect(surface.getSnapshot()).toHaveLength(1) + expect(surface.getSnapshot()[0]).toMatchObject({ id: 'model', - valueSource: 'unknown' + valueSource: 'unknown', + kind: { + choices: [ + expect.objectContaining({ value: 'fable', label: 'Fable' }), + expect.objectContaining({ value: 'opus', label: 'Opus' }), + expect.objectContaining({ value: 'sonnet', label: 'Sonnet' }), + expect.objectContaining({ value: 'haiku', label: 'Haiku' }) + ] + } }) }) @@ -122,7 +141,7 @@ describe('native chat PTY session options', () => { expect(dispatch).toHaveBeenCalledWith('/model fable', { detectAgentInteraction: 'claude-model-switch-confirmation', - expectedChoiceLabel: 'Fable 5' + expectedChoiceLabel: 'Fable' }) expect(onAgentPicker).not.toHaveBeenCalled() expect(result.snapshot[0]).toMatchObject({ @@ -603,6 +622,58 @@ describe('native chat PTY session options', () => { }) }) + it('keeps a tracked alias selectable when the host catalog omits it', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { + model: 'opus', + effort: 'xhigh' + }) + const dispatch = vi.fn() + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + // Why: current CLIs list `opus[1m]` and no plain `opus`. + initialModels: [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'sonnet', label: 'Sonnet', options: [] } + ], + mode: 'live', + dispatchCommand: dispatch + })! + + expect(surface.getSnapshot()[0].kind).toMatchObject({ + currentValue: 'opus', + choices: expect.arrayContaining([ + { value: 'opus', label: 'Opus', description: expect.any(String) } + ]) + }) + expect(surface.getSnapshot().find(({ id }) => id === 'effort')).toMatchObject({ + settable: true, + kind: { currentValue: 'xhigh' } + }) + + await surface.setOption('effort', 'high') + + expect(dispatch).toHaveBeenCalledWith('/effort high') + }) + + it('drops the reconciled row once the tracked model moves onto the host catalog', async () => { + seedNativeChatAppliedSessionOptions('pty-1', 'claude', { model: 'opus' }) + const surface = createNativeChatPtySessionOptions({ + agent: 'claude', + scopeKey: 'pty-1', + initialModels: [{ id: 'opus[1m]', label: 'Opus (1M context)', options: [] }], + mode: 'live', + dispatchCommand: vi.fn() + })! + + await surface.setOption('model', 'opus[1m]') + + expect(surface.getSnapshot()[0].kind).toMatchObject({ + currentValue: 'opus[1m]', + choices: [{ value: 'opus[1m]', label: 'Opus (1M context)' }] + }) + }) + it('recomposes Cursor model slugs for live option changes', async () => { seedNativeChatAppliedSessionOptions('pty-1', 'cursor', { model: 'gpt-5.3-codex', diff --git a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts index a97b471edee..a48a3611a22 100644 --- a/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts +++ b/src/renderer/src/components/native-chat/native-chat-pty-session-options.ts @@ -1,5 +1,6 @@ import { getAgentSessionOptionCatalog, + type AgentSessionOptionCatalog, type CatalogModel } from '../../../../shared/agent-session-option-catalog' import type { AgentType } from '../../../../shared/agent-status-types' @@ -11,7 +12,8 @@ import type { import { createNativeChatSessionOptionRecord, readNativeChatSessionOptionCache, - writeNativeChatSessionOptionCache + writeNativeChatSessionOptionCache, + type NativeChatSessionOptionRecord } from './native-chat-session-option-cache' import { createSessionOptionAppliers } from './native-chat-session-option-apply' import { @@ -47,6 +49,24 @@ export type CreateNativeChatPtySessionOptionsArgs = { onDraftValuesChanged?: (values: Record) => void } +/** + * Why: the tracked model can sit outside the active list — a persisted default, + * or an alias this host's CLI no longer lists. Keeping a row for it preserves + * the labelled selection and the model's own options instead of blanking both. + */ +function withTrackedModel( + catalog: AgentSessionOptionCatalog, + models: readonly CatalogModel[], + record: NativeChatSessionOptionRecord +): CatalogModel[] { + const trackedId = typeof record.model?.value === 'string' ? record.model.value : null + if (!trackedId || models.some((model) => model.id === trackedId)) { + return [...models] + } + const seeded = catalog.models.find((model) => model.id === trackedId) + return [...models, seeded ?? { id: trackedId, label: trackedId, options: [] }] +} + export function createNativeChatPtySessionOptions( args: CreateNativeChatPtySessionOptionsArgs ): NativeChatPtySessionOptionsSurface | null { @@ -65,9 +85,10 @@ export function createNativeChatPtySessionOptions( if (args.reportedValues && applyNativeChatReportedSessionOptions(record, args.reportedValues)) { writeNativeChatSessionOptionCache(args.scopeKey, record) } + const activeModels = (): CatalogModel[] => withTrackedModel(catalog, models, record) let snapshot = buildNativeChatSessionOptionSnapshot({ catalog, - models, + models: activeModels(), record, mode: args.mode }) @@ -77,7 +98,7 @@ export function createNativeChatPtySessionOptions( writeNativeChatSessionOptionCache(args.scopeKey, record) snapshot = buildNativeChatSessionOptionSnapshot({ catalog, - models, + models: activeModels(), record, mode: args.mode }) @@ -124,7 +145,7 @@ export function createNativeChatPtySessionOptions( const appliers = createSessionOptionAppliers({ mode: args.mode, catalog, - getModels: () => models, + getModels: activeModels, getRecord: () => record, dispatchCommand: args.dispatchCommand, onAgentPicker: args.onAgentPicker, @@ -146,7 +167,7 @@ export function createNativeChatPtySessionOptions( recordOutgoingCommand: (command) => { const result = recordNativeChatSessionOptionCommand({ catalog, - models, + models: activeModels(), record, command, persist diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts index bdf8ee6d1a2..035f05312d4 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-discovery.ts @@ -1,8 +1,18 @@ import type { AgentType } from '../../../../shared/agent-status-types' -import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' -import { getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' +import { + createClaudeCatalogOptions, + type CatalogModel +} from '../../../../shared/agent-session-option-catalog' +import { + getCommitMessageModelDiscoveryHostKeyForLocalRuntime, + getCommitMessageModelDiscoveryHostKeyForScope +} from '../../../../shared/commit-message-host-key' import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft' import { getConnectionIdFromState } from '@/lib/connection-context' +import { + getLocalProjectExecutionRuntimeContext, + getWslDistroFromPath +} from '@/lib/local-preflight-context' import { discoverRuntimeCommitMessageModels, getRuntimeGitScope, @@ -15,6 +25,23 @@ export type NativeChatModelDiscoveryContext = { runtime: RuntimeGitContext } +export function resolveNativeChatModelDiscoveryHostKey( + state: Parameters[0], + worktreeId: string | null, + worktreePath: string, + scope: string | null | undefined +): string { + if (scope !== null) { + return getCommitMessageModelDiscoveryHostKeyForScope(scope) + } + const localProjectRuntime = getLocalProjectExecutionRuntimeContext(state, worktreeId) + const wslDistro = + localProjectRuntime?.status === 'resolved' && localProjectRuntime.runtime.kind === 'wsl' + ? localProjectRuntime.runtime.distro + : getWslDistroFromPath(worktreePath) + return getCommitMessageModelDiscoveryHostKeyForLocalRuntime(wslDistro) +} + export function resolveNativeChatModelDiscoveryContext( terminalTabId: string ): NativeChatModelDiscoveryContext | null { @@ -31,7 +58,7 @@ export function resolveNativeChatModelDiscoveryContext( const worktreePath = worktreeId ? (state.getKnownWorktreeById?.(worktreeId)?.path ?? '') : '' const scope = getRuntimeGitScope(settings, connectionId) return { - hostKey: getCommitMessageModelDiscoveryHostKeyForScope(scope), + hostKey: resolveNativeChatModelDiscoveryHostKey(state, worktreeId, worktreePath, scope), runtime: { settings, worktreeId, @@ -46,12 +73,23 @@ export async function discoverNativeChatCatalogModels( context: RuntimeGitContext ): Promise { const result = await discoverRuntimeCommitMessageModels(context, agent) - if (!result.success || result.models.length === 0) { + if ( + !result.success || + result.models.length === 0 || + (agent === 'claude' && result.catalogOrigin !== 'probe') + ) { return null } return result.models.map((model) => ({ id: model.id, label: model.label, - options: [] + ...(model.description ? { description: model.description } : {}), + options: + agent === 'claude' + ? createClaudeCatalogOptions({ + effortLevelIds: model.thinkingLevels?.map(({ id }) => id) ?? [], + supportsFastMode: model.supportsFastMode + }) + : [] })) } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts index 008d857ec9c..b46f78ef1b3 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CatalogModel } from '../../../../shared/agent-session-option-catalog' +import { + discoverNativeChatCatalogModels, + resolveNativeChatModelDiscoveryHostKey +} from './native-chat-session-option-discovery' import { clearNativeChatModelEnrichmentForTests, ensureNativeChatModelEnrichment, @@ -7,8 +11,20 @@ import { subscribeNativeChatEnrichedModels } from './native-chat-session-option-enrichment' +const mocks = vi.hoisted(() => ({ + discoverRuntimeCommitMessageModels: vi.fn() +})) + +vi.mock('@/runtime/runtime-git-client', () => ({ + discoverRuntimeCommitMessageModels: mocks.discoverRuntimeCommitMessageModels, + getRuntimeGitScope: vi.fn() +})) + describe('native chat session option enrichment', () => { - beforeEach(() => clearNativeChatModelEnrichmentForTests()) + beforeEach(() => { + clearNativeChatModelEnrichmentForTests() + mocks.discoverRuntimeCommitMessageModels.mockReset() + }) it('keeps reads synchronous while one host-scoped probe is in flight', async () => { let resolveDiscovery: ((models: CatalogModel[]) => void) | undefined @@ -55,7 +71,107 @@ describe('native chat session option enrichment', () => { it('does not probe agents whose catalogs have no discovery command', () => { const discover = vi.fn() - ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'local', discover }) + ensureNativeChatModelEnrichment({ agent: 'gemini', hostKey: 'local', discover }) expect(discover).not.toHaveBeenCalled() }) + + it('keeps WSL discovery separate from the Windows host and other distros', () => { + expect( + resolveNativeChatModelDiscoveryHostKey( + {} as never, + null, + '\\\\wsl.localhost\\Ubuntu\\home\\orca', + null + ) + ).toBe('wsl:Ubuntu') + expect( + resolveNativeChatModelDiscoveryHostKey( + {} as never, + null, + '\\\\wsl.localhost\\Debian\\home\\orca', + null + ) + ).toBe('wsl:Debian') + expect(resolveNativeChatModelDiscoveryHostKey({} as never, null, 'C:\\repo', null)).toBe( + 'local' + ) + }) + + it('uses only discovered Claude rows and capabilities per host', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'probe', + models: [ + { + id: 'opus[1m]', + label: 'Opus (1M context)', + description: 'Opus 5 with 1M context', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'high', label: 'High' } + ], + defaultThinkingLevel: 'low', + supportsFastMode: true + }, + { + id: 'sonnet', + label: 'Sonnet', + thinkingLevels: [{ id: 'medium', label: 'Medium' }] + } + ] + }) + const discover = vi.fn(() => + discoverNativeChatCatalogModels('claude', { + settings: {}, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + ) + const listener = vi.fn() + subscribeNativeChatEnrichedModels('claude', 'ssh:host', listener) + + ensureNativeChatModelEnrichment({ agent: 'claude', hostKey: 'ssh:host', discover }) + await vi.waitFor(() => expect(listener).toHaveBeenCalledOnce()) + + const models = readNativeChatEnrichedModels('claude', 'ssh:host')! + expect(models.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet']) + const sonnetEffort = models.find(({ id }) => id === 'sonnet')?.options[0] + expect(sonnetEffort?.kind).toMatchObject({ + type: 'select', + choices: [{ value: 'medium', label: 'Medium' }] + }) + expect(models.find(({ id }) => id === 'opus[1m]')).toMatchObject({ + id: 'opus[1m]', + description: 'Opus 5 with 1M context', + options: [ + expect.objectContaining({ + id: 'effort', + kind: expect.objectContaining({ + choices: [ + { value: 'low', label: 'Low' }, + { value: 'high', label: 'High' } + ] + }) + }), + expect.objectContaining({ id: 'fastMode' }) + ] + }) + expect(readNativeChatEnrichedModels('claude', 'local')).toBeNull() + }) + + it('does not advertise the Claude spec fallback when probing is unavailable', async () => { + mocks.discoverRuntimeCommitMessageModels.mockResolvedValue({ + success: true, + catalogOrigin: 'spec', + models: [{ id: 'sonnet', label: 'Sonnet' }] + }) + + await expect( + discoverNativeChatCatalogModels('claude', { + settings: {}, + worktreeId: 'repo::/worktree', + worktreePath: '/worktree' + }) + ).resolves.toBeNull() + }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts index 5fde5625c52..ee2ad888e6f 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-enrichment.ts @@ -72,7 +72,8 @@ export function ensureNativeChatModelEnrichment(args: { if (!discovered || discovered.length === 0) { return } - entry.models = mergeCatalogModels(catalog.models, discovered) + entry.models = + args.agent === 'claude' ? [...discovered] : mergeCatalogModels(catalog.models, discovered) for (const listener of entry.listeners) { listener([...entry.models]) } diff --git a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts index 125bc1dbb9b..273267f3690 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-option-snapshot.ts @@ -120,15 +120,17 @@ export function buildNativeChatSessionOptionSnapshot(args: { mode: NativeChatSessionOptionMode }): SessionOptionDescriptor[] { const { catalog, models, record, mode } = args + if (models.length === 0) { + return [] + } const modelTracked = record.model - const modelChoices = choiceWithCurrent( - models.map(({ id, label, description }) => ({ - value: id, - label, - ...(description ? { description } : {}) - })), - modelTracked - ) + // Why: callers reconcile the tracked model into `models`, so every listed row + // is a real choice and the trigger never shows a value without one. + const modelChoices = models.map(({ id, label, description }) => ({ + value: id, + label, + ...(description ? { description } : {}) + })) const modelSettable = settableState({ mode, apply: catalog.modelApply }) const modelAction = actionForApply(catalog.modelApply, modelTracked, mode) const snapshot: SessionOptionDescriptor[] = [ diff --git a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts index 23c5f87a294..b0dfe6503fe 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-session-options.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-session-options.ts @@ -54,6 +54,9 @@ export function useNativeChatSessionOptions(args: { agent, scopeKey, ...(targetPtyId ? { fallbackScopeKey: terminalTabId } : {}), + // Why: the catalog seed carries version-neutral family labels, so it is + // safe on every host while the once-per-host probe runs or after it fails + // — without it the whole picker would pop in late or never appear. ...(discoveryContext ? { initialModels: diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 2a1412cde94..028abac821c 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -3700,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, 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/CommitMessageAiPane.test.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx index aaab680a016..fba530089bf 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.test.tsx @@ -5,6 +5,7 @@ import type { GlobalSettings } from '../../../../shared/types' import type { SourceControlAiSettings } from '../../../../shared/source-control-ai-types' import { getCommitMessageModelDiscoveryHostKey, + getCommitMessageModelDiscoveryHostKeyForLocalRuntime, getCommitMessageModelDiscoveryHostKeyForScope } from '../../../../shared/commit-message-host-key' import { useAppStore } from '../../store' @@ -441,6 +442,8 @@ describe('CommitMessageAiPane', () => { expect(getCommitMessageModelDiscoveryHostKey(null)).toBe('local') expect(getCommitMessageModelDiscoveryHostKey('ssh-1')).toBe('ssh:ssh-1') expect(getCommitMessageModelDiscoveryHostKey(undefined)).toBe('unknown') + expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime('Ubuntu')).toBe('wsl:Ubuntu') + expect(getCommitMessageModelDiscoveryHostKeyForLocalRuntime(null)).toBe('local') expect(getCommitMessageModelDiscoveryHostKeyForScope('runtime:env-1')).toBe('runtime:env-1') expect(getCommitMessageModelDiscoveryHostKeyForScope('ssh-1')).toBe('ssh:ssh-1') }) diff --git a/src/renderer/src/components/settings/ReleaseChannelSection.tsx b/src/renderer/src/components/settings/ReleaseChannelSection.tsx index dac9f1b0fa2..b89d8aa5084 100644 --- a/src/renderer/src/components/settings/ReleaseChannelSection.tsx +++ b/src/renderer/src/components/settings/ReleaseChannelSection.tsx @@ -29,7 +29,7 @@ const CHANNEL_DESCRIPTIONS: Record = { function formatBuildLabel(build: ReleaseBuild): string { // Why the release's own title wins: the build workflows compose it (hourly - // `1.4.163 • 01 • 07-31 13:54 • e698241`, adhoc `1.4.163 • wasm-terminal • …`), + // `1.4.163 • 01 • Jul 31, 1:54PM • e698241`, adhoc `1.4.163 • wasm-terminal • …`), // so this row is the same string the GitHub releases list shows — one thing to // search for in either place, rather than two renderings of the same build that // have to be matched up by eye. For adhoc it is also the only place the branch 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/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 ? (

{/* Why: Close backs the user out, so it stays quiet (outline, not a - solid CTA) — matching the sibling SshDisconnectedDialog. */} + solid CTA) — matching the other sidebar dialogs. */} 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/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/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/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/command-code-output-ownership.test.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts new file mode 100644 index 00000000000..18a3b7df437 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' + +describe('canCommandCodeOutputOwnPane', () => { + it('allows the banner fallback when no stronger identity exists', () => { + expect(canCommandCodeOutputOwnPane({})).toBe(true) + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'unknown' })).toBe(true) + }) + + it('allows Command Code ownership evidence', () => { + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'command-code' })).toBe(true) + expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'command-code' })).toBe(true) + }) + + it('rejects another agent owner or foreground process', () => { + expect(canCommandCodeOutputOwnPane({ paneOwnerAgent: 'claude' })).toBe(false) + expect(canCommandCodeOutputOwnPane({ foregroundAgent: 'claude' })).toBe(false) + expect( + canCommandCodeOutputOwnPane({ + paneOwnerAgent: 'unknown', + retainedPaneOwnerAgent: 'claude' + }) + ).toBe(false) + }) + + it('prefers the current foreground process over stale pane ownership', () => { + expect( + canCommandCodeOutputOwnPane({ + foregroundAgent: 'command-code', + paneOwnerAgent: 'claude', + retainedPaneOwnerAgent: 'claude' + }) + ).toBe(true) + expect( + canCommandCodeOutputOwnPane({ + foregroundAgent: 'claude', + paneOwnerAgent: 'command-code' + }) + ).toBe(false) + }) + + it('rejects output observed at a confirmed shell prompt', () => { + expect(canCommandCodeOutputOwnPane({ shellForeground: true })).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts new file mode 100644 index 00000000000..65f5f196ca0 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/command-code-output-ownership.ts @@ -0,0 +1,21 @@ +import type { AgentType } from '../../../../shared/agent-status-types' +import type { TuiAgent } from '../../../../shared/types' + +export function canCommandCodeOutputOwnPane(args: { + foregroundAgent?: TuiAgent | null + shellForeground?: boolean + paneOwnerAgent?: AgentType | null + retainedPaneOwnerAgent?: AgentType | null +}): boolean { + if (args.foregroundAgent) { + return args.foregroundAgent === 'command-code' + } + if (args.shellForeground) { + return false + } + const paneOwnerAgent = + args.paneOwnerAgent && args.paneOwnerAgent !== 'unknown' + ? args.paneOwnerAgent + : (args.retainedPaneOwnerAgent ?? args.paneOwnerAgent) + return !paneOwnerAgent || paneOwnerAgent === 'unknown' || paneOwnerAgent === 'command-code' +} 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/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts index 21d912dd7c0..959a8069db3 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -406,7 +406,7 @@ describe('startParkedTerminalByteWatcher', () => { it('feeds Command Code output through the parked byte detector', async () => { const { dispose } = await startWatcher() - emit('# Command Code v0.27.2') + emit('# Command Code v0.27.2\r\n') emit('⌘ Parsing...') expect(commandStatusPolicy.onCommandCodeWorking).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index 24991e01914..e9005fdd11f 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' const PTY_ID_LOCAL = 'pty-1' const PTY_ID_SSH = 'ssh:target-1@@pty-9' @@ -13,7 +14,11 @@ const DONE_SETTLE_MS = 1500 const ROUTING = { connectionId: null } type MockStoreState = { + tabsByWorktree: Record agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record + paneForegroundAgentByPaneKey: Record + agentLaunchConfigByPaneKey: Record runtimePaneTitlesByTabId: Record> setAgentStatus: ReturnType dropAgentStatus: ReturnType @@ -40,7 +45,11 @@ vi.mock('@/lib/connection-owner-resolution', () => ({ function makeMockStoreState(): MockStoreState { return { + tabsByWorktree: { [WORKTREE_ID]: [{ id: TAB_ID }] }, agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, + agentLaunchConfigByPaneKey: {}, runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } }, setAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), @@ -110,6 +119,80 @@ describe('createParkedTerminalCommandStatusPolicy', () => { policy.dispose() }) + it('rejects scrape status when a Claude hook owns the pane', async () => { + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + policy.onCommandCodeDone('False prompt') + vi.advanceTimersByTime(DONE_SETTLE_MS) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('rejects scrape status when retained Claude identity owns the pane', async () => { + mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' } + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ agentType: 'unknown' }) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + policy.onCommandCodeDone('False prompt') + vi.advanceTimersByTime(DONE_SETTLE_MS) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('rejects scrape status when Claude launch metadata owns the pane', async () => { + mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }] + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('rejects scrape status when Claude is the foreground process', async () => { + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'claude', + shellForeground: false + } + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('False prompt') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + policy.dispose() + }) + + it('lets a current Command Code process reclaim stale Claude ownership', async () => { + mockStoreState.tabsByWorktree[WORKTREE_ID] = [{ id: TAB_ID, launchAgent: 'claude' }] + mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done' }) + mockStoreState.retainedAgentsByPaneKey[PANE_KEY] = { agentType: 'claude' } + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'command-code', + shellForeground: false + } + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandCodeWorking('New Command Code prompt') + + expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith( + PANE_KEY, + { + state: 'working', + prompt: 'New Command Code prompt', + agentType: 'command-code' + }, + '✳ Build feature', + undefined, + ROUTING + ) + policy.dispose() + }) + it('leaves a settled done row alone when working repeats the same prompt', async () => { mockStoreState.agentStatusByPaneKey[PANE_KEY] = makeStatusEntry({ state: 'done', diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 781625318bd..9f54e02e87f 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -5,6 +5,7 @@ * (foreground process-confirm ladder, key-intent interrupt inference) stay with the mounted pane. */ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' @@ -15,6 +16,7 @@ import { openCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } from './command-code-done-settle' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' export type ParkedTerminalCommandStatusPolicy = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -60,6 +62,23 @@ export function createParkedTerminalCommandStatusPolicy(options: { }) } + const canApplyCommandCodeOutputStatus = (): boolean => { + const state = useAppStore.getState() + const tab = (state.tabsByWorktree[worktreeId] ?? []).find((entry) => entry.id === tabId) + const foreground = state.paneForegroundAgentByPaneKey[paneKey] + const paneOwnerAgent = resolvePaneAgentOwner({ + launchAgent: tab?.launchAgent, + startupLaunchAgent: state.agentLaunchConfigByPaneKey[paneKey]?.identity.agentType, + hookAgent: state.agentStatusByPaneKey[paneKey]?.agentType + }) + return canCommandCodeOutputOwnPane({ + foregroundAgent: foreground?.agent, + shellForeground: foreground?.shellForeground, + paneOwnerAgent, + retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[paneKey]?.agentType + }) + } + // Port of pty-connection's dropCommandFinishedStatusIfSameTurn, minus the interrupt-inference // option: parked panes receive no key events, so inference never has evidence here. const dropCommandFinishedStatusIfSameTurn = (entry: AgentStatusEntry | undefined): void => { @@ -145,6 +164,9 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Port of pty-connection's seedCommandCodeOutputWorkingStatus (store-level only). onCommandCodeWorking: (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } clearCommandCodeOutputDoneTimer() const routing = resolveRouting() if (!routing) { @@ -178,6 +200,9 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Port of pty-connection's scheduleCommandCodeOutputDoneStatus: Command Code keeps rendering // the composer while tools run, so only complete the row if no active repaint arrives. onCommandCodeDone: (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } const normalizedPrompt = prompt.trim() if (!normalizedPrompt) { cancelCommandCodeDoneSettle(paneKey) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 8bb448ed11a..5f5cf3a0afc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -17,6 +17,7 @@ import { TERMINAL_PASTE_DIRECT_MAX_BYTES } from './terminal-paste-coordinator' import { resolveWindowsShiftEnterEncodingForPane } from './terminal-windows-shift-enter' import type * as UseNotificationDispatchModule from './use-notification-dispatch' import { getEagerPtyBufferHandle } from './pty-dispatcher' +import type { AgentType } from '../../../../shared/agent-status-types' import { makePaneKey } from '../../../../shared/stable-pane-id' import { toAppSshPtyId } from '../../../../shared/ssh-pty-id' import type { SshConnectionState } from '../../../../shared/ssh-types' @@ -227,6 +228,7 @@ type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record + retainedAgentsByPaneKey: Record paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record suppressedPtyExitIds: Record @@ -928,6 +930,7 @@ describe('connectPanePty', () => { consumePendingSnapshot: vi.fn(() => null), runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, + retainedAgentsByPaneKey: {}, paneForegroundAgentByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, suppressedPtyExitIds: {}, @@ -18999,6 +19002,90 @@ describe('connectPanePty', () => { }) }) + it('rejects Command Code facts when Claude owns the pane', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + const claudeStatus = { + paneKey, + state: 'done' as const, + prompt: 'Previous Claude turn', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude' as const, + stateHistory: [] + } + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: 'tab-1', ptyId: null, launchAgent: 'claude' }] + } + mockStoreState.agentStatusByPaneKey[paneKey] = claudeStatus + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = { + agent: 'claude', + shellForeground: false + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-false-cc') + mockStoreState.setAgentStatus.mockClear() + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-false-cc', + seq: 1, + facts: [ + { kind: 'command-code-working', prompt: 'False prompt' }, + { kind: 'command-code-done', prompt: 'False prompt' } + ] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(claudeStatus) + }) + + it('rejects Command Code facts when retained Claude identity owns the pane', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + const unknownStatus = { + paneKey, + state: 'working' as const, + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'unknown' as const, + stateHistory: [] + } + mockStoreState.retainedAgentsByPaneKey[paneKey] = { agentType: 'claude' } + mockStoreState.agentStatusByPaneKey[paneKey] = unknownStatus + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-retained-false-cc') + mockStoreState.setAgentStatus.mockClear() + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-retained-false-cc', + seq: 1, + facts: [ + { kind: 'command-code-working', prompt: 'False prompt' }, + { kind: 'command-code-done', prompt: 'False prompt' } + ] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toBe(unknownStatus) + }) + it('keeps Command Code working when a working fact lands before the done settles', async () => { enableMainAuthority() const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index fdace14f0d6..d0b0c2a3338 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -267,6 +267,7 @@ import { openCommandCodeDoneSettle, setCommandCodeDoneSettleExecutor } from './command-code-done-settle' +import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' import { isTerminalTabParked } from './terminal-parked-watcher-registry' import { getExecutionHostIdForWorktree, @@ -2818,7 +2819,21 @@ export function connectPanePty( .setAgentStatus(cacheKey, statusPayload, terminalTitle, undefined, routing) } + const canApplyCommandCodeOutputStatus = (): boolean => { + const state = useAppStore.getState() + const foreground = state.paneForegroundAgentByPaneKey[cacheKey] + return canCommandCodeOutputOwnPane({ + foregroundAgent: foreground?.agent, + shellForeground: foreground?.shellForeground, + paneOwnerAgent: getAuthoritativePaneAgent(), + retainedPaneOwnerAgent: state.retainedAgentsByPaneKey[cacheKey]?.agentType + }) + } + const seedCommandCodeOutputWorkingStatus = (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } clearCommandCodeOutputDoneTimer() const routing = resolveCurrentAgentStatusRouting() if (!routing) { @@ -2885,6 +2900,9 @@ export function connectPanePty( ) const clearCommandCodeOutputDoneTimer = (): void => cancelCommandCodeDoneSettle(cacheKey) const scheduleCommandCodeOutputDoneStatus = (prompt: string): void => { + if (!canApplyCommandCodeOutputStatus()) { + return + } const normalizedPrompt = prompt.trim() if (!normalizedPrompt) { cancelCommandCodeDoneSettle(cacheKey) @@ -8246,7 +8264,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/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index 17271179620..e2d4aac2929 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -257,8 +257,8 @@ describe('resumeTerminalVisibility reveal repaint', () => { // every refocus forces a mass re-rasterization that can hit xterm's atlas // page-merge race (#4480) and garble streaming panes. Focus recovery must // resume rendering and present WITHOUT the atlas-clearing reveal repaint — - // scheduleRevealRepaint clears each pane's (shared) atlas, so the refocus - // path must route to the atlas-preserving present instead. + // scheduleRevealRepaint runs shared-atlas recovery, so the refocus path + // must route to the atlas-preserving present instead. const { resetAndRefreshAllTerminalWebglAtlases } = vi.mocked( await import('@/lib/pane-manager/pane-manager-registry') ) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 5c6b8314545..70787266204 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -94,8 +94,8 @@ export function resumeTerminalVisibility({ resetAndRefreshAllTerminalWebglAtlases() } // Why: the synchronous recovery above can fire before the revealed pane is - // attached and laid out, where the WebGL renderer drops redraw requests - // without retry. Follow up with a settled-frame, pane-scoped repaint. + // attached and laid out. Follow up after layout with one shared-atlas-safe + // recovery covering every visible terminal manager. manager.scheduleRevealRepaint() }) } @@ -171,9 +171,9 @@ export function recoverVisibleTerminalWindowWake({ resetAndRefreshAllTerminalWebglAtlases() manager.scheduleRevealRepaint() } else { - // Why: the reveal repaint clears each pane's texture atlas (a shared, - // same-config wipe), so a plain refocus must use the atlas-preserving - // present instead — otherwise it re-arms the same mid-stream garble race. + // Why: the reveal repaint runs a shared-atlas reset, so a plain refocus + // must use the atlas-preserving present instead — otherwise it re-arms the + // same mid-stream garble race. manager.scheduleRevealPresent() } } diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 35c48344e63..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, @@ -2806,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 c8086bd3dbd..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.", @@ -4447,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" @@ -4693,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", @@ -5155,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": { @@ -8489,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" } } }, @@ -9312,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" } } } @@ -9353,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" } } } @@ -9817,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", @@ -13486,7 +13523,8 @@ "4a9568f773": "Back", "4f86e2a10b": "Skip tour", "d974f32a83": "Dismiss tour", - "ffa4412b66": "next" + "ffa4412b66": "next", + "complete": "Done" }, "ContextualTourProgressDots": { "7734cb8ad3": "of", @@ -13496,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." + } + } } } } @@ -13918,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...", @@ -14031,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": { @@ -14574,6 +14642,14 @@ "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…" + } } }, "components": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 14dd169539e..dbe94a85ab4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -4356,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" @@ -9679,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", @@ -14361,6 +14352,14 @@ "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" + } } }, "components": { @@ -14613,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 9ec2f14a80d..830e17809cd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -4223,7 +4223,7 @@ "SidebarNav": { "80611a8b10": "検索", "0c3395fd32": "ワークツリーとブラウザタブを検索する", - "c86d83b5c3": "新規", + "c86d83b5c3": "新機能", "1b5c41caee": "Orca モバイル", "9c95e1ce91": "エージェント", "f323383e9a": "自動化", @@ -4337,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": "接続" @@ -4759,7 +4746,7 @@ "ddf25b6262": "エメラルド", "7cebab6d4a": "アンバー", "1b81da243a": "バイオレット", - "6437a8c253": "空", + "6437a8c253": "空色", "fc3b92756c": "青", "52e3c6e2a4": "中性" } @@ -9679,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", @@ -10994,8 +10985,8 @@ "ed93b4f14f": "Commit", "946a8a05ea": "このブランチの {{value0}} を作成します", "e7ffa46946": "{{value0}} を作成する", - "95550cff15": "押す", - "d64292a938": "引く", + "95550cff15": "プッシュ", + "d64292a938": "プル", "795f1509c5": "同期", "390abeab93": "フォースプッシュ", "1884cf34af": "このブランチをオリジンに公開します", @@ -11021,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}} を作成できるか確認中…" } } } @@ -14361,6 +14352,14 @@ "emptyTrace": "この GitLab ジョブのログはありません。", "timedOut": "GitLab ジョブのログの読み込みがタイムアウトしました。" } + }, + "ssh": { + "sshConnectVerb": { + "connect": "接続", + "connecting": "接続中…", + "reconnect": "再接続", + "retry": "再試行" + } } }, "components": { @@ -14613,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 152e6a771dc..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": "이 긴 입력과 일치하는 항목 없음" @@ -4223,7 +4223,7 @@ "SidebarNav": { "80611a8b10": "검색", "0c3395fd32": "작업 트리 및 브라우저 탭 검색", - "c86d83b5c3": "새로 만들기", + "c86d83b5c3": "신규", "1b5c41caee": "Orca 모바일", "9c95e1ce91": "에이전트", "f323383e9a": "자동화", @@ -4337,19 +4337,6 @@ "folderPathIdentity": "브랜치 / 폴더 경로", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "연결 중...", - "89385db176": "닫기", - "656368f3a2": "재연결 실패", - "376bed88e5": "원격 호스트 연결에 오류가 발생했습니다.", - "4afcca1d24": "다시 연결", - "11552bf786": "SSH 연결 끊김", - "cb5938ae79": "다시 연결하는 중...", - "disconnected": "이 SSH 호스트는 연결되어 있지 않습니다.", - "reconnecting": "원격 호스트에 다시 연결하는 중...", - "reconnectionFailed": "원격 호스트에 다시 연결하지 못했습니다.", - "authFailed": "원격 호스트 인증에 실패했습니다." - }, "SshTargetRow": { "4677394048": "연결 중…", "75ad429b5d": "연결" @@ -4658,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 을 실행한 후 삭제를 다시 시도하세요." } @@ -5296,7 +5283,7 @@ "1c9a9679ec": "{{value0}} 사용 가능 여부", "0d9e293a02": "새로고침", "c9b33eb5c0": "새로고침 중…", - "13647f9f80": "쉘 PATH를 다시 ​​읽고 설치된 agents를 다시 검색하십시오.", + "13647f9f80": "셸 PATH를 다시 ​​읽고 설치된 agents를 다시 검색하십시오.", "dc4a2ffdc0": "확장 명령 재정의", "cea7d97be1": "축소 명령 재정의", "db9e9e5887": "명령 사용자 정의", @@ -5404,7 +5391,7 @@ "9241b59bf5": "사용", "a869d0edd8": "브랜치 이름 명령 템플릿", "e784ea62dc": "고급", - "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca 라는 이름의 브랜치만 이름이 변경되며 푸시된 후에는 이름이 변경되지 않습니다.", + "d9b65054ef": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다.", "12ea4a408d": "agent가 새 워크스페이스에서 작업을 시작하면 Orca는 자동 생성된 브랜치의 이름을 바꿉니다(예:", "ef787db0e3": "브랜치 자동 이름 바꾸기", "6a051586d2": "agent가 시작되면 작업을 기반으로 자동 생성된 브랜치의 이름을 바꿉니다.", @@ -5561,7 +5548,7 @@ "15eaad0d31": "명령 경로:", "5dae812f50": "새로고침", "52e640f3a0": "CLI 상태 새로 고침", - "38edbb5721": "쉘 명령", + "38edbb5721": "셸 명령", "6930feda9e": "terminals에서 Orca를 사용하여 앱을 열고, 작업 트리를 관리하고, Orca terminals과 상호 작용할 수 있습니다.", "c5c0f2641d": "Orca CLI", "d77352f2df": "PATH에서 `{{value0}}`을(를) 제거하지 못했습니다.", @@ -5593,7 +5580,7 @@ "0c9f3cf9da": "Orca가 글로벌 agent 스킬을 확인하고 설치하는 위치를 선택합니다.", "f00d6aa9b5": "이 컴퓨터에서는 WSL을 사용할 수 없습니다.", "7c776ff9d8": "wsl", - "fc0fcf72fd": "스킬 설정 전 WSL 쉘 명령어를 등록하세요.", + "fc0fcf72fd": "스킬 설정 전 WSL 셸 명령어를 등록하세요.", "windowsPathUnknown": "WSL shell command PATH could not be checked" }, "CommitMessageAiPane": { @@ -5663,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": "블루투스", @@ -5745,9 +5732,9 @@ "9fb225f2d7": "플로팅 버튼", "3c900e26e5": "키보드 단축키는 토글이 표시되는 위치에 관계없이 작동합니다.", "5e5a8da236": "토글 버튼 위치", - "505001823e": "플로팅 워크스페이스 디렉토리 선택", + "505001823e": "플로팅 워크스페이스 디렉터리 선택", "81afb79785": "새로운 플로팅 terminal 탭이 여기에서 시작됩니다. Markdown 메모는 Orca의 앱 소유 플로팅 워크스페이스에 저장됩니다.", - "12aa09f10c": "Terminal 디렉토리", + "12aa09f10c": "Terminal 디렉터리", "41eb95f7f0": "플로팅 워크스페이스 버튼과 패널을 표시합니다.", "5136813663": "플로팅 워크스페이스 활성화", "37df688d6f": "플로팅 워크스페이스를 활성화하고 새 탭이 시작되는 위치를 선택하세요.", @@ -5873,7 +5860,7 @@ "ba3480642f": "워크스페이스 중첩", "a246f5ce6f": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", "5567191a6e": "찾아보기", - "0e9fc0eadc": "워크스페이스 디렉토리", + "0e9fc0eadc": "워크스페이스 디렉터리", "e2955d9ccb": "새 워크스페이스가 생성되는 위치를 구성합니다.", "7511097c5d": "워크스페이스" }, @@ -6385,7 +6372,7 @@ "8567127a40": "작업 트리가 생성되거나 보관될 때 실행되는 스크립트입니다. 로컬 스크립트는 이 시스템에 저장됩니다. `orca.yaml` 스크립트는 팀과 공유됩니다.", "ff082fe7c6": "작업트리 후크", "5d940bde5c": "로컬 스크립트 추가", - "8c2893fae0": "단일 쉘 스크립트로 실행됩니다. 이 컴퓨터에 저장되었습니다.", + "8c2893fae0": "단일 셸 스크립트로 실행됩니다. 이 컴퓨터에 저장되었습니다.", "40a446ae16": "- 이 기기에서 나만 사용", "2d03a514db": "로컬", "7e4427b4a2": "변경합니다.", @@ -6930,7 +6917,7 @@ "2d7d45e991": "취소", "c240a16f25": "packages/web 또는 apps/api와 같은 repo 상대 경로를 사용하세요.", "fde7ff2cc3": "packages/web shared/ui", - "caf33029cc": "디렉토리", + "caf33029cc": "디렉터리", "3b6f1abd3e": "예를 들어 웹 전용", "a6fcdd9e3c": "이름", "b9922ec194": "사전 설정 편집 취소", @@ -6940,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}} 삭제" @@ -7207,7 +7194,7 @@ "f61ac77f16": "Git Bash", "0f1b8669e6": "명령 프롬프트", "eb7fc4d98a": "PowerShell", - "27e301f22c": "기본 쉘", + "27e301f22c": "기본 셸", "09bf02de9a": "새 terminals 패널을 열 때 사용할 셸입니다. 새 terminals에 적용됩니다.", "bd68f3170d": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.", "a55eee649f": "Windows의 새 terminal 패널에 대한 기본 셸입니다.", @@ -7329,7 +7316,7 @@ "7f4063076c": "커서 아래의 텍스트 색상(블록 커서)", "a2d9f095a7": "Cursor 텍스트", "cd0700762b": "Cursor 색상", - "c9e1fdf42f": "Cursor", + "c9e1fdf42f": "커서", "da64e8f4c1": "Terminal 배경색", "cc1b2ffeb2": "배경", "026a0b8013": "본문 색상", @@ -8002,7 +7989,7 @@ "search": { "94f4d013c8": "상태 표시줄", "a452146574": "토글 버튼", - "6765b85e48": "실행 디렉토리", + "6765b85e48": "실행 디렉터리", "a38bfc3f77": "퀵 패널", "52db6e3baf": "메모", "156ffeee08": "메모", @@ -8153,7 +8140,7 @@ "7887a2c262": "폴더", "7baf524b04": "워크스페이스", "d0bc793689": "워크스페이스 폴더가 생성되는 루트 디렉터리입니다.", - "4c95d08fa2": "워크스페이스 디렉토리", + "4c95d08fa2": "워크스페이스 디렉터리", "161a86a9da": "고정된 탭을 닫기 전에 확인", "8e593f04fc": "고정된 탭을 닫기 전에 확인 대화상자를 표시합니다.", "defaultProjectRuntime": "기본 프로젝트 런타임", @@ -8613,7 +8600,7 @@ "c86478c3d8": "Orca에서 이 프로젝트를 제거하세요.", "c5266c2c9d": "프로젝트 제거", "4b9a18a56d": "모노 repo", - "4e2529722c": "디렉토리", + "4e2529722c": "디렉터리", "1ff4f12c0c": "디렉터리", "9f5ae26ccd": "사전 설정", "095fca94fe": "사전 설정", @@ -8994,7 +8981,7 @@ "6cd20b9e64": "cmd", "7c7056940a": "셸", "713c4a2f92": "Windows의 새 terminal 패널에 대한 기본 셸을 선택합니다.", - "13715f9d23": "기본 쉘" + "13715f9d23": "기본 셸" } } }, @@ -9032,7 +9019,7 @@ "action": { "recipe": { "options": { - "commitMessage": "단계적 변경에서 commit 메시지를 생성합니다.", + "commitMessage": "스테이징된 변경 사항에서 commit 메시지를 생성합니다.", "pullRequest": "호스팅된 리뷰 제목 및 설명을 생성합니다.", "branchName": "초기 agent 작업에서 Orca가 생성한 브랜치의 이름을 바꿉니다.", "fixCommitFailure": "commit 후크 또는 git commit이 실패하면 agent를 시작합니다.", @@ -9421,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": "프로필 연결", @@ -9471,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": "아직 레시피를 찾을 수 없습니다.", @@ -9679,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", @@ -10402,7 +10393,7 @@ "c07b236287": "변경사항을 보려면 워크스페이스를 선택하세요.", "dc5a6465fc": "{{value0}}(예: {{value1}}{{value2}})", "8eb3782a0c": "{{value0}} 파일{{value1}}을(를) 삭제하지 못했습니다.", - "a5e5a11090": "모두 삭제 실패 - 삭제하기 전에 파일을 스테이지 해제할 수 없습니다.", + "a5e5a11090": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다.", "8a5ba6a988": "commit 차이점을 로드하지 못했습니다.", "fe5bd1a610": "{{value0}} 생성 중...", "812cb992ee": "{{value0}}에서 열기", @@ -10420,7 +10411,7 @@ "d62bc0c7d8": "추적되지 않은", "ab31221779": "언스테이지 폴더", "bfe9011a0e": "스테이지 폴더", - "6d7f2a47e5": "폴더 삭제", + "6d7f2a47e5": "폴더의 변경 사항 취소", "9b367363b6": "추적되지 않은 폴더 삭제", "540ca8f78c": "병합 중단", "425f138269": "리베이스 중단", @@ -10905,7 +10896,7 @@ "542bf6a7e2": "기울임", "256300f8ea": "굵게", "87aff03d63": "전송 중...", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." } } } @@ -10963,7 +10954,7 @@ "5ddd8cac7f": "스테이징된 변경 사항을 모두 삭제하시겠습니까?", "1426c2efff": "그러면 이 파일의 모든 변경 사항이 되돌려집니다. 이 작업은 취소할 수 없습니다.", "d4df3a61df": "'{{value0}}' 변경사항을 취소하시겠습니까?", - "40e9357b2a": "이렇게 하면 HEAD에서 파일이 복원되고 삭제 내용이 삭제됩니다. 이 작업은 취소할 수 없습니다.", + "40e9357b2a": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다.", "5c0bdbc4cb": "'{{value0}}'을(를) 복원하시겠습니까?", "d97bf697c9": "이 파일이 영구적으로 삭제됩니다. 이 작업은 취소할 수 없습니다.", "96c772bee9": "'{{value0}}'을 삭제하시겠습니까?" @@ -11004,7 +10995,7 @@ "41d4bcf157": "PR 상태 확인 중…", "acce237921": "commit 할 것이 없습니다. 브랜치에 게시할 변경 사항이 없습니다.", "fa3bd4f40c": "commit 할 파일을 하나 이상 스테이지하세요", - "5a477d80cb": "모든 변경 사항을 준비합니다.", + "5a477d80cb": "모든 변경 사항 스테이징", "18a0fca877": "모두 스테이지", "f01f16d77f": "commit 하려면 commit 메시지를 입력하세요.", "ab41fb926b": "스테이지된 변경 사항 Commit", @@ -11285,7 +11276,7 @@ "fastForwardOnly": "fast-forward만 허용", "fastForwardOnlyDescription": "병합이나 리베이스가 필요 없을 때만 pull합니다.", "title": "Pull 정책 필요", - "diverged": "Diverged", + "diverged": "분기됨", "body": "이 브랜치에는 로컬 commits과 원격 commits이 모두 있습니다. 이 워크트리 또는 SSH 호스트에서 명령 하나를 실행한 다음 풀 또는 동기화를 다시 시도하세요.", "copyAria": "{{value0}} pull 정책 명령 복사", "copied": "복사됨", @@ -11553,7 +11544,7 @@ "86c0f1caa2": "패딩", "06a24f4f2d": "색상", "c021e9dddd": "테마 팔레트", - "ab2a583a97": "Cursor", + "ab2a583a97": "커서", "cc1858e19e": "글꼴", "248c812283": "가져오기", "7ee9234e54": "Ghostty 설정을 찾았습니다.", @@ -13205,7 +13196,7 @@ "2b3ce6d394": "취소", "e05063cfc1": "라인 {{value0}}", "c845170b3b": "라인 {{value0}}-{{value1}}", - "commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다." + "commentTooLarge": "댓글이 너무 길어 안전하게 제출할 수 없습니다." }, "useDiffCommentDecorator": { "995fa28b50": "이 메모" @@ -13290,7 +13281,8 @@ "4a9568f773": "뒤로", "4f86e2a10b": "둘러보기 건너뛰기", "d974f32a83": "투어 닫기", - "ffa4412b66": "다음" + "ffa4412b66": "다음", + "complete": "완료" }, "ContextualTourProgressDots": { "7734cb8ad3": "중", @@ -13300,7 +13292,17 @@ "tour": { "overlay": { "measurement": { - "38b3155418": "다음" + "38b3155418": "다음", + "automations": { + "intro": { + "title": "자동화란 무엇인가요?", + "body": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요." + }, + "results": { + "title": "결과 확인", + "body": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다." + } + } } } } @@ -14361,6 +14363,14 @@ "emptyTrace": "이 GitLab 작업의 로그를 사용할 수 없습니다.", "timedOut": "GitLab 작업 로그를 불러오는 중 시간이 초과되었습니다." } + }, + "ssh": { + "sshConnectVerb": { + "connect": "연결", + "connecting": "연결 중…", + "reconnect": "다시 연결", + "retry": "재시도" + } } }, "components": { @@ -14613,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 7f9907ab181..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": "添加项目", @@ -4235,7 +4235,7 @@ "SidebarNav": { "80611a8b10": "搜索", "0c3395fd32": "搜索工作树和浏览器选项卡", - "c86d83b5c3": "新建", + "c86d83b5c3": "新功能", "1b5c41caee": "Orca 手机端", "9c95e1ce91": "智能体", "f323383e9a": "自动化", @@ -4349,19 +4349,6 @@ "folderPathIdentity": "分支 / 文件夹路径", "cli": "Orca CLI" }, - "SshDisconnectedDialog": { - "ca4a7892af": "正在连接...", - "89385db176": "关闭", - "656368f3a2": "重连失败", - "376bed88e5": "与远程主机的连接遇到错误。", - "4afcca1d24": "重新连接", - "11552bf786": "SSH 已断开", - "cb5938ae79": "正在重新连接...", - "disconnected": "此 SSH 主机未连接。", - "reconnecting": "正在重新连接远程主机...", - "reconnectionFailed": "重新连接到远程主机失败。", - "authFailed": "远程主机身份验证失败。" - }, "SshTargetRow": { "4677394048": "正在连接…", "75ad429b5d": "连接" @@ -4471,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}}" @@ -4605,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": "未读:", @@ -4716,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": "工作区已保持打开。请重试;如果问题仍然存在,请检查主机连接。" } } }, @@ -4766,12 +4753,12 @@ "1a9383112b": "Conductor 进度", "caebe3c10f": "Conductor 评审", "895f381714": "Conductor 完成", - "caabd5ca85": "锌", + "caabd5ca85": "锌灰", "7adb43ecf0": "玫瑰", "ddf25b6262": "翡翠", "7cebab6d4a": "琥珀", "1b81da243a": "紫罗兰", - "6437a8c253": "天空", + "6437a8c253": "天蓝", "fc3b92756c": "蓝色", "52e3c6e2a4": "中性" } @@ -5056,7 +5043,7 @@ "parkTerminal": "Park terminal" }, "SidebarFeedbackImageAttachments": { - "screenshotsHint": "Attach up to {count} screenshots", + "screenshotsHint": "最多附加 {count} 张截图", "attachImages": "Attach", "removeImage": "Remove {{fileName}}" }, @@ -5242,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": "重新启动", @@ -5331,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": { @@ -5606,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 的存储库使用。", @@ -5804,7 +5791,7 @@ "4aa4d9fb73": "在差异编辑器中换行显示长行,而不是要求水平滚动。", "bf16ef0af2": "关闭", "3f6892f307": "开启", - "b82f86d7d2": "Rich Markdown Spellcheck", + "b82f86d7d2": "Markdown 拼写检查(富文本)", "5195f0b9ef": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "7ddd66fede": "编辑器自动换行", "9b18de6eea": "在文件编辑器中自动换行长行,而无需水平滚动。" @@ -6097,7 +6084,7 @@ }, "McpConfigFileRow": { "b145eb6009": "环境:", - "e720c139cd": "开放", + "e720c139cd": "打开", "845ae248e8": "有效的" }, "McpConfigSection": { @@ -7349,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": "重置" @@ -7376,7 +7363,7 @@ "8f4d2a51d7": "离线", "d504ab05f0": "流媒体", "fbe5990716": "选择型号", - "e24f7d43d2": "选择语音模型。本地模型离线运行;云模型需要 API 密钥。", + "e24f7d43d2": "选择语音模型,本地模型离线运行;云模型需要 API 密钥。", "174da92062": "抓住", "118b3c2dee": "切换", "901985625d": "切换", @@ -8170,12 +8157,12 @@ "8e593f04fc": "关闭固定标签页前显示确认对话框。", "defaultProjectRuntime": "默认项目运行时", "defaultProjectRuntimeDescription": "选择本地 Windows 项目继承的运行时。", - "d2d2d929c0": "Rich Markdown Spellcheck", + "d2d2d929c0": "Markdown 拼写检查(富文本)", "4497e2e2bb": "在编辑富 Markdown 时显示浏览器拼写下划线和建议。", "e61157e926": "编辑器自动换行", "005be5c699": "在文件编辑器中自动换行长行,而无需水平滚动。", "editorFontFamily": "编辑器字体", - "editorFontFamilyDesc": "文件编辑器和差异视图使用的字体。留空则跟随终端字体。" + "editorFontFamilyDesc": "文件编辑器和差异视图使用的字体,留空则跟随终端字体。" } }, "git": { @@ -8308,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", @@ -8618,7 +8605,7 @@ "cfad7ce5f3": "AI", "a47f51127e": "源代码控制", "6cc5c65e64": "项目特定的 git 生成覆盖。", - "eec3995dc6": "Git AI Author", + "eec3995dc6": "Git AI 作者", "cc876ca5f2": "存储库", "6469de5368": "项目", "3067595d82": "删除", @@ -9633,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", @@ -9691,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": "后台工作进程 — 运行自己的进程", @@ -9732,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": { @@ -9802,7 +9793,7 @@ "PluginMarketplaceListingRow": { "official": "官方", "installed": "已安装", - "noDescription": "No description provided.", + "noDescription": "未提供描述。", "blocked": "已被 Orca 安全列表阻止:{{value0}}", "blockedAction": "已阻止", "checkUpdate": "检查更新", @@ -10053,6 +10044,14 @@ "setupRecipe": "为你的云提供商设置环境模板。", "createWorkspace": "创建工作区,并在“运行于”中选择该模板。", "openSetup": "设置环境模板" + }, + "VoiceMicrophoneSetting": { + "systemDefault": "系统默认", + "unavailable": "不可用", + "label": "麦克风", + "description": "用于语音听写的输入设备,系统默认会跟随操作系统的麦克风设置。", + "accessHint": "允许麦克风访问以列出输入设备。", + "allowAccess": "允许访问" } }, "right": { @@ -11128,7 +11127,7 @@ "openSupportedWorkspace": "在恢复会话之前,请先打开一个工作区。", "localSessionSshWorkspaceUnsupported": "此会话的历史记录存储在本机上,因此无法在 SSH 工作区中恢复。请改为打开一个本地工作区。", "sessionHostMismatchUnsupported": "此会话属于其他主机。请打开同一主机上的工作区以恢复它。", - "prepareSessionResumeFailed": "Could not prepare this session for resume." + "prepareSessionResumeFailed": "无法为恢复准备此会话。" }, "AiVaultPanelControls": { "scanningSessions": "正在扫描会话", @@ -11330,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 刷新。正在显示上次已知状态。" } } } @@ -11565,7 +11564,7 @@ "86c0f1caa2": "内边距", "06a24f4f2d": "颜色", "c021e9dddd": "主题配色", - "ab2a583a97": "Cursor", + "ab2a583a97": "光标", "cc1858e19e": "字体", "248c812283": "导入", "7ee9234e54": "已检测 Ghostty 配置。", @@ -11690,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": { @@ -13196,7 +13195,7 @@ "save": { "failure": { "notice": { - "8c59ce5075": "Failed to save the file. Please try again." + "8c59ce5075": "保存文件失败。请重试。" } } } @@ -13911,7 +13910,7 @@ "79e7aaed39": "取消", "fdd26d81cc": "Atlassian 账户设置", "8090504a3e": "在此创建 token:", - "7b3967c12f": "Atlassian API token", + "7b3967c12f": "Atlassian API 令牌", "3d81bf3ab3": "API 令牌", "e91b9a4073": "you@example.com", "2849ddb295": "Atlassian 邮箱", @@ -13920,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", @@ -13929,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 账户用户名和密码。" } } @@ -14323,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": { @@ -14355,24 +14354,32 @@ }, "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": "重试" + } } }, "components": { @@ -14549,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", @@ -14582,7 +14589,7 @@ "subagents_other": "{{count}} 个子代理" }, "terminal": { - "closed": "No live terminal — this agent's pane has closed.", + "closed": "没有活动的终端 — 该智能体的窗格已关闭。", "focusWorktree": "Focus worktree", "close": "Close" }, @@ -14625,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/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index d864ce0de37..832f506f54f 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -47,7 +47,7 @@ export function resetAllTerminalWebglAtlases(): void { } } -export function resetAndRefreshAllTerminalWebglAtlases(): void { +export function resetAndRefreshAllTerminalWebglAtlases(reason?: string): void { // Why: the atlas wipe is the heavy recovery path; recording it lets a freeze // report show whether a post-wake repaint actually ran. Silent breadcrumb. const recoveryManagers = Array.from(liveManagers).filter( @@ -55,7 +55,8 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void { ) recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: recoveryManagers.length, - mountedManagers: liveManagers.size + mountedManagers: liveManagers.size, + ...(reason ? { reason } : {}) }) const resetManagers: RegisteredPaneManager[] = [] for (const manager of recoveryManagers) { diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts index 4985ffe1355..8604a0fd6dc 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.test.ts @@ -1,9 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { schedulePaneRevealPresent, schedulePaneRevealRepaint } from './pane-reveal-repaint' -import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' +import { registerLivePaneManager, unregisterLivePaneManager } from './pane-manager-registry' +import { resetTerminalWebglSuggestion, resetWebglTextureAtlas } from './pane-webgl-renderer' type FakeWebglAddon = { clearTextureAtlas: ReturnType } +type FakePaneManager = { + resetWebglTextureAtlases: Mock<() => void> + refreshAllPanes: Mock<() => void> +} function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): ManagedPaneInternal { const leafId = '33333333-3333-4333-8333-333333333333' as never @@ -46,6 +51,25 @@ function createPane(options: { webglAddon?: FakeWebglAddon | null } = {}): Manag describe('schedulePaneRevealRepaint', () => { let rafQueue: FrameRequestCallback[] + const registeredManagers: FakePaneManager[] = [] + + function registerPaneManager(getPanes: () => Iterable): FakePaneManager { + const manager: FakePaneManager = { + resetWebglTextureAtlases: vi.fn(() => { + for (const pane of getPanes()) { + resetWebglTextureAtlas(pane) + } + }), + refreshAllPanes: vi.fn(() => { + for (const pane of getPanes()) { + pane.terminal.refresh(0, pane.terminal.rows - 1) + } + }) + } + registerLivePaneManager(manager) + registeredManagers.push(manager) + return manager + } function flushFrame(): void { const queue = rafQueue @@ -66,6 +90,9 @@ describe('schedulePaneRevealRepaint', () => { }) afterEach(() => { + for (const manager of registeredManagers.splice(0)) { + unregisterLivePaneManager(manager) + } vi.unstubAllGlobals() vi.restoreAllMocks() }) @@ -73,6 +100,7 @@ describe('schedulePaneRevealRepaint', () => { it('repaints only after the post-reveal frame has settled', () => { const webglAddon = { clearTextureAtlas: vi.fn() } const pane = createPane({ webglAddon }) + registerPaneManager(() => [pane]) schedulePaneRevealRepaint(() => [pane]) // First frame: reveal layout may still be in flight; redraw requests fired @@ -86,14 +114,47 @@ describe('schedulePaneRevealRepaint', () => { expect(pane.terminal.refresh).toHaveBeenCalledWith(0, 23) }) + it('coordinates a settled atlas clear across recovery-eligible managers', () => { + const pane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const siblingPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const targetManager = registerPaneManager(() => [pane]) + const siblingManager = registerPaneManager(() => [siblingPane]) + + schedulePaneRevealRepaint(() => [pane]) + flushFrame() + flushFrame() + + expect(targetManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(siblingManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect((siblingPane.webglAddon as never as FakeWebglAddon).clearTextureAtlas).toHaveBeenCalled() + expect(siblingPane.terminal.refresh).toHaveBeenCalledWith(0, 23) + }) + + it('coalesces concurrent reveal clears into one global recovery', () => { + const firstPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const secondPane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) + const firstManager = registerPaneManager(() => [firstPane]) + const secondManager = registerPaneManager(() => [secondPane]) + + schedulePaneRevealRepaint(() => [firstPane]) + schedulePaneRevealRepaint(() => [secondPane]) + flushFrame() + flushFrame() + + expect(firstManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + expect(secondManager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) + }) + it('reattaches a missing WebGL addon before repainting', () => { const pane = createPane() + const manager = registerPaneManager(() => [pane]) schedulePaneRevealRepaint(() => [pane]) flushFrame() flushFrame() expect(pane.webglAddon).not.toBeNull() + expect(manager.resetWebglTextureAtlases).toHaveBeenCalledTimes(1) expect(pane.terminal.refresh).toHaveBeenCalled() }) @@ -101,6 +162,7 @@ describe('schedulePaneRevealRepaint', () => { const stalePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) const livePane = createPane({ webglAddon: { clearTextureAtlas: vi.fn() } }) const panes = [stalePane] + registerPaneManager(() => panes) schedulePaneRevealRepaint(() => panes) panes.splice(0, panes.length, livePane) @@ -123,6 +185,7 @@ describe('schedulePaneRevealRepaint', () => { } as never as ManagedPaneInternal const webglAddon = { clearTextureAtlas: vi.fn() } const livePane = createPane({ webglAddon }) + registerPaneManager(() => [explosivePane, livePane]) schedulePaneRevealRepaint(() => [explosivePane, livePane]) flushFrame() @@ -137,6 +200,7 @@ describe('schedulePaneRevealRepaint', () => { vi.stubGlobal('requestAnimationFrame', undefined) const webglAddon = { clearTextureAtlas: vi.fn() } const pane = createPane({ webglAddon }) + registerPaneManager(() => [pane]) schedulePaneRevealRepaint(() => [pane]) vi.runAllTimers() diff --git a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts index 9fb283782d2..bfc4797aa52 100644 --- a/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts +++ b/src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts @@ -1,6 +1,11 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { reattachWebglIfNeeded } from './pane-webgl-reattach' -import { resetWebglTextureAtlas } from './pane-webgl-renderer' +import { resetAndRefreshAllTerminalWebglAtlases } from './pane-manager-registry' + +type PaneGetter = () => Iterable + +const pendingRevealRepaints = new Set() +let revealRepaintScheduled = false function scheduleSettledFrame(callback: () => void): void { if (typeof globalThis.requestAnimationFrame !== 'function') { @@ -30,6 +35,34 @@ function forEachPaneOnSettledFrame( }) } +function flushPaneRevealRepaints(): void { + revealRepaintScheduled = false + const paneGetters = Array.from(pendingRevealRepaints) + pendingRevealRepaints.clear() + const livePanes = new Set() + + for (const getPanes of paneGetters) { + try { + for (const pane of getPanes()) { + livePanes.add(pane) + } + } catch { + /* ignore — a manager may be destroyed while its repaint is pending */ + } + } + + for (const pane of livePanes) { + try { + reattachWebglIfNeeded(pane) + } catch { + /* ignore — one pane's teardown must not block global recovery */ + } + } + if (livePanes.size > 0) { + resetAndRefreshAllTerminalWebglAtlases('settled-reveal') + } +} + /** * Repaints a revealed tab's panes from their xterm buffers. * @@ -37,14 +70,17 @@ function forEachPaneOnSettledFrame( * per-cell model without ever presenting a frame. At reveal the model diff * reports those cells unchanged, so plain refreshes skip them and the canvas * keeps compositing pre-hide pixels until a selection or resize rebuilds the - * model. Clearing the model per pane — after (re)attach, once layout has - * settled — forces a full rebuild from the buffer without any PTY resize. + * model. Once layout settles, reattach every revealed renderer before one + * registry-wide atlas reset so no delayed pane-local clear can invalidate a + * sibling terminal's rebuilt model. */ export function schedulePaneRevealRepaint(getPanes: () => Iterable): void { - forEachPaneOnSettledFrame(getPanes, (pane) => { - reattachWebglIfNeeded(pane) - resetWebglTextureAtlas(pane) - }) + pendingRevealRepaints.add(getPanes) + if (revealRepaintScheduled) { + return + } + revealRepaintScheduled = true + scheduleSettledFrame(flushPaneRevealRepaints) } /** 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/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index 2398252afb6..adf06f74206 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -62,6 +62,8 @@ type RuntimeDiscoverCommitMessageModelsResult = capability: CommitMessageAgentCapability models: CommitMessageModelCapability[] defaultModelId: string + /** Missing only when an older remote runtime produced the response. */ + catalogOrigin?: 'probe' | 'spec' } | { success: false; error: string } 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/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/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 75ad5985153..16a58fd985f 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -243,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> 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 310e3e82d8d..6cd073abbe1 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -51,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' @@ -1711,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( @@ -4498,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) @@ -4541,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) || @@ -4662,7 +4694,7 @@ export const createWorktreeSlice: StateCreator } }) if (shouldApplyUpdate && !didApply) { - return + return { ok: true } } if (hasHostedReviewLinkUpdates(enriched)) { bumpHostedReviewLinkMutationGeneration(worktreeId) @@ -4710,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) => { 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-option-catalog-claude-codex.ts b/src/shared/agent-session-option-catalog-claude-codex.ts index 47fa8226af7..71327f1d532 100644 --- a/src/shared/agent-session-option-catalog-claude-codex.ts +++ b/src/shared/agent-session-option-catalog-claude-codex.ts @@ -1,4 +1,13 @@ -import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' +import type { + AgentSessionOptionCatalog, + CatalogModel, + CatalogOption +} from './agent-session-option-catalog-types' +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN, + parseClaudeModelList +} from './claude-model-list-probe' function hasFlag(tokens: readonly string[], flags: readonly string[]): boolean { return tokens.some((token) => @@ -40,14 +49,20 @@ const EXTENDED_EFFORT_CHOICES = [ ] function claudeEffort(extended: boolean): CatalogOption { + return claudeEffortWithChoices(extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES) +} + +function claudeEffortWithChoices(choices: typeof EXTENDED_EFFORT_CHOICES): CatalogOption { return { id: 'effort', label: 'Effort', category: 'thought_level', kind: { type: 'select', - choices: extended ? EXTENDED_EFFORT_CHOICES : STANDARD_EFFORT_CHOICES, - defaultValue: 'high' + choices, + defaultValue: choices.some((choice) => choice.value === 'high') + ? 'high' + : (choices[0]?.value ?? 'high') }, apply: { launchArgs: (value) => ['--effort', String(value)], @@ -57,6 +72,33 @@ function claudeEffort(extended: boolean): CatalogOption { } } +export function createClaudeCatalogOptions(args: { + effortLevelIds: readonly string[] + supportsFastMode?: boolean +}): CatalogOption[] { + const effortChoices = EXTENDED_EFFORT_CHOICES.filter((choice) => + args.effortLevelIds.includes(choice.value) + ) + return [ + ...(effortChoices.length > 0 ? [claudeEffortWithChoices(effortChoices)] : []), + ...(args.supportsFastMode ? [CLAUDE_FAST_MODE] : []) + ] +} + +function parseClaudeCatalogModels(stdout: string): CatalogModel[] { + return parseClaudeModelList(stdout).map((model) => { + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + options: createClaudeCatalogOptions({ + effortLevelIds: model.effortLevels, + supportsFastMode: model.supportsFastMode + }) + } + }) +} + const CLAUDE_FAST_MODE: CatalogOption = { id: 'fastMode', label: 'Fast mode', @@ -66,26 +108,35 @@ const CLAUDE_FAST_MODE: CatalogOption = { } export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + // Why: these ids are Claude CLI aliases that resolve to the newest model of + // each family on the host's CLI (`opus` is Opus 5 on current CLIs, older + // Opus on older CLIs), so pinned version labels lie on part of the fleet. + // Family labels also keep header scraping and /model echo detection working + // across CLI versions; listModels overlays exact per-host names below. models: [ { id: 'fable', - label: 'Fable 5', + label: 'Fable', + description: 'Most capable for the hardest, longest-running tasks', options: [claudeEffort(true)] }, { id: 'opus', - label: 'Opus 4.8', + label: 'Opus', + description: 'Best for everyday, complex tasks', options: [claudeEffort(true), CLAUDE_FAST_MODE] }, { id: 'sonnet', - label: 'Sonnet 5', + label: 'Sonnet', + description: 'Efficient for routine tasks', isDefault: true, options: [claudeEffort(true)] }, { id: 'haiku', label: 'Haiku', + description: 'Fastest for quick answers', options: [] } ], @@ -100,6 +151,10 @@ export const CLAUDE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { // actual prompt so ordinary model changes stay in native chat. detectAgentInteraction: 'claude-model-switch-confirmation' } + }, + listModels: { + command: `echo '${CLAUDE_MODEL_LIST_STDIN.trim()}' | claude ${CLAUDE_MODEL_LIST_ARGS.join(' ')}`, + parse: parseClaudeCatalogModels } } diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index ffabb087e61..e7aae4aaaaa 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -36,6 +36,91 @@ describe('agent session option catalog', () => { }) }) + it('labels Claude seed models by alias family so no host is mislabeled', () => { + const catalog = getAgentSessionOptionCatalog('claude')! + expect(catalog.models.map(({ id, label }) => ({ id, label }))).toEqual([ + { id: 'fable', label: 'Fable' }, + { id: 'opus', label: 'Opus' }, + { id: 'sonnet', label: 'Sonnet' }, + { id: 'haiku', label: 'Haiku' } + ]) + expect(catalog.models.find((model) => model.isDefault)?.id).toBe('sonnet') + }) + + it('parses Claude list_models discovery into catalog models with options', () => { + const stdout = JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + response: { + models: [ + { + value: 'default', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }, + { + value: 'opus[1m]', + displayName: 'Opus (1M context)', + description: 'Opus 5 with 1M context', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }, + { + value: 'sonnet', + displayName: 'Sonnet', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high'] + }, + { value: 'haiku', displayName: 'Haiku' } + ] + } + } + }) + const parsed = getAgentSessionOptionCatalog('claude')!.listModels!.parse(stdout) + expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku']) + expect(parsed[0]).toMatchObject({ + label: 'Opus (1M context)', + description: 'Opus 5 with 1M context' + }) + expect(parsed[0].options.map(({ id }) => id)).toEqual(['effort', 'fastMode']) + const opusEffort = parsed[0].options[0] + expect(opusEffort.kind).toMatchObject({ defaultValue: 'high' }) + expect( + opusEffort.kind.type === 'select' ? opusEffort.kind.choices.map((c) => c.value) : [] + ).toEqual(['low', 'medium', 'high', 'xhigh', 'max']) + const sonnetEffort = parsed[1].options[0] + expect( + sonnetEffort.kind.type === 'select' ? sonnetEffort.kind.choices.map((c) => c.value) : [] + ).toEqual(['low', 'medium', 'high']) + expect(parsed[2].options).toEqual([]) + }) + + it('keeps the Claude seed when list_models output is unsupported or malformed', () => { + const parse = getAgentSessionOptionCatalog('claude')!.listModels!.parse + const unsupported = + '{"type":"control_response","response":{"subtype":"error","request_id":"x","error":"Unsupported control request subtype: list_models"}}' + expect(parse(unsupported)).toEqual([]) + expect(parse('')).toEqual([]) + expect(parse('garbage')).toEqual([]) + }) + + it('merges discovered Claude variants after the seed and overlays matched labels', () => { + const catalog = getAgentSessionOptionCatalog('claude')! + const merged = mergeCatalogModels(catalog.models, [ + { id: 'opus[1m]', label: 'Opus (1M context)', options: [] }, + { id: 'sonnet', label: 'Sonnet', description: 'Sonnet 5 · Efficient', options: [] } + ]) + expect(merged.map(({ id }) => id)).toEqual(['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]']) + const sonnet = merged.find((model) => model.id === 'sonnet')! + expect(sonnet.description).toBe('Sonnet 5 · Efficient') + expect(sonnet.isDefault).toBe(true) + expect(sonnet.options.map(({ id }) => id)).toEqual(['effort']) + }) + it('parses Cursor model discovery without treating headings as models', () => { const parsed = getAgentSessionOptionCatalog('cursor')!.listModels!.parse( 'Available models:\n- auto (default)\n- gpt-5.3-codex\nmodels\n' diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index 6ba6ee65043..76074aeb7b7 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -1,7 +1,8 @@ import type { AgentType } from './agent-status-types' import { CLAUDE_SESSION_OPTION_CATALOG, - CODEX_SESSION_OPTION_CATALOG + CODEX_SESSION_OPTION_CATALOG, + createClaudeCatalogOptions } from './agent-session-option-catalog-claude-codex' import { CURSOR_SESSION_OPTION_CATALOG, @@ -23,6 +24,7 @@ export type { CatalogOption, CatalogOptionApply } from './agent-session-option-catalog-types' +export { createClaudeCatalogOptions } const CATALOGS: AgentSessionOptionCatalogMap = { claude: CLAUDE_SESSION_OPTION_CATALOG, @@ -49,8 +51,7 @@ export function findCatalogOption( return model?.options.find((option) => option.id === optionId) } -/** Merge live rows over the static seed while retaining only option shapes Orca - * can actually map. Newly discovered ids remain model-only until cataloged. */ +/** Merge live rows over the static seed while retaining cataloged option mappings. */ export function mergeCatalogModels( seed: readonly CatalogModel[], discovered: readonly CatalogModel[] 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/shared/claude-model-list-probe.test.ts b/src/shared/claude-model-list-probe.test.ts new file mode 100644 index 00000000000..86305b45549 --- /dev/null +++ b/src/shared/claude-model-list-probe.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest' +import { parseClaudeModelList } from './claude-model-list-probe' + +function controlResponseLine(models: unknown[]): string { + return JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { models } + } + }) +} + +// Captured from `claude` 2.1.220 answering a list_models control request. +const LIVE_MODELS = [ + { + value: 'default', + resolvedModel: 'claude-opus-5[1m]', + displayName: 'Default (recommended)', + description: 'Use the default model (currently Opus 5 (1M context)) · $5/$25 per Mtok', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }, + { + value: 'opus[1m]', + resolvedModel: 'claude-opus-5[1m]', + displayName: 'Opus (1M context)', + description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }, + { + value: 'sonnet', + resolvedModel: 'claude-sonnet-5', + displayName: 'Sonnet', + description: 'Sonnet 5 · Efficient for routine tasks · $2/$10 per Mtok', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsAdaptiveThinking: true + }, + { + value: 'haiku', + resolvedModel: 'claude-haiku-4-5-20251001', + displayName: 'Haiku', + description: 'Haiku 4.5 · Fastest for quick answers · $1/$5 per Mtok' + } +] + +describe('parseClaudeModelList', () => { + it('parses the picker catalog and drops the mirror default row', () => { + const parsed = parseClaudeModelList(`${controlResponseLine(LIVE_MODELS)}\n`) + expect(parsed.map(({ id }) => id)).toEqual(['opus[1m]', 'sonnet', 'haiku']) + expect(parsed[0]).toEqual({ + id: 'opus[1m]', + label: 'Opus (1M context)', + description: 'Opus 5 with 1M context · Best for everyday, complex tasks · $5/$25 per Mtok', + effortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }) + expect(parsed[2]).toMatchObject({ effortLevels: [], supportsFastMode: false }) + }) + + it('skips init noise, CRLF endings, and duplicate values', () => { + const stdout = + '{"type":"system","subtype":"init","model":"claude-sonnet-5"}\r\n' + + 'not json at all\r\n' + + `${controlResponseLine([ + { value: 'sonnet', displayName: 'Sonnet' }, + { value: 'sonnet', displayName: 'Sonnet (duplicate)' }, + { value: ' ', displayName: 'Blank' } + ])}\r\n` + expect(parseClaudeModelList(stdout)).toEqual([ + { id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false } + ]) + }) + + it('skips non-object model entries instead of failing the discovery response', () => { + const stdout = controlResponseLine([null, 7, [], { value: 'sonnet', displayName: 'Sonnet' }]) + expect(parseClaudeModelList(stdout)).toEqual([ + { id: 'sonnet', label: 'Sonnet', effortLevels: [], supportsFastMode: false } + ]) + }) + + it('returns no models for the control error emitted by CLIs without list_models', () => { + // Captured from `claude` 2.1.100: unsupported subtype still exits 0. + const stdout = + '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n' + expect(parseClaudeModelList(stdout)).toEqual([]) + }) + + it('returns no models for empty, malformed, or structurally hostile output', () => { + expect(parseClaudeModelList('')).toEqual([]) + expect(parseClaudeModelList('{"type":"control_response"')).toEqual([]) + expect( + parseClaudeModelList( + `{"type":"control_response","response":{"subtype":"success","response":{"models":${'['.repeat(64)}${']'.repeat(64)}}}}` + ) + ).toEqual([]) + const hostile = `{"a":${'['.repeat(40)}${']'.repeat(40)},"type":"control_response"}` + expect(parseClaudeModelList(hostile)).toEqual([]) + }) + + it('ignores effort levels when the model does not declare effort support', () => { + const parsed = parseClaudeModelList( + controlResponseLine([ + { value: 'haiku', displayName: 'Haiku', supportedEffortLevels: ['low', 'high'] } + ]) + ) + expect(parsed[0]?.effortLevels).toEqual([]) + }) +}) diff --git a/src/shared/claude-model-list-probe.ts b/src/shared/claude-model-list-probe.ts new file mode 100644 index 00000000000..fa953d3da16 --- /dev/null +++ b/src/shared/claude-model-list-probe.ts @@ -0,0 +1,120 @@ +import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' + +// Why: the Claude CLI has no model-listing subcommand (`claude models` starts a +// chat session). One `list_models` control request over --print stream-json +// returns the CLI's /model picker catalog without starting an API turn. CLIs +// that predate the request answer `{"subtype":"error"}` and still exit 0, so +// parsing yields no models and callers keep their seed list. +export const CLAUDE_MODEL_LIST_STDIN = `${JSON.stringify({ + type: 'control_request', + request_id: 'orca-model-discovery', + request: { subtype: 'list_models' } +})}\n` + +// Why: --print rejects stream-json output unless --verbose is also set. +export const CLAUDE_MODEL_LIST_ARGS = [ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose' +] + +export type ClaudeListedModel = { + /** Value the CLI accepts for `--model` and `/model` (e.g. `opus[1m]`). */ + id: string + /** The CLI's own picker label (e.g. `Opus (1M context)`). */ + label: string + /** Names what the value resolves to on this host (e.g. `Opus 5 with 1M context …`). */ + description?: string + /** `--effort` values this model accepts; empty when it has no effort control. */ + effortLevels: string[] + supportsFastMode: boolean +} + +const CLAUDE_MODEL_LIST_JSON_LIMITS = { + structuralTokens: 64 * 1024, + nestingDepth: 16 +} as const + +type RawControlResponse = { + type?: unknown + response?: { + subtype?: unknown + response?: { models?: unknown } + } +} + +type RawListedModel = { + value?: unknown + displayName?: unknown + description?: unknown + supportsEffort?: unknown + supportedEffortLevels?: unknown + supportsFastMode?: unknown +} + +function toListedModel(value: unknown): ClaudeListedModel | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + const raw = value as RawListedModel + const id = typeof raw.value === 'string' ? raw.value.trim() : '' + if (!id) { + return null + } + const label = typeof raw.displayName === 'string' && raw.displayName.trim() ? raw.displayName : id + const description = + typeof raw.description === 'string' && raw.description.trim() ? raw.description : undefined + const effortLevels = + raw.supportsEffort === true && Array.isArray(raw.supportedEffortLevels) + ? raw.supportedEffortLevels.filter((level): level is string => typeof level === 'string') + : [] + return { + id, + label, + ...(description ? { description } : {}), + effortLevels, + supportsFastMode: raw.supportsFastMode === true + } +} + +export function parseClaudeModelList(stdout: string): ClaudeListedModel[] { + for (const rawLine of stdout.split(/\r?\n/)) { + const line = rawLine.trim() + if (!line.startsWith('{') || !line.includes('control_response')) { + continue + } + let parsed: RawControlResponse + try { + assertJsonTextStructureWithinLimits(line, CLAUDE_MODEL_LIST_JSON_LIMITS) + parsed = JSON.parse(line) as RawControlResponse + } catch { + continue + } + if (parsed.type !== 'control_response' || parsed.response?.subtype !== 'success') { + continue + } + const models = parsed.response.response?.models + if (!Array.isArray(models)) { + continue + } + const seen = new Set() + const listed: ClaudeListedModel[] = [] + for (const entry of models) { + const model = toListedModel(entry) + // Why: the `default` row mirrors whichever entry it currently resolves + // to; Orca's pickers manage their own default selection. + if (!model || model.id === 'default' || seen.has(model.id)) { + continue + } + seen.add(model.id) + listed.push(model) + } + if (listed.length > 0) { + return listed + } + } + return [] +} diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index a86c89a174b..77a8005056e 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -29,7 +29,7 @@ describe('createCommandCodeOutputStatusDetector', () => { }) expect(detector.observe('Thinking about unrelated shell output')).toBe(false) - expect(detector.observe('# Command Code v0.27.2')).toBe(false) + expect(detector.observe('# Command Code v0.27.2\r\n')).toBe(false) expect(detector.observe('⌘ Parsing...')).toBe(true) expect(onWorking).toHaveBeenCalledWith('') @@ -43,7 +43,7 @@ describe('createCommandCodeOutputStatusDetector', () => { }) expect(detector.observe('# Command')).toBe(false) - expect(detector.observe(' Code v0.27.2')).toBe(false) + expect(detector.observe(' Code v0.27.2\r\n')).toBe(false) expect(detector.observe('⌘ Parsing...')).toBe(true) expect(onWorking).toHaveBeenCalledWith('') @@ -56,7 +56,7 @@ describe('createCommandCodeOutputStatusDetector', () => { onWorking }) - expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2')).toBe(false) + expect(detector.observe('# C\x1b[35mommand Co\x1b[0mde v0.27.2\r\n')).toBe(false) expect(detector.observe('⌘ Parsing...')).toBe(true) expect(onWorking).toHaveBeenCalledWith('') @@ -74,6 +74,59 @@ describe('createCommandCodeOutputStatusDetector', () => { expect(onWorking).not.toHaveBeenCalled() }) + it('does not arm when another agent merely discusses Command Code', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking + }) + + expect(detector.observe('// Why: Command Code exposes transcript prompts')).toBe(false) + expect(detector.observe('\r\n✻ Thinking...')).toBe(false) + + expect(onWorking).not.toHaveBeenCalled() + }) + + it.each([ + '# Command Code v1.2\r\n', + '# Command Code v1.2.3.4\r\n', + '# Command Code v01.2.3\r\n', + '# Command Code v1.2.3suffix\r\n', + '#\nCommand Code v1.2.3\r\n' + ])('does not arm for noncanonical banner %j', (banner) => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking }) + + expect(detector.observe(banner)).toBe(false) + expect(detector.observe('✻ Thinking...')).toBe(false) + + expect(onWorking).not.toHaveBeenCalled() + }) + + it('does not arm from a four-part version split at a PTY chunk boundary', () => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking }) + + expect(detector.observe('# Command Code v1.2.3')).toBe(false) + expect(detector.observe('.4\r\n')).toBe(false) + expect(detector.observe('✻ Thinking...')).toBe(false) + + expect(onWorking).not.toHaveBeenCalled() + }) + + it.each(['# Command Code v1.2.3\r\n', '# Command Code v1.2.3-beta.1+build.9\n'])( + 'arms for canonical banner %j', + (banner) => { + const onWorking = vi.fn() + const detector = createCommandCodeOutputStatusDetector({ startupCommand: null, onWorking }) + + expect(detector.observe(banner)).toBe(false) + expect(detector.observe('✻ Thinking...')).toBe(true) + + expect(onWorking).toHaveBeenCalledWith('') + } + ) + it.each([ 'Pondering', 'Contemplating', diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 41e4c9eefc1..af862b5abc7 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -112,7 +112,16 @@ const ACTIVE_EXECUTION_STATUS_RE = new RegExp( `(?:^|[\\r\\n])\\s*(?:${COMMAND_CODE_STATUS_GLYPH_RE_SOURCE}\\s*)?(?:Executing:\\s+\\S|Running\\s*\\()` ) const IDLE_PROMPT_RE = /(?:^|[\r\n])\s*[❯>]\s+Ask your question\.\.\./ -const COMMAND_CODE_BANNER_RE = /\bCommand Code\b/ +const SEMVER_NUMBER_RE_SOURCE = '(?:0|[1-9]\\d*)' +const SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)' +const SEMVER_BUILD_IDENTIFIER_RE_SOURCE = '[0-9A-Za-z-]+' +const COMMAND_CODE_BANNER_RE = new RegExp( + `(?:^|[\\r\\n])[ \\t]*#[ \\t]+Command Code[ \\t]+v` + + `${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}\\.${SEMVER_NUMBER_RE_SOURCE}` + + `(?:-${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_PRERELEASE_IDENTIFIER_RE_SOURCE})*)?` + + `(?:\\+${SEMVER_BUILD_IDENTIFIER_RE_SOURCE}(?:\\.${SEMVER_BUILD_IDENTIFIER_RE_SOURCE})*)?` + + `(?=[ \\t]*[\\r\\n])` +) function cleanPromptCandidate(value: string): string { return cleanCommandCodePromptCandidate(stripTerminalControl(value)) @@ -233,17 +242,19 @@ export function createCommandCodeOutputStatusDetector(args: { : scanRawText if (!hasSeenCommandCodeUi) { - if ( - !rawTextMayContainCommandCodeBanner(scanRawText) && - !rawTextMayContainCommandCodeBanner(scanRawTextWithChunkBoundary) - ) { + if (!rawTextMayContainCommandCodeBanner(scanRawText)) { return false } const scanText = stripTerminalControl(scanRawText) const scanTextWithChunkBoundary = stripTerminalControl(scanRawTextWithChunkBoundary) + const previousTextWithChunkBoundaryLength = previousRawText + ? stripTerminalControl(`${previousRawText}\n`).length + : 0 if ( !COMMAND_CODE_BANNER_RE.test(scanText) && - !COMMAND_CODE_BANNER_RE.test(scanTextWithChunkBoundary) + !COMMAND_CODE_BANNER_RE.test( + scanTextWithChunkBoundary.slice(previousTextWithChunkBoundaryLength) + ) ) { return false } diff --git a/src/shared/commit-message-agent-spec.test.ts b/src/shared/commit-message-agent-spec.test.ts index 3d8eac29c81..af2d0ea43ee 100644 --- a/src/shared/commit-message-agent-spec.test.ts +++ b/src/shared/commit-message-agent-spec.test.ts @@ -12,6 +12,7 @@ import { listCommitMessageAgentCapabilities, listCommitMessageAgentIds, parseAntigravityModels, + parseClaudeModels, parseCodexModels, parseCursorModels, parseLineModels, @@ -194,6 +195,81 @@ describe('buildArgs (Claude)', () => { }) describe('model discovery parsers', () => { + it('parses Claude list_models output into commit-message models', () => { + const stdout = `${JSON.stringify({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'orca-model-discovery', + response: { + models: [ + { + value: 'default', + displayName: 'Default (recommended)', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'] + }, + { + value: 'opus[1m]', + displayName: 'Opus (1M context)', + description: 'Opus 5 with 1M context · $5/$25 per Mtok', + supportsEffort: true, + supportedEffortLevels: ['low', 'medium', 'high', 'xhigh', 'max'], + supportsFastMode: true + }, + { value: 'haiku', displayName: 'Haiku' } + ] + } + } + })}\n` + expect(parseClaudeModels(stdout)).toEqual([ + { + id: 'opus[1m]', + label: 'Opus (1M context)', + description: 'Opus 5 with 1M context · $5/$25 per Mtok', + thinkingLevels: [ + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + { id: 'xhigh', label: 'Extra High' }, + { id: 'max', label: 'Max' } + ], + defaultThinkingLevel: 'low', + supportsFastMode: true + }, + { id: 'haiku', label: 'Haiku' } + ]) + }) + + it('returns no Claude models when the CLI lacks list_models so the seed stays', () => { + expect( + parseClaudeModels( + '{"type":"control_response","response":{"subtype":"error","request_id":"orca-model-discovery","error":"Unsupported control request subtype: list_models"}}\n' + ) + ).toEqual([]) + }) + + it('declares stdin-driven dynamic discovery for Claude', () => { + const discovery = COMMIT_MESSAGE_AGENT_SPECS.claude?.modelDiscovery + expect(COMMIT_MESSAGE_AGENT_SPECS.claude?.modelSource).toBe('dynamic') + expect(discovery?.binary).toBe('claude') + expect(discovery?.args).toEqual([ + '-p', + '--input-format', + 'stream-json', + '--output-format', + 'stream-json', + '--verbose' + ]) + const payload = JSON.parse(discovery?.stdinPayload ?? '') as { + type?: string + request?: { subtype?: string } + } + expect(payload.type).toBe('control_request') + expect(payload.request?.subtype).toBe('list_models') + expect(discovery?.stdinPayload?.endsWith('\n')).toBe(true) + }) + it('parses Codex model JSON', () => { expect( parseCodexModels( diff --git a/src/shared/commit-message-agent-spec.ts b/src/shared/commit-message-agent-spec.ts index 98ed09d681e..a6b91755b74 100644 --- a/src/shared/commit-message-agent-spec.ts +++ b/src/shared/commit-message-agent-spec.ts @@ -1,6 +1,11 @@ import type { TuiAgent } from './types' import { isTuiAgentEnabled } from './tui-agent-selection' import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit' +import { + CLAUDE_MODEL_LIST_ARGS, + CLAUDE_MODEL_LIST_STDIN, + parseClaudeModelList +} from './claude-model-list-probe' /* eslint-disable max-lines -- Why: this is the single registry for non-interactive commit-message agents, their model discovery parsers, and UI capabilities. */ @@ -16,10 +21,14 @@ export type CommitMessageModel = { id: string /** Visible label in the model dropdown. */ label: string + /** Discovery-provided detail, e.g. what a CLI alias resolves to on this host. */ + description?: string /** Omit when the model does not expose an effort selector — the UI then hides the dropdown. */ thinkingLevels?: ThinkingLevel[] /** Required when thinkingLevels is present. */ defaultThinkingLevel?: string + /** Whether the model exposes Claude's mid-session Fast mode toggle. */ + supportsFastMode?: boolean } export type CommitMessageAgentSpec = { @@ -37,6 +46,8 @@ export type CommitMessageAgentSpec = { modelDiscovery?: { binary: string args: string[] + /** Written to the CLI's stdin, for CLIs whose listing is request-driven. */ + stdinPayload?: string parse: (stdout: string) => CommitMessageModel[] } models: CommitMessageModel[] @@ -46,8 +57,10 @@ export type CommitMessageAgentSpec = { export type CommitMessageModelCapability = { id: string label: string + description?: string thinkingLevels?: ThinkingLevel[] defaultThinkingLevel?: string + supportsFastMode?: boolean } export type CommitMessageAgentCapability = { @@ -139,6 +152,30 @@ function withOpenAiThinking( : {} } +export function parseClaudeModels(stdout: string): CommitMessageModel[] { + return uniqueModels( + parseClaudeModelList(stdout).map((model) => { + const thinkingLevels = CLAUDE_THINKING_LEVELS.filter((level) => + model.effortLevels.includes(level.id) + ) + return { + id: model.id, + label: model.label, + ...(model.description ? { description: model.description } : {}), + ...(thinkingLevels.length > 0 + ? { + thinkingLevels, + defaultThinkingLevel: thinkingLevels.some((level) => level.id === 'low') + ? 'low' + : thinkingLevels[0].id + } + : {}), + ...(model.supportsFastMode ? { supportsFastMode: true } : {}) + } + }) + ) +} + export function parseCodexModels(stdout: string): CommitMessageModel[] { try { assertJsonTextStructureWithinLimits(stdout, COMMIT_MESSAGE_MODEL_JSON_STRUCTURE_LIMITS) @@ -311,7 +348,16 @@ export const COMMIT_MESSAGE_AGENT_SPECS: Partial ({ id: model.id, label: model.label, + ...(model.description ? { description: model.description } : {}), ...(model.thinkingLevels ? { thinkingLevels: [...model.thinkingLevels] } : {}), - ...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}) + ...(model.defaultThinkingLevel ? { defaultThinkingLevel: model.defaultThinkingLevel } : {}), + ...(model.supportsFastMode ? { supportsFastMode: true } : {}) })) } } diff --git a/src/shared/commit-message-host-key.ts b/src/shared/commit-message-host-key.ts index 6edf51a9bc3..9a93dec3992 100644 --- a/src/shared/commit-message-host-key.ts +++ b/src/shared/commit-message-host-key.ts @@ -2,6 +2,13 @@ export const LOCAL_COMMIT_MESSAGE_HOST_KEY = 'local' export const UNKNOWN_COMMIT_MESSAGE_HOST_KEY = 'unknown' export const RUNTIME_COMMIT_MESSAGE_HOST_KEY_PREFIX = 'runtime:' +export function getCommitMessageModelDiscoveryHostKeyForLocalRuntime( + wslDistro: string | null | undefined +): string { + const distro = wslDistro?.trim() + return distro ? `wsl:${distro}` : LOCAL_COMMIT_MESSAGE_HOST_KEY +} + export function getCommitMessageModelDiscoveryHostKey( connectionId: string | null | undefined ): string { 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/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 906a9b78811..660a2b7aaad 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rename, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { promisify } from 'node:util' @@ -137,6 +137,19 @@ describeBinaryCompatibility('real Git binary compatibility', () => { ).resolves.toBeDefined() }) + it('deregisters a worktree whose directory was renamed away', async () => { + // Orca renames the checkout into a trash directory and then clears the registration, so every + // supported Git must accept `worktree remove --force` on the now-missing path. + await runGit(['worktree', 'add', '-b', 'compat-deferred', 'deferred-wt']) + await rename(join(repoPath, 'deferred-wt'), join(repoPath, 'deferred-trash')) + + await expect(runGit(['worktree', 'remove', '--force', 'deferred-wt'])).resolves.toBeDefined() + + const remaining = await runGit(['worktree', 'list', '--porcelain']) + expect(remaining.stdout).not.toContain('deferred-wt') + await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true }) + }) + it('recognizes ref and merge-tree compatibility boundaries', async () => { await expectPreferredOrRecognizedFallback( ['for-each-ref', '--format=%(refname)', '--exclude=refs/remotes/**/HEAD', '--count=10'], 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-href-routing.test.ts b/src/shared/native-chat-href-routing.test.ts new file mode 100644 index 00000000000..1b7c276ffd3 --- /dev/null +++ b/src/shared/native-chat-href-routing.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { routeNativeChatHref } from './native-chat-href-routing' + +describe('routeNativeChatHref', () => { + it('classifies web and mail links', () => { + expect(routeNativeChatHref('https://example.com/docs')).toEqual({ + kind: 'web', + url: 'https://example.com/docs' + }) + expect(routeNativeChatHref(' mailto:dev@example.com ')).toEqual({ + kind: 'web', + url: 'mailto:dev@example.com' + }) + }) + + it('parses relative file hrefs and line fragments', () => { + expect(routeNativeChatHref('docs/plan.md?plain=1#line-7')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md', + line: 7 + }) + expect(routeNativeChatHref('docs/plan.md#usage')).toEqual({ + kind: 'file', + pathText: 'docs/plan.md', + line: null + }) + }) + + it('decodes relative and file URI paths', () => { + expect(routeNativeChatHref('docs/release%20notes.md')).toEqual({ + kind: 'file', + pathText: 'docs/release notes.md', + line: null + }) + expect(routeNativeChatHref('file:///Users/me/wt/My%20File.tsx#L12')).toEqual({ + kind: 'file', + pathText: '/Users/me/wt/My File.tsx', + line: 12 + }) + }) + + it('keeps Windows drive paths out of the scheme filter', () => { + expect(routeNativeChatHref(String.raw`C:\repo\src\index.ts`)).toEqual({ + kind: 'file', + pathText: String.raw`C:\repo\src\index.ts`, + line: null + }) + }) + + it('drops anchors, unknown schemes, malformed file URIs, and empty hrefs', () => { + expect(routeNativeChatHref('#section')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref(undefined)).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('editor://file/x.ts')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('javascript:alert(1)')).toEqual({ kind: 'none' }) + expect(routeNativeChatHref('file:///tmp/%E0%A4%A.txt')).toEqual({ kind: 'none' }) + }) +}) diff --git a/src/shared/native-chat-href-routing.ts b/src/shared/native-chat-href-routing.ts new file mode 100644 index 00000000000..005ab957f65 --- /dev/null +++ b/src/shared/native-chat-href-routing.ts @@ -0,0 +1,74 @@ +import { isWindowsAbsolutePathLike } from './cross-platform-path' +import { fileUriToFilesystemPath } from './file-uri-path' + +export type NativeChatHrefRoute = + | { kind: 'web'; url: string } + | { kind: 'file'; pathText: string; line: number | null } + | { kind: 'none' } + +const WEB_SCHEME_PATTERN = /^(?:https?|mailto):/i +const SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/ + +function parseLineFragment(hash: string): number | null { + if (!hash) { + return null + } + let decoded = hash + try { + decoded = decodeURIComponent(hash) + } catch { + // Keep the raw fragment when decoding fails. + } + const match = /^(?:L|line-?)([1-9]\d*)\b/i.exec(decoded) + return match ? Number.parseInt(match[1]!, 10) : null +} + +function stripQueryAndHash(value: string): { pathText: string; line: number | null } { + const hashIndex = value.indexOf('#') + const queryIndex = value.indexOf('?') + const suffixIndex = + hashIndex === -1 ? queryIndex : queryIndex === -1 ? hashIndex : Math.min(hashIndex, queryIndex) + const pathText = suffixIndex === -1 ? value : value.slice(0, suffixIndex) + const hash = + hashIndex === -1 + ? '' + : value.slice(hashIndex + 1, queryIndex > hashIndex ? queryIndex : undefined) + return { pathText, line: parseLineFragment(hash) } +} + +function maybeDecodeHrefPath(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +export function routeNativeChatHref(href: string | null | undefined): NativeChatHrefRoute { + const trimmed = href?.trim() + if (!trimmed || trimmed.startsWith('#')) { + return { kind: 'none' } + } + if (WEB_SCHEME_PATTERN.test(trimmed)) { + return { kind: 'web', url: trimmed } + } + if (/^file:/i.test(trimmed)) { + let url: URL + try { + url = new URL(trimmed) + } catch { + return { kind: 'none' } + } + const pathText = fileUriToFilesystemPath(url) + if (!pathText) { + return { kind: 'none' } + } + return { kind: 'file', pathText, line: parseLineFragment(url.hash.slice(1)) } + } + if (!isWindowsAbsolutePathLike(trimmed) && SCHEME_PATTERN.test(trimmed)) { + return { kind: 'none' } + } + const { pathText, line } = stripQueryAndHash(trimmed) + const decodedPathText = maybeDecodeHrefPath(pathText) + return decodedPathText ? { kind: 'file', pathText: decodedPathText, line } : { kind: 'none' } +} 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/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) +})