diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 1840514e5fd..6dba5ac1ff6 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1015,6 +1015,27 @@ jobs: with: ref: refs/tags/${{ needs.cut.outputs.tag }} + # Why: `uses: ./…` resolves from the checked-out tag, not from the workflow + # ref, so cutting from an older/off-main ref whose tree predates a composite + # action would fail the step with "Can't find 'action.yml'". Restore the + # actions directory from the commit this workflow file itself came from. + - name: Restore composite actions from the workflow ref + if: matrix.platform == 'win' + shell: bash + env: + WORKFLOW_SHA: ${{ github.workflow_sha }} + run: | + set -euo pipefail + action_path=".github/actions/install-signpath-module/action.yml" + if [ -f "$action_path" ]; then + echo "Composite actions already present at the cut ref." + exit 0 + fi + echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA." + git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA" + git checkout "$WORKFLOW_SHA" -- .github/actions + test -f "$action_path" + # pnpm must be on PATH before setup-node so setup-node can locate the store for caching. - name: Setup pnpm uses: pnpm/action-setup@v6 diff --git a/build-plugins/bootstrap-fatal-exit-banner.ts b/build-plugins/bootstrap-fatal-exit-banner.ts new file mode 100644 index 00000000000..de4da1319e5 --- /dev/null +++ b/build-plugins/bootstrap-fatal-exit-banner.ts @@ -0,0 +1,107 @@ +import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard' +export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG' +export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log' +const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144 + +export function createBootstrapFatalExitBanner(): string { + // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. + // Suppressing that dialog also removes the only account of the failure, so record one first: + // a partially copied resources tree otherwise exits silently on every launch, with nothing + // for the user or a support log to name the module that was missing. + return ` +;(() => { + const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} + if (typeof globalThis[guardKey] === 'function') { + return + } + const describeBootstrapError = (error) => { + try { + const detail = error && typeof error === 'object' && error.stack ? error.stack : error + return String(detail).split('\\n').slice(0, 12).join(' | ').slice(0, 4000) + } catch { + return '' + } + } + const readBootstrapFatalLogOverride = () => { + const override = typeof process.env === 'object' && process.env ? process.env.${BOOTSTRAP_FATAL_LOG_ENV_VAR} : undefined + return typeof override === 'string' && override.length > 0 ? override : undefined + } + const resolveDefaultBootstrapFatalLogPath = () => { + let directory + try { + directory = require('electron').app.getPath('userData') + } catch { + // Why: a bootstrap fault can predate a usable app object; temp still outlives the process. + directory = require('node:os').tmpdir() + } + return require('node:path').join(directory, ${JSON.stringify(BOOTSTRAP_FATAL_LOG_FILE_NAME)}) + } + const appendBootstrapFatalLine = (fs, logPath, entry) => { + try { + // Why: an override can name a directory nothing has created yet, and a missing + // parent would drop the only account of the failure. + fs.mkdirSync(require('node:path').dirname(logPath), { recursive: true }) + // Why: a broken install repeats this on every relaunch; keep the trail bounded. + let flags = 'a' + try { + flags = fs.statSync(logPath).size > ${BOOTSTRAP_FATAL_LOG_MAX_BYTES} ? 'w' : 'a' + } catch { + flags = 'a' + } + const descriptor = fs.openSync(logPath, flags, 0o600) + try { + fs.writeSync(descriptor, entry) + } finally { + fs.closeSync(descriptor) + } + return true + } catch { + return false + } + } + const recordBootstrapFailure = (error) => { + const line = '[bootstrap] fatal-exit pid=' + process.pid + ' error=' + describeBootstrapError(error) + '\\n' + let fs + try { + fs = require('node:fs') + } catch { + fs = undefined + } + try { + fs.writeSync(2, line) + } catch { + try { + process.stderr.write(line) + } catch { + // Diagnostics must never replace the exit below. + } + } + try { + const entry = new Date().toISOString() + ' ' + line + const override = readBootstrapFatalLogOverride() + // Why: an unwritable override must cost the user a location, not the diagnostic. + if (!override || !appendBootstrapFatalLine(fs, override, entry)) { + appendBootstrapFatalLine(fs, resolveDefaultBootstrapFatalLogPath(), entry) + } + } catch { + // Diagnostics must never replace the exit below. + } + } + let exitScheduled = false + const exitAfterBootstrapFailure = (error) => { + if (exitScheduled) { + return + } + exitScheduled = true + recordBootstrapFailure(error) + process.exitCode = 1 + setImmediate(() => process.exit(1)) + } + globalThis[guardKey] = () => { + process.off('uncaughtException', exitAfterBootstrapFailure) + delete globalThis[guardKey] + } + process.once('uncaughtException', exitAfterBootstrapFailure) +})(); +` +} diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index a243d500d0b..0dd16bca193 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-29", + "updatedAt": "2026-07-30", "policy": { "maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"], "blockingPromotion": { @@ -2224,6 +2224,7 @@ "Advanced Active Server setting", "saved server Connect and Disconnect", "remote workspace navigation", + "Add Project host selection, scan, import, and catalog refresh", "terminal reveal and create", "browser and mobile handoff", "app restart" @@ -2232,12 +2233,16 @@ "providers": ["local", "remote-runtime", "ssh", "wsl"], "coveredPlatforms": ["macos"], "coveredProviders": ["local", "remote-runtime", "ssh", "wsl"], - "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.", + "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, Add Project, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, Add Project owner capture and stale-result fencing, host-qualified group/folder/worktree identity, and restart reset of transient routing are covered. A visible macOS Orca desktop server plus a separate paired Electron client proves real Git, plain-folder, Clone, Create, nested import, runtime-switch, disconnect, reconnect, concurrently visible same-ID local/runtime repo/worktree/group/folder rows, reversed catalog order, reversed refresh completion, local-after-runtime catalog refresh routing, and first paired-terminal activation without local fallback; hidden-window parity also passed. Live Linux, Windows, WSL, and SSH Add Project journeys remain uncollected.", "motivatingLinks": ["https://github.com/stablyai/orca/pull/9687"], - "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.", - "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.", + "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Every Add Project scan, add, import, catalog refresh, and worktree refresh must stay on the host selected when that operation began, even if the durable preference changes or the repo catalog is not hydrated. Generic settings mutation cannot bypass the dedicated preference IPC.", + "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. In a separate paired Electron client, select a non-default headed runtime and add a real Git repo, plain folder, clone, created project, and nested repos while switching the preference and disconnecting/reconnecting. Before Git Add, plain-folder Add, Clone, and Create completion, insert a same-ID local repo/worktree decoy; require final activation to remain on the captured runtime. Merge same-ID local/runtime repo, worktree, group, and folder rows, reverse same-host refresh completions, overlap refresh with reconnect, issue ordinary unqualified local refreshes, then create the first paired runtime terminal while the same-ID local/runtime worktrees remain visible. Require only the selected server inventory to change, exact runtime ownership in the client store, preserved host-qualified catalogs, authoritative direct-SSH pruning without runtime sibling loss, visible projects, and zero direct SSH fallback. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", + "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line" ], "testFiles": [ "src/main/ipc/settings.test.ts", @@ -2249,7 +2254,28 @@ "src/renderer/src/components/status-bar/SshStatusSegment.test.ts", "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts", "src/renderer/src/hooks/useIpcEvents.test.ts", - "src/renderer/src/web/web-preload-api.test.ts" + "src/renderer/src/web/web-preload-api.test.ts", + "src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx", + "src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx", + "src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts", + "src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts", + "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts", + "src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts", + "src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts", + "src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts", + "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts", + "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts", + "src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts", + "src/renderer/src/lib/resolved-worktree-execution-host.test.ts", + "src/renderer/src/lib/worktree-runtime-owner.test.ts", + "src/renderer/src/store/selectors.test.ts", + "src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts", + "src/renderer/src/store/slices/repos-project-groups.test.ts", + "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", + "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts", + "src/renderer/src/store/slices/worktrees.test.ts", + "src/renderer/src/runtime/web-runtime-session.test.ts", + "tests/e2e/pr11346-selected-runtime-add.spec.ts" ], "assertionRefs": [ { @@ -2292,6 +2318,73 @@ "generic web settings writes cannot mutate Active Server", "the dedicated web preference setter rejects unknown server identities without corrupting the saved choice" ] + }, + { + "file": "src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts", + "assertions": [ + "stamps a Clone response with the captured runtime or SSH owner before store upsert and worktree refresh", + "preserves same-ID repository siblings that belong to different execution hosts" + ] + }, + { + "file": "src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts", + "assertions": [ + "stamps Create responses with the captured runtime or SSH owner before store upsert and Git or folder worktree refresh" + ] + }, + { + "file": "src/renderer/src/store/slices/repos-selected-owner-routing.test.ts", + "assertions": [ + "keeps same-ID local, direct-SSH, and runtime group/folder rows partitioned by execution host", + "drops reversed same-host and pre-reconnect group/folder responses without pruning newer catalogs", + "prunes deleted direct-SSH rows from the desktop-owned catalog without erasing same-ID runtime siblings", + "keeps explicit runtime groups and folders after a later ordinary local refresh", + "keeps a selected-runtime import refresh across an overlapping local refresh", + "pins selected SSH scans and cancellation to local IPC over an ambient runtime" + ] + }, + { + "file": "src/renderer/src/components/sidebar/project-added-default-checkout.test.ts", + "assertions": [ + "filters loaded, detected, refreshed, and activated default checkouts by the captured execution host when repo IDs collide" + ] + }, + { + "file": "src/renderer/src/store/slices/worktrees.test.ts", + "assertions": [ + "honors an explicit runtime owner before the repo catalog is hydrated", + "honors an explicit SSH owner before the repo catalog is hydrated", + "rejects a missing-owner SSH result after the repo catalog changes", + "rejects a missing-owner SSH result after the provider reconnects" + ] + }, + { + "file": "src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts", + "assertions": [ + "selects the runtime worktree when exact repo/worktree IDs collide in local-first or reversed order", + "keeps exact-ID folder/group activation on the explicitly selected runtime without local fallback" + ] + }, + { + "file": "src/renderer/src/store/selectors.test.ts", + "assertions": [ + "does not fall back to a same-ID local repo when the selected runtime row is unavailable" + ] + }, + { + "file": "src/renderer/src/runtime/web-runtime-session.test.ts", + "assertions": [ + "terminal, browser, and staged-browser selection preserve the explicit runtime execution host when local and runtime worktree IDs collide" + ] + }, + { + "file": "tests/e2e/pr11346-selected-runtime-add.spec.ts", + "assertions": [ + "routes Git Add, plain-folder Add, Clone, Create, and nested import to a selected non-default headed runtime while exact-ID local siblings remain visible and cannot capture final activation @headful", + "preserves same-ID runtime group/folder/worktree catalogs across reversed order, local refresh, switch, disconnect, and reconnect-overlap completion", + "keeps every expanded selected-runtime Add Project path in hidden-window desktop parity", + "creates the first paired runtime terminal while same-ID local/runtime worktrees remain visible without clearing the selected runtime owner" + ] } ], "evidenceRuns": [ @@ -2303,34 +2396,91 @@ "result": "passed", "durationSeconds": 6.55, "summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 8.77, + "summary": "Nineteen files and 386 tests passed after rebasing onto 94cf2f1422f30fc309cb47c5e864a831d516fa8b, including exact-ID repo/worktree/group/folder host identity in both catalog orders, fail-closed active-repo selection when a runtime row is unavailable, selected runtime and SSH owner capture through final Add/Clone/Create/folder activation, direct-SSH authoritative pruning, reversed catalog responses, reconnect generation fencing, local-after-runtime isolation, and missing-catalog worktree routing." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 23, + "summary": "A visible isolated Orca desktop server and separate paired Electron client added one real Git repo, one plain folder, one clone, one created project, and two nested repos to the selected non-default runtime across preference switches and disconnect/reconnect. Exact-ID local/runtime repo, worktree, group, and folder rows remained concurrently visible in local-first and reversed order without capturing final activation. Server inventory, active host identity, rendered rows, local-client exclusion, preserved post-reconnect catalogs, and zero direct SSH fallback agreed. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 307/1667/2932/1247/1321/259/1319 ms." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 66, + "summary": "The hidden-window desktop-server parity journey passed with exact-ID Add/Clone/Create activation, selected-runtime inventory, reversed catalog order, reconnect overlap, ownership, local exclusion, and host-qualified catalog-preservation assertions. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 59/1770/4330/1375/2137/279/1945 ms." + }, + { + "date": "2026-07-31", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/sidebar/AddProjectFromFolderDialog.test.tsx src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx src/renderer/src/components/sidebar/AddRepoDialog.default-checkout.test.ts src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts src/renderer/src/components/sidebar/project-added-default-checkout.test.ts src/renderer/src/components/sidebar/useAddRepoLocalFolderFlow.test.ts src/renderer/src/components/sidebar/useAddRepoNestedImportFlow.test.ts src/renderer/src/components/sidebar/useAddRepoServerPathFlow.test.ts src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts src/renderer/src/lib/resolved-worktree-execution-host.test.ts src/renderer/src/lib/worktree-runtime-owner.test.ts src/renderer/src/store/selectors.test.ts src/renderer/src/store/slices/repos-all-hosts-folder-workspaces.test.ts src/renderer/src/store/slices/repos-project-groups.test.ts src/renderer/src/store/slices/repos-selected-owner-routing.test.ts src/renderer/src/store/slices/selected-host-active-workspace-identity.test.ts src/renderer/src/store/slices/worktrees.test.ts src/renderer/src/runtime/web-runtime-session.test.ts --maxWorkers=1", + "result": "passed", + "durationSeconds": 7.1, + "summary": "Twenty files and 430 tests passed on current main integration, including explicit runtime-owner preservation for terminal and browser session activation." + }, + { + "date": "2026-07-31", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 19.9, + "summary": "The visible headed server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 43/1513/3841/1200/790/202/1312 ms." + }, + { + "date": "2026-07-31", + "runner": "local", + "platform": "macos", + "command": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/pr11346-selected-runtime-add.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --reporter=line", + "result": "passed", + "durationSeconds": 19.7, + "summary": "The hidden-window server and separate paired client passed the strengthened same-ID terminal-activation oracle with a disposable runtime Git identity. Runtime switch/Git/folder/Clone/Create/reconnect/nested-import measurements were 51/1502/3843/880/939/200/1359 ms." } ], "runtimeBudget": { - "p95Seconds": 20, - "scope": "focused persistence and routing contract tests" + "p95Seconds": 180, + "scope": "focused persistence/routing contracts plus isolated paired Electron journeys" }, "flakeHistory": { "status": "unknown", - "evidence": "New deterministic gate with no soak history." + "evidence": "Deterministic contracts and the final visible plus hidden-window paired Electron runs passed locally. During expansion, attempts exposed three harness-only assumptions: exact project labels did not allow path disambiguation, clicking the inner host label raced cmdk layout, and a disposable CI runtime had no Git author identity for Create. The oracle now targets the host command item, accepts the rendered disambiguated label, seeds only its isolated runtime home with a test Git identity, and explicitly creates a first paired terminal while same-ID local/runtime worktrees remain present. CI soak history is not yet available." }, "redGreenEvidence": { - "status": "partial", - "evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached." + "status": "complete", + "evidence": "The byte-identical focused oracle (SHA-256 6de18c140a86801460e58f52287e27a58b97f1576f9f387fa82e1bd4eb378f7b) fails 2/3 on review baseline aa6f945001a5f78a07663c854fabb95c55d8b40b and on the r5-disabled parent e21f4ca58e: local-first lookup returns /local/repo and folder activation has no runtime host. It passes 3/3 on the candidate. Main later advanced to 94cf2f1422f30fc309cb47c5e864a831d516fa8b without changing any of the candidate's 68 files or the focused oracle production boundary; the rebased disabled parent f247d961cab15237bc7fcf0598fe76007f78ec7c has the same stable patch ID as e21f4ca58e. The final paired spec and fixture have SHA-256 bd6d9014d1bed2715397bcd2121d5d7e01792347b13ff14778ccf608bf98789c and d1d78a82f868e2f3152777550825c0ca2d4dc3cba5c6de548e066d2931751fc7. With the final runtime-session owner fix disabled, the focused session test fails 3/43 and the byte-identical hidden paired oracle deterministically creates the host terminal but reports null active client owner while same-ID local/runtime worktrees remain. The integrated candidate passes the same focused test and both headed/hidden paired oracles with exact runtime ownership, server inventory, local exclusion, preserved catalogs, visible rows, and zero direct SSH fallback. Earlier Active Server preference paths retain deterministic coverage but do not yet have a saved intentional-break artifact." }, "performanceBudget": { - "required": false, - "evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes." + "required": true, + "evidence": "Preference writes are explicit user actions; transient routing adds no polling or provider fanout and retains one authoritative worktree refresh per completed add. Host-qualified owner indexes are WeakMap-cached and do not add subprocesses or network calls. The final headed journey measured 43 ms for three preference switches, 1,513 ms for Git add, 3,841 ms for folder add, 1,200 ms for Clone, 790 ms for Create with controlled exact-ID completion gates, 202 ms for disconnect/reconnect with overlapping catalog refreshes, and 1,312 ms for nested import. The final session-activation fix adds one execution-host string conversion per explicit terminal/browser activation and no provider call, scan, timer, or retry. Existing Zustand selector fan-out evidence remained 0 render invalidations across 5,000,000 selector runs." }, "promotionCriteria": [ "Run the focused gate in soak across macOS, Linux, and Windows.", "Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.", - "Attach saved red/green evidence for generic settings mutation and transient connection routing." + "Attach saved red/green evidence for generic settings mutation and transient connection routing.", + "Run the paired Add Project journey on Linux and native Windows, plus live SSH and WSL hosts." ], "knownGaps": [ "The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.", - "Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session." + "Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session.", + "The paired Add Project journey is macOS-only; native Windows was unavailable.", + "SSH and WSL Add Project ownership is deterministic store/controller coverage, not a live host journey." ], - "demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart." + "demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal or Add Project depends on the durable default instead of captured workspace/host ownership, if an Add Project operation reaches a different host after it begins, or if transient host state survives restart." }, { "id": "terminal-geometry.visible-convergence", @@ -7879,6 +8029,139 @@ "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default." ], "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge." + }, + { + "id": "ssh-port-forward.renderer-snapshot-continuity", + "title": "SSH forwarded-port state survives stale renderer hydration", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-ssh", + "layer": "renderer-ssh-snapshot-reconciliation", + "surfaces": [ + "SSH Ports panel", + "forwarded-port renderer state", + "persisted forward restoration", + "ssh2 port forwarding", + "system-SSH port forwarding" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["ssh2", "system-ssh"], + "coveredPlatforms": ["macos"], + "coveredProviders": ["ssh2", "system-ssh"], + "coverageNotes": "A deterministic renderer ordering test covers stale initial snapshots independently by target and stream, including hydration after partial SSH authority reconciliation. Headed macOS Electron tests force the same startup hydration race and use an ephemeral Docker sshd, a real remote Git worktree, real remote Node listeners, real HTTP forwards, in-place relay and full transport reconnect restoration, collision rejection, scan refresh, explicit removal, and an unrelated surviving forward through both transports. The forced-system run requires a recorded OpenSSH -L invocation.", + "motivatingLinks": ["user-reported SSH Ports panel disappearance"], + "invariant": "For one connected SSH authority, a renderer snapshot may update Forwarded or Detected state only if no newer push for that target and stream arrived after the snapshot began. Active tunnels and persisted intent remain authoritative through scan refresh and reconnect until explicit removal or a real connection-lifecycle transition.", + "oracle": "Hold empty initial Forwarded and Detected snapshot promises, publish live events, release the snapshots, and require each target and stream to preserve only its own newer push while applying unaffected snapshots. Reject one target's Detected snapshot and require its Forwarded snapshot plus later targets to hydrate independently. Begin another target with partial connected authority and require its snapshots to hydrate after same-watermark authority reconciliation. In headed Electron, hold an authoritative empty Forwarded snapshot across renderer reload, add a forward through the Ports panel, release and confirm the wrapped main handler resumed, then complete a later renderer-to-main listPortForwards round trip as the ordered hydration-continuation barrier. Before checking the Forwarded row, require main inventory, persisted intent, HTTP, remote process identity, and close warnings to prove the tunnel remained authoritative. Then forward two real Docker listeners, refresh detection, force an in-place relay-channel reconnect, perform a full transport reconnect, reject a bound local-port collision, remove one row, and require all signals to agree.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "pnpm exec electron-vite build --mode e2e", + "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1" + ], + "testFiles": [ + "src/renderer/src/hooks/useIpcEvents.test.ts", + "src/main/ssh/ssh-port-forward.test.ts", + "src/main/ssh/system-ssh-forward-process.test.ts", + "src/main/ipc/ssh.test.ts", + "src/main/ssh/ssh-relay-session.test.ts", + "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "tests/e2e/ssh-port-forward-lifecycle.spec.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/hooks/useIpcEvents.test.ts", + "assertions": ["does not let initial SSH port snapshots overwrite newer push events"] + }, + { + "file": "src/main/ssh/ssh-port-forward.test.ts", + "assertions": ["lists forwards filtered by connectionId", "removes a forward by id"] + }, + { + "file": "src/main/ssh/system-ssh-forward-process.test.ts", + "assertions": [ + "does not spawn ssh when the requested local forward port is already in use", + "sends SIGTERM then SIGKILL when the process does not exit", + "does not resolve stop until the process exits" + ] + }, + { + "file": "src/main/ipc/ssh.test.ts", + "assertions": [ + "preserves active port forwards and live connections across handler re-registration", + "persists desired forwards and broadcasts when an active forward closes unexpectedly" + ] + }, + { + "file": "src/main/ssh/ssh-relay-session.test.ts", + "assertions": ["cleans up port forwards on reconnect"] + }, + { + "file": "tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts", + "assertions": ["holds only the first matching request while its snapshot is unresolved"] + }, + { + "file": "tests/e2e/ssh-port-forward-lifecycle.spec.ts", + "assertions": ["keeps a user-forwarded listener live across scan refresh @headful"] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/hooks/useIpcEvents.test.ts src/main/ssh/ssh-port-forward.test.ts src/main/ssh/system-ssh-forward-process.test.ts src/main/ipc/ssh.test.ts src/main/ssh/ssh-relay-session.test.ts tests/e2e/helpers/ssh-port-forward-snapshot-barrier.unit.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 6.77, + "summary": "Six renderer, main-process, and barrier lifecycle files passed with 215 tests, including deterministic stale-snapshot, rejected-stream, partial-authority, and single-capture isolation plus existing collision, delayed-exit, reconnect, persistence, re-registration, and unrelated-forward contracts." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 53.7, + "summary": "Headed Electron passed against a real Docker sshd over ssh2 after forcing the stale startup-snapshot race and exact renderer-continuation barrier, with two remote listeners, scan refresh, in-place relay and full transport reconnect restoration, collision rejection, explicit removal, independent state inventories, HTTP responses, process identity, and no live forward-close warning." + }, + { + "date": "2026-07-30", + "runner": "local", + "platform": "macos", + "command": "ORCA_E2E_SSH_DOCKER=1 ORCA_SSH_FORCE_SYSTEM_TRANSPORT=1 ORCA_E2E_FORWARD_APP_LOGS=1 SKIP_BUILD=1 pnpm exec playwright test tests/e2e/ssh-port-forward-lifecycle.spec.ts --config tests/playwright.config.ts --project electron-headful --workers=1", + "result": "passed", + "durationSeconds": 66, + "summary": "The identical stale-snapshot and headed lifecycle passed through forced system OpenSSH, with a wrapper marker proving the -L forward process executed; in-place relay and full transport reconnects restored both intents, no unexpected forward close was captured before assertions, and intentional removal completed." + } + ], + "runtimeBudget": { + "p95Seconds": 180, + "scope": "focused renderer/main contracts plus two headed Docker SSH transport runs" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic contract and both real transport runs passed locally; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The identical renderer unit and headed Electron oracles fail on current main because the stale empty hydration reply becomes a second and final Forwarded write, pass with bounded per-target per-stream pending-hydration state, fail again when only that fix is removed, and pass again after restoration. In the tightened headed revert, both transports first proved main inventory, persisted intent, HTTP, remote process identity, no live close warning, and renderer continuation, then failed only at the missing Forwarded-row assertion. Both passed after rebuilding the restored candidate." + }, + "performanceBudget": { + "required": true, + "evidence": "The fix adds one effect-scoped map containing only unresolved target hydrations, one O(1) boolean write per relevant existing push event, and one O(1) authority/stream check per initial snapshot. Forwarded and Detected hydrate independently so a stalled stream does not block its peer or later targets; entries are removed after both settle. It adds no polling, timers, IPC calls, scans, subprocesses, or renderer subscriptions." + }, + "promotionCriteria": [ + "Collect 100 consecutive focused CI passes or 14 days of soak history.", + "Run the headed topology on Linux and Windows with their native system-SSH clients.", + "Re-run the headed snapshot barrier after Electron major-version upgrades.", + "Keep the independent Forwarded and Detected freshness assertions and both transport runs green." + ], + "knownGaps": [ + "Headed live evidence is macOS-only; Linux and Windows system-SSH clients were not exercised.", + "The headed race uses Electron's private invoke-handler registry to delay the real listPortForwards handler because Electron exposes no public handler-wrapping API.", + "The live run reconnects an existing app session but does not restart the packaged application from disk.", + "The live topology uses an SSH Git worktree; folder-workspace behavior is covered by target-scoped renderer reconciliation rather than a second headed topology." + ], + "demotionRule": "Keep experimental or demote if same-authority snapshots can overwrite newer pushes, reconnect silently loses persisted intent, one forward operation disturbs unrelated forwards, live HTTP diverges from renderer/main inventory, or either transport topology flakes without an identified product or harness fault." } ] } diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index 1154ec9cc57..931f9b93d01 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -1,13 +1,72 @@ -import { readFileSync } from 'node:fs' +import * as nodeFs from 'node:fs' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import * as nodePath from 'node:path' +import { join } from 'node:path' +import { tmpdir } from 'node:os' import { EventEmitter } from 'node:events' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' -import { createBootstrapFatalExitBanner, electronViteConfig } from '../../electron.vite.config' +import { + BOOTSTRAP_FATAL_LOG_ENV_VAR, + BOOTSTRAP_FATAL_LOG_FILE_NAME, + createBootstrapFatalExitBanner +} from '../../build-plugins/bootstrap-fatal-exit-banner' +import { electronViteConfig } from '../../electron.vite.config' import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard' const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8') +type BootstrapProcessMock = EventEmitter & { + env: Record + pid: number + exit: (code: number) => void + exitCode?: number +} + +/** Runs the banner in a bare context and raises the bootstrap fault it guards against. */ +function failBootstrapWithBanner(options: { + env: Record + tmpdir?: string + stderrWrites?: string[] +}): BootstrapProcessMock { + const processMock = new EventEmitter() as BootstrapProcessMock + processMock.env = options.env + processMock.pid = 4242 + processMock.exit = () => {} + const fsShim = { + ...nodeFs, + writeSync: (descriptor: number, data: string) => { + if (descriptor === 2) { + options.stderrWrites?.push(data) + return data.length + } + return nodeFs.writeSync(descriptor, data) + } + } + const context = { + process: processMock, + setImmediate: () => {}, + require: (specifier: string) => { + if (specifier === 'node:fs') { + return fsShim + } + if (specifier === 'node:path') { + return nodePath + } + if (specifier === 'node:os' && options.tmpdir !== undefined) { + return { tmpdir: () => options.tmpdir } + } + // Electron's own module is unreachable from a bootstrap fault this early. + throw new Error(`unexpected require: ${specifier}`) + } + } + + runInNewContext(createBootstrapFatalExitBanner(), context) + processMock.emit('uncaughtException', new Error("Cannot find module 'ws'")) + return processMock +} + describe('Electron Vite output contract', () => { it('keeps main-process and plain-Node entries at stable CommonJS paths', () => { const output = electronViteConfig.main?.build?.rollupOptions?.output @@ -40,12 +99,20 @@ describe('Electron Vite output contract', () => { const processMock = new EventEmitter() as EventEmitter & { exit: (code: number) => void exitCode?: number + stderr: { write: (chunk: string) => boolean } } let scheduledExit: (() => void) | null = null let exitedWith: number | null = null + const stderrWrites: string[] = [] processMock.exit = (code) => { exitedWith = code } + processMock.stderr = { + write: (chunk) => { + stderrWrites.push(chunk) + return true + } + } const context = { process: processMock, setImmediate: (callback: () => void) => { @@ -61,6 +128,63 @@ describe('Electron Vite output contract', () => { scheduledExit?.() expect(exitedWith).toBe(1) expect(context).toHaveProperty(BOOTSTRAP_FATAL_EXIT_GUARD_KEY) + expect(stderrWrites.join('')).toContain("Cannot find module 'zod'") + }) + + it('records the bootstrap failure it exits on, since the guard hides Electron dialog', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const logPath = join(logDirectory, 'fatal.log') + const stderrWrites: string[] = [] + + try { + const processMock = failBootstrapWithBanner({ + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath }, + stderrWrites + }) + + expect(stderrWrites.join('')).toContain("Cannot find module 'ws'") + const recorded = readFileSync(logPath, 'utf8') + expect(recorded).toContain("Cannot find module 'ws'") + expect(recorded).toContain('pid=4242') + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } + }) + + it('creates the parent directory an overridden log path names but does not have', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const logPath = join(logDirectory, 'nested', 'diagnostics', 'fatal.log') + + try { + const processMock = failBootstrapWithBanner({ + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logPath } + }) + + expect(readFileSync(logPath, 'utf8')).toContain("Cannot find module 'ws'") + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } + }) + + it('falls back to the default location when the overridden log path is unwritable', () => { + const logDirectory = mkdtempSync(join(tmpdir(), 'orca-bootstrap-fatal-')) + const fallbackDirectory = join(logDirectory, 'fallback') + + try { + const processMock = failBootstrapWithBanner({ + // A directory can never be opened as the log file, so the override must yield. + env: { [BOOTSTRAP_FATAL_LOG_ENV_VAR]: logDirectory }, + tmpdir: fallbackDirectory + }) + + const recorded = readFileSync(join(fallbackDirectory, BOOTSTRAP_FATAL_LOG_FILE_NAME), 'utf8') + expect(recorded).toContain("Cannot find module 'ws'") + expect(processMock.exitCode).toBe(1) + } finally { + rmSync(logDirectory, { recursive: true, force: true }) + } }) it('isolates renderer entry side effects behind strict facades', () => { diff --git a/config/scripts/smoke-packaged-cli.mjs b/config/scripts/smoke-packaged-cli.mjs index 4cecf49ad4b..877150f9e9b 100644 --- a/config/scripts/smoke-packaged-cli.mjs +++ b/config/scripts/smoke-packaged-cli.mjs @@ -35,6 +35,7 @@ const appDir = resolve(readAppDirArg(process.argv.slice(2))) const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-')) const copiedAppDir = join(tempRoot, basename(appDir)) +let smokeFailure = null try { await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true }) const cliPath = getPackagedCliPath(copiedAppDir) @@ -73,6 +74,31 @@ try { assert.equal(install.executed, false) assert.equal(update.executed, false) console.log(`[packaged-cli-smoke] help and skills commands passed via ${cliPath}`) -} finally { - await rm(tempRoot, { recursive: true, force: true }) +} catch (error) { + smokeFailure = error +} + +// Why: on Windows the launcher above spawns the copied Orca.exe (and its crashpad/utility children) +// once per command; those handles can outlive execFile's exit by a few ms, so this cleanup hits +// EBUSY on our own just-exited process after every assertion already passed. Same retry treatment +// as removeHostTree(); a lock that never clears still throws — unless the smoke run itself failed, +// in which case surfacing EBUSY instead of the real assertion would hide the actual regression. +const cleanupFailure = await rm(tempRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 250 +}).then( + () => null, + (error) => error +) + +if (smokeFailure) { + if (cleanupFailure) { + console.warn(`[packaged-cli-smoke] temp cleanup failed: ${cleanupFailure.message}`) + } + throw smokeFailure +} +if (cleanupFailure) { + throw cleanupFailure } diff --git a/config/scripts/windows-signing-workflow-contract.test.mjs b/config/scripts/windows-signing-workflow-contract.test.mjs index fc2087379f8..b5a8f41d01f 100644 --- a/config/scripts/windows-signing-workflow-contract.test.mjs +++ b/config/scripts/windows-signing-workflow-contract.test.mjs @@ -104,6 +104,41 @@ describe('Windows signing workflow contract', () => { expect(installRun).toContain('throw "Unable to install the SignPath PowerShell module') }) + it('still installs SignPath when the cut ref predates the composite action', () => { + const parsedWorkflow = readWorkflow('.github/workflows/release-cut.yml') + const steps = parsedWorkflow.jobs.build.steps + const stepNames = steps.map((step) => step.name) + const checkoutIndex = stepNames.indexOf('Checkout') + const restoreIndex = stepNames.indexOf('Restore composite actions from the workflow ref') + const installIndex = stepNames.indexOf('Install SignPath PowerShell module') + + // Why: the build job checks out the cut tag, which for a hotfix cut from an + // older ref can predate `.github/actions/install-signpath-module`; without + // this restore the `uses: ./…` step dies on a missing action.yml. + expect(restoreIndex).toBeGreaterThan(checkoutIndex) + expect(restoreIndex).toBeLessThan(installIndex) + + const restoreStep = steps[restoreIndex] + const restoreRun = restoreStep.run + + expect(restoreStep.env.WORKFLOW_SHA).toBe('${{ github.workflow_sha }}') + expect(restoreRun).toContain('.github/actions/install-signpath-module/action.yml') + expect(restoreRun).toContain('git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"') + expect(restoreRun).toContain('git checkout "$WORKFLOW_SHA" -- .github/actions') + + // Why: restoring the action must not turn signing into a soft dependency — + // a missing module still has to fail the Windows job, and the CDN fallback + // still has to reject an unexpected payload. + expect(steps[installIndex]['continue-on-error']).toBeUndefined() + expect(restoreStep['continue-on-error']).toBeUndefined() + + const installRun = readWorkflow('.github/actions/install-signpath-module/action.yml').runs + .steps[0].run + + expect(installRun).toContain('$actualHash -ne $expectedHash.ToUpperInvariant()') + expect(installRun).toContain('throw "SHA-256 mismatch for $source') + }) + it('shares one SignPath module install path between release and rehearsal', () => { const rehearsalWorkflow = readWorkflow('.github/workflows/windows-signing-rehearsal.yml') const stepNames = rehearsalWorkflow.jobs.rehearse.steps.map((step) => step.name) diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 39756947028..d42021315f0 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -6,6 +6,7 @@ "../src/renderer/src/**/*.tsx", "../src/preload/api-types.ts", "../src/shared/**/*", + "../src/main/gitlab/mappers.ts", "../src/main/ipc/worktree-branch-name.ts", "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 957e3f60b38..02239421ae9 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path' import { defineConfig, type UserConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' +import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' import packageJson from './package.json' with { type: 'json' } @@ -16,7 +17,6 @@ const BUNDLED_MAIN_DEPENDENCIES = new Set([ const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter( (dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency) ) -const BOOTSTRAP_FATAL_EXIT_GUARD_KEY = '__ORCA_BOOTSTRAP_FATAL_EXIT_GUARD__' function isExternalMainModule(source: string): boolean { if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) { @@ -171,32 +171,6 @@ function createStartupDiagnosticsBanner(chunkName: string): string { ` } -export function createBootstrapFatalExitBanner(): string { - // Why: Electron's pre-import error dialog can leave main resident and block NSIS replacement. - return ` -;(() => { - const guardKey = ${JSON.stringify(BOOTSTRAP_FATAL_EXIT_GUARD_KEY)} - if (typeof globalThis[guardKey] === 'function') { - return - } - let exitScheduled = false - const exitAfterBootstrapFailure = () => { - if (exitScheduled) { - return - } - exitScheduled = true - process.exitCode = 1 - setImmediate(() => process.exit(1)) - } - globalThis[guardKey] = () => { - process.off('uncaughtException', exitAfterBootstrapFailure) - delete globalThis[guardKey] - } - process.once('uncaughtException', exitAfterBootstrapFailure) -})(); -` -} - function createMainBootstrapPlugin() { return { name: 'orca-main-bootstrap', diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index c9e561a7b73..5f57f13608a 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -1904,6 +1904,7 @@ export default function SessionScreen() { setActiveHandle(active.terminal) subscribeToTerminal(active.terminal) } else if (active) { + // Why: an empty snapshot can transiently omit a live terminal; explicit close clears it on RPC success. const previous = activeHandleRef.current if (previous) { unsubscribeTerminal(previous) @@ -4133,6 +4134,7 @@ export default function SessionScreen() { reason: 'user' }) if (response.ok) { + const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { pendingBrowserFocusPageIdRef.current = null } @@ -4143,14 +4145,16 @@ export default function SessionScreen() { initializedHandlesRef.current.delete(terminalHandle) clearTerminalLiveInputDefault(terminalHandle) } - setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id)) + sessionTabsRef.current = remainingTabs + setSessionTabs(remainingTabs) // Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab. closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000) // Why: bulk close re-activates the anchor before awaiting each close; // the render-synced ref sees that switch while this closure would not, // so comparing against the ref keeps the anchor from being nulled out. - if (activeSessionTabIdRef.current === tab.id) { + if (activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0) { activeSessionTabTypeRef.current = null + activeSessionTabIdRef.current = null setActiveSessionTabId(null) activeHandleRef.current = null setActiveHandle(null) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 604f76ff653..82130a53c5a 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -26,6 +26,7 @@ import { } from '../src/components/AccountUsage' import AsyncStorage from '@react-native-async-storage/async-storage' import { loadHosts } from '../src/transport/host-store' +import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation' import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle' import { pickResumeWorktree } from '../src/worktree/resume-worktree' import type { RpcClient } from '../src/transport/rpc-client' @@ -1020,7 +1021,7 @@ export default function HomeScreen() { closeBeforePress: true, onPress: () => { setActionTarget(null) - router.push(`/h/${host.id}/edit`) + navigateToMobileHostEdit(router, host.id) } }) items.push({ diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index c5aebf054b4..5971b9dee94 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -13,11 +13,7 @@ import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture' -import { - createMockTerminals, - FAKE_SCROLLBACK, - STREAMING_CHUNKS -} from './mock-server-terminal-fixtures' +import { handleMockTerminalRequest } from './mock-server-terminal-stream' import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario' const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2) @@ -64,6 +60,8 @@ export type RpcResponse = { _meta: { runtimeId: string } } +export type RpcRespond = (response: RpcResponse, shouldSend?: () => boolean) => void + export const mockScenarioSummary = { repoCount: FAKE_REPOS.length, worktreeCount: fakeWorktrees.length, @@ -111,13 +109,18 @@ export function handleRequest( send: (response: RpcResponse) => void, ws: WebSocket ): void { - const respond = (response: RpcResponse) => { + const respond: RpcRespond = (response, shouldSend) => { + const deliver = () => { + if (shouldSend?.() !== false) { + send(response) + } + } const delay = responseDelayFor(request.method) if (delay > 0) { - setTimeout(() => send(response), delay) + setTimeout(deliver, delay) return } - send(response) + deliver() } // Each returns false for methods it does not own; first owner wins. @@ -126,7 +129,8 @@ export function handleRequest( handleMockFilePreviewRequest(request, respond, success, error) || handleMockAccountRequest(request, respond, success, error) || handleMockNativeChatRequest(request, respond, success, error, ws) || - handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) + handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId) || + handleMockTerminalRequest(request, respond, success, ws, terminalListWorktreeId) ) { return } @@ -279,44 +283,6 @@ export function handleRequest( break } - case 'terminal.list': { - const terminals = createMockTerminals(terminalListWorktreeId(request.params?.worktree)) - respond( - success(request.id, { - terminals, - totalCount: terminals.length, - truncated: false - }) - ) - break - } - - case 'terminal.subscribe': { - respond(success(request.id, { type: 'scrollback', lines: FAKE_SCROLLBACK, truncated: false })) - - let chunkIndex = 0 - const interval = setInterval(() => { - if (chunkIndex >= STREAMING_CHUNKS.length || ws.readyState !== ws.OPEN) { - clearInterval(interval) - if (ws.readyState === ws.OPEN) { - respond(success(request.id, { type: 'end' })) - } - return - } - respond(success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true)) - chunkIndex++ - }, 500) - break - } - - case 'terminal.send': - respond(success(request.id, { send: { handle: 'term-1', ok: true } })) - break - - case 'terminal.unsubscribe': - respond(success(request.id, { unsubscribed: true })) - break - case 'files.open': case 'files.openDiff': respond( diff --git a/mobile/scripts/mock-server-session-tabs-fixture.ts b/mobile/scripts/mock-server-session-tabs-fixture.ts index fbc94c2829e..f4a5bd1da1c 100644 --- a/mobile/scripts/mock-server-session-tabs-fixture.ts +++ b/mobile/scripts/mock-server-session-tabs-fixture.ts @@ -60,10 +60,24 @@ export function handleMockSessionTabsRequest( // absent or `id:`-prefixed selector means. resolveWorktreeId: (selector: unknown) => string | undefined ): boolean { - if (request.method !== 'session.tabs.list') { - return false + if (request.method === 'session.tabs.list') { + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + respond(success(request.id, createMockSessionTabs(worktreeId))) + return true } - const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' - respond(success(request.id, createMockSessionTabs(worktreeId))) - return true + if (request.method === 'session.tabs.subscribe') { + // Without a live stream the client's health loop keeps invalidating list + // fetches mid-flight (barrier bump on the failed probe), so the session + // screen never leaves 'Loading tabs'. snapshot then updated => 'live'. + const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock' + const snapshot = createMockSessionTabs(worktreeId) + respond(success(request.id, { type: 'snapshot', ...snapshot }, true)) + respond(success(request.id, { type: 'updated', ...snapshot }, true)) + return true + } + if (request.method === 'session.tabs.unsubscribe') { + respond(success(request.id, { unsubscribed: true })) + return true + } + return false } diff --git a/mobile/scripts/mock-server-terminal-stream.ts b/mobile/scripts/mock-server-terminal-stream.ts new file mode 100644 index 00000000000..f747ec59b21 --- /dev/null +++ b/mobile/scripts/mock-server-terminal-stream.ts @@ -0,0 +1,132 @@ +import type { WebSocket } from 'ws' +import type { RpcRequest, RpcRespond, RpcResponse } from './mock-server-rpc-handlers' +import { + createMockTerminals, + FAKE_SCROLLBACK, + STREAMING_CHUNKS +} from './mock-server-terminal-fixtures' + +// Why: the client resubscribes on every viewport change; without cancellation +// each resubscribe would stack another interval streaming under a dead request. +type TerminalStream = { interval: ReturnType | null } +const terminalStreams = new WeakMap>() + +function clearTerminalStream(ws: WebSocket, terminal: string): void { + const perTerminal = terminalStreams.get(ws) + const stream = perTerminal?.get(terminal) + if (stream) { + stopTerminalStreamInterval(stream) + perTerminal?.delete(terminal) + } +} + +function beginTerminalStream(ws: WebSocket, terminal: string): TerminalStream { + clearTerminalStream(ws, terminal) + let perTerminal = terminalStreams.get(ws) + if (!perTerminal) { + perTerminal = new Map() + terminalStreams.set(ws, perTerminal) + } + const stream = { interval: null } + perTerminal.set(terminal, stream) + return stream +} + +function isCurrentTerminalStream(ws: WebSocket, terminal: string, stream: TerminalStream): boolean { + return terminalStreams.get(ws)?.get(terminal) === stream && ws.readyState === ws.OPEN +} + +function stopTerminalStreamInterval(stream: TerminalStream): void { + if (stream.interval !== null) { + clearInterval(stream.interval) + stream.interval = null + } +} + +/** Terminal list/stream/input backend for the mock server. Returns false for + * methods it does not own. */ +export function handleMockTerminalRequest( + request: RpcRequest, + respond: RpcRespond, + success: (id: string, result: unknown, streaming?: boolean) => RpcResponse, + ws: WebSocket, + // Shared with `session.tabs.list` so both surfaces agree on which worktree an + // absent or `id:`-prefixed selector means. + resolveWorktreeId: (selector: unknown) => string | undefined +): boolean { + switch (request.method) { + case 'terminal.list': { + const terminals = createMockTerminals(resolveWorktreeId(request.params?.worktree)) + respond( + success(request.id, { + terminals, + totalCount: terminals.length, + truncated: false + }) + ) + return true + } + + case 'terminal.subscribe': { + const terminal = String(request.params?.terminal ?? 'term-1') + const stream = beginTerminalStream(ws, terminal) + const isCurrent = () => isCurrentTerminalStream(ws, terminal, stream) + // Why: the client resubscribes until scrollback echoes its viewport dims; + // the legacy `lines` shape left the session screen in that loop forever. + const viewport = request.params?.viewport as { cols?: number; rows?: number } | undefined + // MOCK_TUI=1 arms SGR drag mouse tracking (1002/1006) inside the scrollback + // itself so every xterm re-init re-enters the mode - used by mouse/touch + // input repros (#8818). + const tuiPreamble = + process.env.MOCK_TUI === '1' + ? '\x1b[?1002h\x1b[?1006h[mock] mouse tracking ON (1002/1006)\r\n' + : '' + respond( + success(request.id, { + type: 'scrollback', + cols: viewport?.cols ?? 80, + rows: viewport?.rows ?? 24, + serialized: FAKE_SCROLLBACK.replace(/\n/g, '\r\n') + tuiPreamble, + truncated: false + }), + isCurrent + ) + + let chunkIndex = 0 + stream.interval = setInterval(() => { + if (!isCurrent()) { + stopTerminalStreamInterval(stream) + return + } + if (chunkIndex >= STREAMING_CHUNKS.length) { + // Why: no `end` event - a live terminal stream stays open, and `end` + // makes the client tear the subscription down and blank the pane. + stopTerminalStreamInterval(stream) + return + } + respond( + success(request.id, { type: 'data', chunk: STREAMING_CHUNKS[chunkIndex] }, true), + isCurrent + ) + chunkIndex++ + }, 500) + return true + } + + case 'terminal.send': + // Input-routing repros (#8818) assert on the exact bytes reaching the host. + console.log( + `[SEND] terminal=${String(request.params?.terminal)} text=${JSON.stringify(request.params?.text)}` + ) + respond(success(request.id, { send: { handle: 'term-1', ok: true } })) + return true + + case 'terminal.unsubscribe': + clearTerminalStream(ws, String(request.params?.terminal ?? 'term-1')) + respond(success(request.id, { unsubscribed: true })) + return true + + default: + return false + } +} diff --git a/mobile/src/components/WorktreeAgentList.test.tsx b/mobile/src/components/WorktreeAgentList.test.tsx new file mode 100644 index 00000000000..036f5d35996 --- /dev/null +++ b/mobile/src/components/WorktreeAgentList.test.tsx @@ -0,0 +1,105 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { WorktreeAgentList } from './WorktreeAgentList' + +vi.mock('react-native', () => ({ + Pressable: 'Pressable', + StyleSheet: { create: (styles: T) => styles }, + Text: 'Text', + View: 'View' +})) +vi.mock('lucide-react-native', () => ({ + ChevronDown: 'ChevronDown', + ChevronRight: 'ChevronRight' +})) +vi.mock('./AgentStateDot', () => ({ AgentStateDot: () => null })) +vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null })) +vi.mock('./WorktreeAgentRow', () => ({ WorktreeAgentRow: 'WorktreeAgentRow' })) + +function agent(paneKey: string, parentPaneKey: string | null = null): RuntimeWorktreeAgentRow { + return { + paneKey, + parentPaneKey, + state: 'working', + agentType: 'codex', + prompt: `Prompt ${paneKey}`, + taskTitle: null, + displayName: null, + lastAssistantMessage: null, + toolName: null, + toolInput: null, + interrupted: false, + stateStartedAt: 1_000, + updatedAt: 1_000 + } +} + +describe('WorktreeAgentList', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + it('collapses multiple agents to their status icons by default', async () => { + const stopPropagation = vi.fn() + await act(async () => { + renderer = create( + createElement(WorktreeAgentList, { + agents: [agent('agent-1'), agent('agent-2'), agent('agent-3')], + now: 2_000, + unvisited: false + }) + ) + }) + + const summary = renderer!.root.findByType('Pressable') + expect(summary.props.accessibilityLabel).toBe('Expand 3 agents') + expect(summary.props.accessibilityState).toEqual({ expanded: false }) + expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(0) + + await act(async () => summary.props.onPress({ stopPropagation })) + + expect(stopPropagation).toHaveBeenCalledTimes(1) + expect(renderer!.root.findByType('Pressable').props.accessibilityLabel).toBe( + 'Collapse 3 agents' + ) + expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(3) + }) + + it('keeps a single agent visible without a redundant disclosure control', async () => { + await act(async () => { + renderer = create( + createElement(WorktreeAgentList, { + agents: [agent('agent-1')], + now: 2_000, + unvisited: false + }) + ) + }) + + expect(renderer!.root.findAllByType('Pressable')).toHaveLength(0) + expect(renderer!.root.findAllByType('WorktreeAgentRow')).toHaveLength(1) + }) + + it('summarizes lineage by root agents like desktop', async () => { + await act(async () => { + renderer = create( + createElement(WorktreeAgentList, { + agents: [agent('parent-1'), agent('child-1', 'parent-1'), agent('parent-2')], + now: 2_000, + unvisited: false + }) + ) + }) + + expect(renderer!.root.findByType('Pressable').props.accessibilityLabel).toBe('Expand 2 agents') + }) +}) diff --git a/mobile/src/components/WorktreeAgentList.tsx b/mobile/src/components/WorktreeAgentList.tsx index dc357ad685b..3abe97e00e1 100644 --- a/mobile/src/components/WorktreeAgentList.tsx +++ b/mobile/src/components/WorktreeAgentList.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { StyleSheet, View } from 'react-native' import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' -import { flattenAgentRowLineage } from '../worktree/agent-row-lineage' +import { buildAgentRowLineageTree, flattenAgentRowLineage } from '../worktree/agent-row-lineage' import { WorktreeAgentRow } from './WorktreeAgentRow' +import { WorktreeAgentSummary } from './WorktreeAgentSummary' type Props = { agents: RuntimeWorktreeAgentRow[] @@ -14,20 +15,35 @@ type Props = { // a depth-indented WorktreeAgentRow per agent, mirroring the desktop sidebar's // WorktreeCardAgents. export function WorktreeAgentList({ agents, now, unvisited }: Props) { - // Why: rebuild the lineage tree only when the agent list changes, not on every - // re-render (the shared useNow tick re-renders this list every 30s). const nodes = useMemo(() => flattenAgentRowLineage(agents), [agents]) + const summaryAgents = useMemo(() => { + const lineage = buildAgentRowLineageTree(agents) + return lineage.childrenByParentPaneKey.size > 0 ? lineage.rootRows : agents + }, [agents]) + const [expanded, setExpanded] = useState(false) + const usesSummary = summaryAgents.length > 1 + return ( - {nodes.map((node) => ( - setExpanded((value) => !value)} /> - ))} + ) : null} + {!usesSummary || expanded + ? nodes.map((node) => ( + + )) + : null} ) } diff --git a/mobile/src/components/WorktreeAgentSummary.tsx b/mobile/src/components/WorktreeAgentSummary.tsx new file mode 100644 index 00000000000..76d3b8ff4b6 --- /dev/null +++ b/mobile/src/components/WorktreeAgentSummary.tsx @@ -0,0 +1,104 @@ +import { ChevronDown, ChevronRight } from 'lucide-react-native' +import { Pressable, StyleSheet, Text, View } from 'react-native' +import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types' +import { colors, radii, spacing } from '../theme/mobile-theme' +import { agentDotState } from '../worktree/agent-row-display' +import { AgentStateDot } from './AgentStateDot' +import { MobileAgentIcon } from './MobileAgentIcon' + +const MAX_VISIBLE_AGENTS = 3 + +type Props = { + agents: RuntimeWorktreeAgentRow[] + expanded: boolean + now: number + onToggle: () => void +} + +export function WorktreeAgentSummary({ agents, expanded, now, onToggle }: Props) { + const visibleAgents = agents.slice(0, MAX_VISIBLE_AGENTS) + const hiddenCount = agents.length - visibleAgents.length + const subject = `${agents.length} agents` + + return ( + [ + styles.summary, + !expanded && styles.summaryCollapsed, + pressed && styles.summaryPressed + ]} + accessibilityRole="button" + accessibilityLabel={`${expanded ? 'Collapse' : 'Expand'} ${subject}`} + accessibilityState={{ expanded }} + onPress={(event) => { + event.stopPropagation() + onToggle() + }} + > + {expanded ? ( + {subject} + ) : ( + + {visibleAgents.map((agent) => ( + + + {agent.agentType ? : null} + + ))} + {hiddenCount > 0 ? +{hiddenCount} : null} + + )} + {expanded ? ( + + ) : ( + + )} + + ) +} + +const styles = StyleSheet.create({ + summary: { + minHeight: 24, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.xs, + borderRadius: radii.button + }, + summaryCollapsed: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised + }, + summaryPressed: { + opacity: 0.72 + }, + expandedLabel: { + flex: 1, + paddingLeft: spacing.xs, + fontSize: 11, + fontWeight: '500', + color: colors.textMuted + }, + agentIcons: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + agentStatus: { + height: 19, + flexDirection: 'row', + alignItems: 'center', + gap: 2, + paddingHorizontal: 3, + borderRadius: radii.button, + backgroundColor: colors.bgPanel + }, + hiddenCount: { + fontSize: 10, + color: colors.textMuted + } +}) diff --git a/mobile/src/session/mobile-clipboard-image.test.ts b/mobile/src/session/mobile-clipboard-image.test.ts index c0cc283fe5c..cc563127631 100644 --- a/mobile/src/session/mobile-clipboard-image.test.ts +++ b/mobile/src/session/mobile-clipboard-image.test.ts @@ -8,6 +8,7 @@ import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image' import type { RpcClient } from '../transport/rpc-client' +import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types' function ok(id: string, result: unknown): RpcSuccess { @@ -124,6 +125,30 @@ describe('mobile clipboard image paste helpers', () => { }) }) + it('restarts the upload after a logical connection cutover', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce(ok('start-1', { uploadId: 'upload-1' })) + .mockRejectedValueOnce(new LogicalClientCutoverError()) + .mockResolvedValueOnce(ok('abort-1', { aborted: true })) + .mockResolvedValueOnce(ok('start-2', { uploadId: 'upload-2' })) + .mockResolvedValueOnce(ok('append-2', { receivedBase64Length: 8 })) + .mockResolvedValueOnce(ok('commit-2', '/tmp/orca-paste-image.png')) + + await expect(saveMobileClipboardImageAsTempFile({ sendRequest }, 'aGVsbG8=')).resolves.toBe( + '/tmp/orca-paste-image.png' + ) + + expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([ + 'clipboard.startImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.abortImageUpload', + 'clipboard.startImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload' + ]) + }) + it('brackets generated image paths before sending to the terminal', () => { expect(buildMobileImagePastePayload('/tmp/orca.png')).toBe('\x1b[200~/tmp/orca.png\x1b[201~') expect(buildMobileImagePastePayload('/tmp/\x1b.png')).toBe('\x1b[200~/tmp/\u241b.png\x1b[201~') diff --git a/mobile/src/session/mobile-clipboard-image.ts b/mobile/src/session/mobile-clipboard-image.ts index 471eb220060..d3e60a6f755 100644 --- a/mobile/src/session/mobile-clipboard-image.ts +++ b/mobile/src/session/mobile-clipboard-image.ts @@ -1,10 +1,12 @@ import type { RpcClient } from '../transport/rpc-client' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import type { RpcFailure, RpcSuccess } from '../transport/types' import { t } from '@/i18n/mobile-i18n' export const MOBILE_CLIPBOARD_IMAGE_MAX_BASE64_CHARS = 24 * 1024 * 1024 export const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CHUNK_BASE64_CHARS = 512 * 1024 export const MOBILE_CLIPBOARD_IMAGE_SINGLE_FRAME_FALLBACK_BASE64_CHARS = 256 * 1024 +const MOBILE_CLIPBOARD_IMAGE_UPLOAD_CUTOVER_MAX_RETRIES = 1 // Why: PNG bytes don't scale exactly with pixel area, so undershoot the target on // each pass and let the bounded retry below converge instead of distorting in one shot. const MOBILE_CLIPBOARD_IMAGE_DOWNSCALE_SAFETY = 0.85 @@ -107,6 +109,26 @@ export async function saveMobileClipboardImageAsTempFile( ): Promise { const contentBase64 = normalizeMobileClipboardImageBase64(imageData) const connectionId = args?.connectionId ?? null + for (let retry = 0; ; retry += 1) { + try { + return await uploadMobileClipboardImageTransaction(client, contentBase64, connectionId) + } catch (error) { + if ( + !isLogicalClientCutoverError(error) || + retry >= MOBILE_CLIPBOARD_IMAGE_UPLOAD_CUTOVER_MAX_RETRIES + ) { + throw error + } + // Why: upload replay can create only temp state; terminal input is sent after this returns. + } + } +} + +async function uploadMobileClipboardImageTransaction( + client: Pick, + contentBase64: string, + connectionId: string | null +): Promise { const startResponse = await client.sendRequest('clipboard.startImageUpload', { expectedBase64Length: contentBase64.length, connectionId diff --git a/mobile/src/session/mobile-image-base64-accumulator.test.ts b/mobile/src/session/mobile-image-base64-accumulator.test.ts new file mode 100644 index 00000000000..c8e930d4bda --- /dev/null +++ b/mobile/src/session/mobile-image-base64-accumulator.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' + +function decodeBase64(data: string): Uint8Array { + return Uint8Array.from(atob(data), (character) => character.charCodeAt(0)) +} + +describe('MobileImageBase64Accumulator', () => { + it('preserves bytes split across non-aligned source chunks', () => { + const accumulator = new MobileImageBase64Accumulator() + accumulator.append(new Uint8Array([1])) + accumulator.append(new Uint8Array([2, 3])) + accumulator.append(new Uint8Array([4, 5])) + + expect(accumulator.finish()).toBe('AQIDBAU=') + }) + + it('preserves bytes across internal staging flushes', () => { + const bytes = new Uint8Array(256 * 1024 + 7) + bytes.forEach((_, index) => { + bytes[index] = index % 251 + }) + const accumulator = new MobileImageBase64Accumulator() + accumulator.append(bytes.subarray(0, 123_457)) + accumulator.append(bytes.subarray(123_457)) + + expect(decodeBase64(accumulator.finish())).toEqual(bytes) + }) +}) diff --git a/mobile/src/session/mobile-image-base64-accumulator.ts b/mobile/src/session/mobile-image-base64-accumulator.ts new file mode 100644 index 00000000000..b56150737ef --- /dev/null +++ b/mobile/src/session/mobile-image-base64-accumulator.ts @@ -0,0 +1,54 @@ +const MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES = 8190 +const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1 + +function encodeMobileImageBytes(bytes: Uint8Array): string { + const encoded: string[] = [] + for ( + let offset = 0; + offset < bytes.byteLength; + offset += MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES + ) { + const end = Math.min(offset + MOBILE_IMAGE_BASE64_BINARY_CHUNK_BYTES, bytes.byteLength) + let binary = '' + for (let index = offset; index < end; index += 1) { + binary += String.fromCharCode(bytes[index]!) + } + encoded.push(btoa(binary)) + } + return encoded.join('') +} + +export class MobileImageBase64Accumulator { + private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES) + private readonly encodedChunks: string[] = [] + private stagingLength = 0 + + append(bytes: Uint8Array): void { + let offset = 0 + while (offset < bytes.byteLength) { + const copied = Math.min( + this.staging.byteLength - this.stagingLength, + bytes.byteLength - offset + ) + this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength) + this.stagingLength += copied + offset += copied + if (this.stagingLength === this.staging.byteLength) { + this.flushStaging() + } + } + } + + finish(): string { + this.flushStaging() + return this.encodedChunks.join('') + } + + private flushStaging(): void { + if (this.stagingLength === 0) { + return + } + this.encodedChunks.push(encodeMobileImageBytes(this.staging.subarray(0, this.stagingLength))) + this.stagingLength = 0 + } +} diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index a165e963329..fbdd9eb5dec 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image' vi.mock('expo-image-picker', () => ({ requestMediaLibraryPermissionsAsync: vi.fn(), @@ -7,6 +8,9 @@ vi.mock('expo-image-picker', () => ({ vi.mock('expo-document-picker', () => ({ getDocumentAsync: vi.fn() })) +vi.mock('expo-file-system', () => ({ + File: vi.fn() +})) import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' @@ -15,17 +19,53 @@ const granted = { granted: true } as Awaited< > const denied = { granted: false } as typeof granted +function fileFactory( + bytes: Uint8Array, + options?: { fileSize?: number; handleSize?: number | null; readError?: Error } +) { + const close = vi.fn() + const chunks = [bytes] + const readBytes = vi.fn(() => { + if (options?.readError) { + throw options.readError + } + return chunks.shift() ?? new Uint8Array() + }) + const open = vi.fn(() => ({ + size: options?.handleSize ?? options?.fileSize ?? bytes.length, + readBytes, + close + })) + const createFile = vi.fn(() => ({ size: options?.fileSize ?? bytes.length, open })) + return { close, createFile, open } +} + describe('pickMobileImage', () => { - it('returns base64 from the photo library', async () => { + afterEach(() => vi.restoreAllMocks()) + + it('reads a photo URI without relying on React Native fetch', async () => { + const bytes = new Uint8Array([0, 1, 2, 3]) + const file = fileFactory(bytes) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }] + }) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Network request failed')) const result = await pickMobileImage('library', { requestLibraryPermission: vi.fn().mockResolvedValue(granted), - launchLibrary: vi.fn().mockResolvedValue({ - canceled: false, - assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }] - }) + launchLibrary, + createFile: file.createFile }) - expect(result).toEqual({ base64: 'AAAA', uri: 'file:///x.jpg' }) + expect(result).toEqual({ + base64: Buffer.from(bytes).toString('base64'), + uri: 'file:///x.jpg' + }) + expect(launchLibrary).toHaveBeenCalledWith(expect.objectContaining({ base64: false })) + expect(fetchSpy).not.toHaveBeenCalled() + expect(file.close).toHaveBeenCalledTimes(1) }) it('throws when photo library permission is denied', async () => { @@ -48,22 +88,21 @@ describe('pickMobileImage', () => { it('reads a picked file URI into base64 for the files source', async () => { const bytes = new Uint8Array([1, 2, 3, 4]) - const fetchSpy = vi - .spyOn(globalThis, 'fetch') - .mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } })) + const file = fileFactory(bytes) const result = await pickMobileImage('files', { launchFiles: vi.fn().mockResolvedValue({ canceled: false, - assets: [{ uri: 'file:///doc.png' }] - }) + assets: [{ uri: 'file:///doc.png', size: bytes.length }] + }), + createFile: file.createFile }) expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64'), uri: 'file:///doc.png' }) - fetchSpy.mockRestore() + expect(file.close).toHaveBeenCalledTimes(1) }) it('returns null when the files picker is cancelled', async () => { @@ -73,4 +112,33 @@ describe('pickMobileImage', () => { expect(result).toBeNull() }) + + it('rejects an oversized asset before opening it', async () => { + const file = fileFactory(new Uint8Array([1])) + await expect( + pickMobileImage('files', { + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }] + }), + createFile: file.createFile + }) + ).rejects.toThrow('Clipboard image is too large') + expect(file.createFile).not.toHaveBeenCalled() + expect(file.open).not.toHaveBeenCalled() + }) + + it('closes the file handle when reading fails', async () => { + const file = fileFactory(new Uint8Array(), { fileSize: 4, readError: new Error('read failed') }) + await expect( + pickMobileImage('files', { + launchFiles: vi.fn().mockResolvedValue({ + canceled: false, + assets: [{ uri: 'file:///broken.png', size: 4 }] + }), + createFile: file.createFile + }) + ).rejects.toThrow('read failed') + expect(file.close).toHaveBeenCalledTimes(1) + }) }) diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index 7f510494618..bf9c529cc25 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -1,8 +1,12 @@ -// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro -// can't resolve Node's builtin in a React Native bundle. -import { Buffer } from 'buffer' import * as DocumentPicker from 'expo-document-picker' +import { File as FsFile } from 'expo-file-system' import * as ImagePicker from 'expo-image-picker' +import { + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES, + assertClipboardImageBase64LengthWithinLimit, + assertClipboardImageByteLengthWithinLimit +} from '../../../src/shared/clipboard-image' +import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator' export type MobileImageSource = 'library' | 'files' @@ -21,18 +25,68 @@ export class ImageLibraryPermissionError extends Error { } } -// Why: expo-document-picker returns a file URI, not base64. Read it through -// fetch + Buffer so we match the base64 contract the upload pipeline expects -// without pulling in expo-file-system. -async function readUriAsBase64(uri: string): Promise { - const response = await fetch(uri) - const bytes = new Uint8Array(await response.arrayBuffer()) - return Buffer.from(bytes).toString('base64') +const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024 + +type MobileImageFileHandle = { + readonly size: number | null + readBytes(length: number): Uint8Array + close(): void +} + +type MobileImageFile = { + readonly size: number + open(): MobileImageFileHandle +} + +export type MobileImageFileFactory = (uri: string) => MobileImageFile + +function defaultMobileImageFileFactory(uri: string): MobileImageFile { + return new FsFile(uri) +} + +async function readUriAsBase64( + uri: string, + declaredSize: number | undefined, + createFile: MobileImageFileFactory +): Promise { + if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) { + assertClipboardImageByteLengthWithinLimit(declaredSize) + } + + const file = createFile(uri) + assertClipboardImageByteLengthWithinLimit(file.size) + const handle = file.open() + try { + if (handle.size !== null) { + assertClipboardImageByteLengthWithinLimit(handle.size) + } + const accumulator = new MobileImageBase64Accumulator() + let bytesRead = 0 + while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) { + const requested = Math.min( + MOBILE_IMAGE_READ_CHUNK_BYTES, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1 + ) + const bytes = handle.readBytes(requested) + if (bytes.byteLength === 0) { + break + } + bytesRead += bytes.byteLength + assertClipboardImageByteLengthWithinLimit(bytesRead) + accumulator.append(bytes) + } + const base64 = accumulator.finish() + assertClipboardImageBase64LengthWithinLimit(base64.length) + return base64 + } finally { + handle.close() + } } async function pickFromLibrary( requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, - launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync + launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, + createFile: MobileImageFileFactory = defaultMobileImageFileFactory ): Promise { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. @@ -41,7 +95,7 @@ async function pickFromLibrary( } const result = await launch({ mediaTypes: ['images'], - base64: true, + base64: false, allowsMultipleSelection: false, quality: 1 }) @@ -49,7 +103,7 @@ async function pickFromLibrary( return null } const asset = result.assets[0] - const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null) + const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null if (!base64) { return null } @@ -57,7 +111,8 @@ async function pickFromLibrary( } async function pickFromFiles( - launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync + launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, + createFile: MobileImageFileFactory = defaultMobileImageFileFactory ): Promise { const result = await launch({ type: 'image/*', @@ -71,7 +126,8 @@ async function pickFromFiles( if (!asset?.uri) { return null } - return { base64: await readUriAsBase64(asset.uri), uri: asset.uri } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + return base64 ? { base64, uri: asset.uri } : null } export async function pickMobileImage( @@ -80,10 +136,11 @@ export async function pickMobileImage( readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory } ): Promise { if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary) + return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) } - return pickFromFiles(deps?.launchFiles) + return pickFromFiles(deps?.launchFiles, deps?.createFile) } diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts new file mode 100644 index 00000000000..7bd6ad179fc --- /dev/null +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) + +describe('mobile session last-tab close', () => { + it('preserves terminal identity while an empty snapshot may be transient', () => { + const start = sessionRouteSource.indexOf('const applySessionTabs = useCallback') + const end = sessionRouteSource.indexOf('const readMarkdownTab', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain('} else if (active) {') + }) + + it('clears stale active identity when closing leaves no tabs', () => { + const start = sessionRouteSource.indexOf('async function handleCloseSessionTab') + const end = sessionRouteSource.indexOf('const bulkCloseActions', start) + const block = sessionRouteSource.slice(start, end) + + expect(block).toContain( + 'activeSessionTabIdRef.current === tab.id || remainingTabs.length === 0' + ) + expect(block).toContain('activeSessionTabIdRef.current = null') + expect(block).toContain('activeHandleRef.current = null') + }) +}) diff --git a/mobile/src/tasks/github-check-summary.test.ts b/mobile/src/tasks/github-check-summary.test.ts index a173663d9a5..3eafcc7661d 100644 --- a/mobile/src/tasks/github-check-summary.test.ts +++ b/mobile/src/tasks/github-check-summary.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' -import { buildGitHubCheckSummary } from './github-check-summary' +import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary' import { buildGitLabCheckSummary } from './gitlab-check-summary' +import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary' +import type { ProviderCheckSummary } from '../../../src/shared/types' describe('buildGitHubCheckSummary', () => { it('returns none for empty check lists', () => { @@ -31,14 +33,14 @@ describe('buildGitHubCheckSummary', () => { }) }) - it('keeps neutral and unknown terminal conclusions out of passed', () => { + it('keeps neutral and unknown terminal conclusions out of passed without demoting the PR', () => { expect( buildGitHubCheckSummary([ { status: 'completed', conclusion: 'success' }, { status: 'completed', conclusion: 'neutral' } ]) ).toEqual({ - state: 'neutral', + state: 'success', total: 2, passed: 1, failed: 0, @@ -49,7 +51,7 @@ describe('buildGitHubCheckSummary', () => { it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => { expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({ - state: 'neutral', + state: 'success', total: 2, passed: 1, failed: 0, @@ -58,3 +60,84 @@ describe('buildGitHubCheckSummary', () => { }) }) }) + +type ParityCase = { + name: string + checks: GitHubCheckLike[] + expected: Omit +} + +const completed = (conclusion: string): GitHubCheckLike => ({ status: 'completed', conclusion }) + +const PARITY_CASES: ParityCase[] = [ + { + name: 'all success', + checks: [completed('success'), completed('success')], + expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 } + }, + { + name: 'success plus skipped', + checks: [completed('success'), completed('skipped')], + expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 } + }, + { + name: 'all skipped', + checks: [completed('skipped'), completed('skipped')], + expected: { state: 'success', passed: 2, failed: 0, pending: 0, neutral: 0 } + }, + { + name: 'success plus neutral', + checks: [completed('success'), completed('neutral')], + expected: { state: 'success', passed: 1, failed: 0, pending: 0, neutral: 1 } + }, + { + name: 'all neutral', + checks: [completed('neutral')], + expected: { state: 'neutral', passed: 0, failed: 0, pending: 0, neutral: 1 } + }, + { + name: 'success plus failure', + checks: [completed('success'), completed('failure')], + expected: { state: 'failure', passed: 1, failed: 1, pending: 0, neutral: 0 } + }, + { + name: 'success plus running', + checks: [completed('success'), { status: 'in_progress', conclusion: null }], + expected: { state: 'pending', passed: 1, failed: 0, pending: 1, neutral: 0 } + }, + { + name: 'genuine action_required', + checks: [completed('success'), completed('action_required')], + expected: { state: 'failure', passed: 1, failed: 1, pending: 0, neutral: 0 } + } +] + +describe('mobile / desktop check classification parity', () => { + it.each(PARITY_CASES)('$name matches the shared desktop classifier', ({ checks, expected }) => { + const summary = { ...expected, total: checks.length } + expect(buildGitHubCheckSummary(checks)).toEqual(summary) + expect(summarizeProviderChecks(checks)).toEqual(summary) + }) + + it.each([ + { + name: 'GitLab manual gate only', + statuses: ['manual'], + expected: { state: 'neutral', passed: 0, failed: 0, pending: 0, neutral: 1 } + }, + { + name: 'GitLab manual gate alongside a green pipeline', + statuses: ['manual', 'success'], + expected: { state: 'success', passed: 1, failed: 0, pending: 0, neutral: 1 } + } + ] satisfies { + name: string + statuses: string[] + expected: Omit + }[])('$name never reads as failing', ({ statuses, expected }) => { + expect(buildGitLabCheckSummary(statuses.map((status) => ({ status })))).toEqual({ + ...expected, + total: statuses.length + }) + }) +}) diff --git a/mobile/src/tasks/github-check-summary.ts b/mobile/src/tasks/github-check-summary.ts index a0006efb95d..8a69db32291 100644 --- a/mobile/src/tasks/github-check-summary.ts +++ b/mobile/src/tasks/github-check-summary.ts @@ -1,60 +1,15 @@ +import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary' +import type { ProviderCheckSummary } from '../../../src/shared/types' + export type GitHubCheckLike = { status: string conclusion?: string | null } -export type GitHubCheckSummary = { - state: 'success' | 'failure' | 'pending' | 'neutral' | 'none' - total: number - passed: number - failed: number - pending: number - neutral: number -} - -function isFailedCheck(check: GitHubCheckLike): boolean { - return ( - check.conclusion === 'failure' || - check.conclusion === 'timed_out' || - check.conclusion === 'cancelled' - ) -} - -function isPendingCheck(check: GitHubCheckLike): boolean { - return ( - check.status === 'queued' || check.status === 'in_progress' || check.conclusion === 'pending' - ) -} +export type GitHubCheckSummary = ProviderCheckSummary +// Why: reuse the desktop classifier verbatim — a second copy is what let mobile call `skipped` +// unresolved while desktop called the same PR green. export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary { - let failed = 0 - let pending = 0 - let neutral = 0 - - for (const check of checks) { - if (isFailedCheck(check) || check.conclusion === 'action_required') { - failed += 1 - } else if (isPendingCheck(check)) { - pending += 1 - } else if (check.conclusion === 'success') { - continue - } else { - neutral += 1 - } - } - - const total = checks.length - const passed = total - failed - pending - neutral - const state = - total === 0 - ? 'none' - : failed > 0 - ? 'failure' - : pending > 0 - ? 'pending' - : neutral > 0 - ? 'neutral' - : 'success' - - return { state, total, passed, failed, pending, neutral } + return summarizeProviderChecks(checks) } diff --git a/mobile/src/tasks/gitlab-check-summary.ts b/mobile/src/tasks/gitlab-check-summary.ts index d984b6e4283..b5834a1d8b9 100644 --- a/mobile/src/tasks/gitlab-check-summary.ts +++ b/mobile/src/tasks/gitlab-check-summary.ts @@ -1,37 +1,17 @@ -import { buildGitHubCheckSummary, type GitHubCheckLike } from './github-check-summary' +import { + mapGitLabPipelineJobStatusToCheckStatus, + mapGitLabPipelineJobStatusToConclusion +} from '../../../src/shared/gitlab-pipeline-checks' +import { summarizeProviderChecks } from '../../../src/shared/provider-check-summary' import type { ProviderCheckSummary } from '../../../src/shared/types' type GitLabPipelineJobLike = { status: string } export function buildGitLabCheckSummary(jobs: GitLabPipelineJobLike[]): ProviderCheckSummary { - return buildGitHubCheckSummary( - jobs.map((job): GitHubCheckLike => { - const status = job.status.toLowerCase() - const nonterminal = [ - 'created', - 'pending', - 'scheduled', - 'waiting_for_callback', - 'waiting_for_resource', - 'preparing' - ].includes(status) - return { - status: status === 'running' ? 'in_progress' : nonterminal ? 'queued' : 'completed', - conclusion: - status === 'success' - ? 'success' - : status === 'failed' - ? 'failure' - : ['canceled', 'canceling'].includes(status) - ? 'cancelled' - : status === 'skipped' - ? 'skipped' - : ['manual', 'action_required'].includes(status) - ? 'action_required' - : status === 'running' || nonterminal - ? 'pending' - : null - } - }) + return summarizeProviderChecks( + jobs.map((job) => ({ + status: mapGitLabPipelineJobStatusToCheckStatus(job.status), + conclusion: mapGitLabPipelineJobStatusToConclusion(job.status) + })) ) } diff --git a/mobile/src/tasks/mobile-hosted-check-status.test.ts b/mobile/src/tasks/mobile-hosted-check-status.test.ts index f98b71dc575..05b8e0fcf88 100644 --- a/mobile/src/tasks/mobile-hosted-check-status.test.ts +++ b/mobile/src/tasks/mobile-hosted-check-status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { HostedReviewDecision } from '../../../src/shared/hosted-review' +import { mobileI18n } from '../i18n/mobile-i18n' import { getHostedChecksLabel, getHostedReviewLabel, @@ -20,6 +21,27 @@ describe('mobile hosted check status', () => { expect(getHostedReviewSignalTone({ checksSummary: summary }, 'checks')).toBe('neutral') }) + it('keeps shared provider summaries localized on mobile', async () => { + const initialLocale = mobileI18n.language + await mobileI18n.changeLanguage('es') + try { + expect( + getHostedChecksLabel({ + checksSummary: { + state: 'success', + total: 2, + passed: 2, + failed: 0, + pending: 0, + neutral: 0 + } + }) + ).toBe('2/2 pasó') + } finally { + await mobileI18n.changeLanguage(initialLocale) + } + }) + it('accepts provider-neutral GitHub status fields without a shared work-item type', () => { expect( getHostedReviewSignalTone( diff --git a/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts new file mode 100644 index 00000000000..66e4900fe7c --- /dev/null +++ b/mobile/src/terminal/mock-server-terminal-stream-cancellation.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WebSocket } from 'ws' +import { handleMockTerminalRequest } from '../../scripts/mock-server-terminal-stream' +import { + handleRequest, + type RpcRequest, + type RpcRespond, + type RpcResponse +} from '../../scripts/mock-server-rpc-handlers' + +type DeferredResponse = { + response: RpcResponse + shouldSend?: () => boolean +} + +function success(id: string, result: unknown, streaming?: boolean): RpcResponse { + return { + id, + ok: true, + result, + ...(streaming ? { streaming: true as const } : {}), + _meta: { runtimeId: 'test' } + } +} + +function request(id: string, method: string): RpcRequest { + return { id, method, params: { terminal: 'term-1', viewport: { cols: 80, rows: 24 } } } +} + +describe('mock terminal stream cancellation', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() + }) + + it('invalidates delayed frames after resubscribe and unsubscribe', () => { + vi.useFakeTimers() + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const deferred: DeferredResponse[] = [] + const respond: RpcRespond = (response, shouldSend) => { + deferred.push({ response, shouldSend }) + } + const handle = (rpcRequest: RpcRequest) => + handleMockTerminalRequest(rpcRequest, respond, success, ws, () => 'wt') + + expect(handle(request('old', 'terminal.subscribe'))).toBe(true) + vi.advanceTimersByTime(500) + expect(deferred.filter(({ response }) => response.id === 'old')).toHaveLength(2) + + expect(handle(request('new', 'terminal.subscribe'))).toBe(true) + const oldResponses = deferred.filter(({ response }) => response.id === 'old') + const newResponses = deferred.filter(({ response }) => response.id === 'new') + expect(oldResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === true)).toBe(true) + + expect(handle(request('stop', 'terminal.unsubscribe'))).toBe(true) + expect(newResponses.every(({ shouldSend }) => shouldSend?.() === false)).toBe(true) + }) + + it('drops queued delayed frames after stream cancellation', () => { + vi.useFakeTimers() + vi.stubEnv('MOCK_RPC_DELAY_TERMINAL_SUBSCRIBE_MS', '1000') + const ws = { OPEN: 1, readyState: 1 } as unknown as WebSocket + const send = vi.fn<(response: RpcResponse) => void>() + + handleRequest(request('old', 'terminal.subscribe'), send, ws) + vi.advanceTimersByTime(500) + handleRequest(request('new', 'terminal.subscribe'), send, ws) + handleRequest(request('stop', 'terminal.unsubscribe'), send, ws) + + vi.advanceTimersByTime(1000) + expect(send.mock.calls.map(([response]) => response.id)).toEqual(['stop']) + }) +}) diff --git a/mobile/src/terminal/terminal-gesture-input.test.ts b/mobile/src/terminal/terminal-gesture-input.test.ts index a120d569db9..a920f37a7a3 100644 --- a/mobile/src/terminal/terminal-gesture-input.test.ts +++ b/mobile/src/terminal/terminal-gesture-input.test.ts @@ -27,6 +27,22 @@ describe('isTerminalGestureInput', () => { expect(isTerminalGestureInput(`${ESC}[<0;9999;9999M${ESC}[<0;9999;9999m`)).toBe(true) }) + it('accepts SGR left-drag motion sequences but rejects a motion release', () => { + expect(isTerminalGestureInput(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(true) + expect(countTerminalGestureInputSequences(`${ESC}[<32;10;5M${ESC}[<32;11;5M`)).toBe(2) + expect(isTerminalGestureInput(`${ESC}[<0;10;5M${ESC}[<32;11;5M${ESC}[<0;11;5m`)).toBe(true) + expect(isTerminalGestureInput(`${ESC}[<32;10;5m`)).toBe(false) + }) + + it('accepts default-encoding left-drag motion sequences', () => { + expect(isTerminalGestureInput(`${ESC}[M${String.fromCharCode(64, 43, 38)}`)).toBe(true) + expect( + isTerminalGestureInput( + `${ESC}[M${String.fromCharCode(32, 33, 33)}${ESC}[M${String.fromCharCode(64, 34, 33)}${ESC}[M${String.fromCharCode(35, 34, 33)}` + ) + ).toBe(true) + }) + it('accepts repeated default mouse wheel sequences', () => { expect( isTerminalGestureInput( diff --git a/mobile/src/terminal/terminal-gesture-input.ts b/mobile/src/terminal/terminal-gesture-input.ts index 6b84c4824fe..6af8a2367f9 100644 --- a/mobile/src/terminal/terminal-gesture-input.ts +++ b/mobile/src/terminal/terminal-gesture-input.ts @@ -1,8 +1,9 @@ const ESC = '\x1b' const MAX_TERMINAL_GESTURE_INPUT_LENGTH = 2048 const MAX_TERMINAL_GESTURE_INPUT_SEQUENCES = 32 +// Buttons: 0 left press/release, 32 left-drag motion, 64/65 wheel. const SGR_MOUSE_GESTURE_SEQUENCE_RE = new RegExp( - `^${ESC}\\[<(0|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` + `^${ESC}\\[<(0|32|64|65);([0-9]{1,4});([0-9]{1,4})([Mm])$` ) function isDefaultMouseGestureSequence(bytes: string, offset: number): number | null { @@ -12,8 +13,9 @@ function isDefaultMouseGestureSequence(bytes: string, offset: number): number | const button = bytes.charCodeAt(offset + 3) const col = bytes.charCodeAt(offset + 4) const row = bytes.charCodeAt(offset + 5) + // Buttons: 32 left press, 35 release, 64 left-drag motion, 96/97 wheel. if ( - (button === 32 || button === 35 || button === 96 || button === 97) && + (button === 32 || button === 35 || button === 64 || button === 96 || button === 97) && col >= 33 && col <= 126 && row >= 33 && diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index e8409cb3bee..a7715e4f564 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,6 +11,8 @@ import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' +import { TERMINAL_MOUSE_CLICK_DRAG_JS } from './terminal-webview-mouse-click-drag-injected' +import { TERMINAL_MOUSE_REPORT_CELL_JS } from './terminal-webview-mouse-report-cell-injected' import { TERMINAL_WHEEL_SCROLL_JS } from './terminal-webview-wheel-scroll-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { @@ -1153,31 +1155,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} return { col: col, row: viewportRow + viewportY }; } - function viewportToMouseReportCell(clientX, clientY) { - if (!term) return null; - var cellW = getCellWidth(); - var cellH = getCellHeight(); - if (cellW <= 0 || cellH <= 0) return null; - if (typeof clientX !== 'number') clientX = window.innerWidth / 2; - if (typeof clientY !== 'number') clientY = window.innerHeight / 2; - var total = getTotalScale(); - if (total <= 0) total = 1; - var sx = (clientX - panX) / total; - var sy = (clientY - panY) / total; - var maxX = Math.max(0, term.cols * cellW - 1); - var maxY = Math.max(0, term.rows * cellH - 1); - if (sx < 0) sx = 0; - if (sx > maxX) sx = maxX; - if (sy < 0) sy = 0; - if (sy > maxY) sy = maxY; - var col = Math.floor(sx / cellW); - var row = Math.floor(sy / cellH); - if (col < 0) col = 0; - if (col > term.cols - 1) col = term.cols - 1; - if (row < 0) row = 0; - if (row > term.rows - 1) row = term.rows - 1; - return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; - } + ${TERMINAL_MOUSE_REPORT_CELL_JS} function isAlternateBufferActive() { try { @@ -1648,6 +1626,10 @@ ${TERMINAL_WEBGL_RECOVERY_JS} // terminal-webview-wheel-scroll-injected.ts (extracted for max-lines). ${TERMINAL_WHEEL_SCROLL_JS} + // External mouse click/drag: see + // terminal-webview-mouse-click-drag-injected.ts (extracted for max-lines). + ${TERMINAL_MOUSE_CLICK_DRAG_JS} + btnCopy.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); @@ -1705,6 +1687,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS} targetSurface.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); }, true); attachSurfaceWheelHandler(targetSurface); + attachSurfaceMouseClickDragHandler(targetSurface); targetSurface.addEventListener('touchstart', function(e) { if (dispatcherShouldBlockSurface()) return; diff --git a/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts new file mode 100644 index 00000000000..bc9c12aa016 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click-drag-injected.ts @@ -0,0 +1,199 @@ +// Indirect-pointer (external mouse / trackpad) click and drag for the terminal +// surface, injected into XTERM_HTML. Extracted from terminal-webview-html.ts to +// keep that file within its max-lines budget. Companion to +// terminal-webview-wheel-scroll-injected.ts, which owns the wheel half (#11247); +// this owns the click/drag half of #8818. Closes over host-IIFE state/functions: +// term, ESC, sel, selMode, selectionOverlay, TAP_SLOP, getMouseTrackingMode, +// viewportToCell, viewportToMouseReportCell, isSafeSgrMouseCoordinate, +// sgrMouseMode, sgrMousePixelsMode, notify, notifyTerminalSurfaceTap, +// cancelSelect, applyXtermSelection, repositionOverlay, handleDragMove, +// stopEdgeScroll, and dispatcherShouldBlockSurface. +// +// Why pointer events: a hardware mouse on Android/iPadOS raises pointer events +// with pointerType 'mouse' and NO touch events, while a finger raises +// pointerType 'touch' plus the touch events the document dispatcher owns. The +// capture-phase mousedown/click suppression in attachSurfaceEventHandlers stays: +// it is what keeps xterm's own mouse handling inert (its onData output is +// dropped by the mobile bridge), and pointer events are unaffected by it. +export const TERMINAL_MOUSE_CLICK_DRAG_JS = ` + var mouseGesture = null; + + // One report per transition, built with the same encoding ladder as + // buildMouseClickInput: SGR pixels (1016) > SGR (1006) > default. Returns '' + // when the mode does not report this transition (x10 has no release, only + // drag/any report motion) or the cell is not encodable. + function buildMouseButtonReport(kind, clientX, clientY) { + var mouseTrackingMode = getMouseTrackingMode(); + if (mouseTrackingMode === 'none') return ''; + if (kind === 'motion' && mouseTrackingMode !== 'drag' && mouseTrackingMode !== 'any') return ''; + if (kind === 'release' && mouseTrackingMode === 'x10') return ''; + var cell = viewportToMouseReportCell(clientX, clientY); + if (!cell) return ''; + var sgrButton = kind === 'motion' ? 32 : 0; + var sgrFinal = kind === 'release' ? 'm' : 'M'; + if (sgrMousePixelsMode) { + if (!isSafeSgrMouseCoordinate(cell.x) || !isSafeSgrMouseCoordinate(cell.y)) return ''; + return ESC + '[<' + sgrButton + ';' + cell.x + ';' + cell.y + sgrFinal; + } + if (sgrMouseMode) { + // Why: xterm increments zero-based mouse cells before encoding reports. + var sgrCol = cell.col + 1; + var sgrRow = cell.row + 1; + if (!isSafeSgrMouseCoordinate(sgrCol) || !isSafeSgrMouseCoordinate(sgrRow)) return ''; + return ESC + '[<' + sgrButton + ';' + sgrCol + ';' + sgrRow + sgrFinal; + } + var button = kind === 'motion' ? 64 : kind === 'release' ? 35 : 32; + var col = cell.col + 1 + 32; + var row = cell.row + 1 + 32; + // Why: non-SGR mouse bytes above ASCII are not preserved reliably through + // the mobile JSON/RPC string path; drop instead of corrupting input. + if (col > 126 || row > 126) return ''; + return ESC + '[M' + String.fromCharCode(button) + String.fromCharCode(col) + String.fromCharCode(row); + } + + function mouseReportCellKey(clientX, clientY) { + var cell = viewportToMouseReportCell(clientX, clientY); + return cell ? cell.col + ',' + cell.row : null; + } + + function abandonMouseGesture() { + var gesture = mouseGesture; + mouseGesture = null; + if (!gesture) return; + if (gesture.mode === 'tracking') { + // Why: the press report already went to the TUI; a lost pointer must not + // leave the button latched down on the far side. + var release = buildMouseButtonReport('release', gesture.lastX, gesture.lastY); + if (release) notify({ type: 'terminal-input', bytes: release }); + } else if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + } + } + + function beginMouseDrag(gesture) { + gesture.moved = true; + if (getMouseTrackingMode() !== 'none') { + gesture.mode = 'tracking'; + gesture.lastCellKey = mouseReportCellKey(gesture.startX, gesture.startY); + var press = buildMouseButtonReport('press', gesture.startX, gesture.startY); + if (press) notify({ type: 'terminal-input', bytes: press }); + return; + } + var anchor = viewportToCell(gesture.startX, gesture.startY); + if (!anchor) { + gesture.mode = 'cancelled'; + return; + } + // Why: mouse drags select character-anchored ranges like desktop terminals, + // not the word-seeded long-press selection; reuse the touch handle-drag + // plumbing (edge scroll included) by acting as a live 'end' handle. + gesture.mode = 'selecting'; + selMode = 'select'; + sel = { anchor: anchor, focus: anchor, activeHandle: 'end' }; + selectionOverlay.classList.add('active'); + notify({ type: 'set-select-mode', enabled: true }); + applyXtermSelection(); + repositionOverlay(); + } + + function attachSurfaceMouseClickDragHandler(targetSurface) { + targetSurface.addEventListener('pointerdown', function(e) { + if (e.pointerType !== 'mouse' || e.button !== 0) return; + if (dispatcherShouldBlockSurface() || !term) return; + // Why: a pointerup lost outside the WebView must not leave the previous + // gesture latched (tracking press with no release) when the next one lands. + if (mouseGesture) abandonMouseGesture(); + // Why: mouse pointers have no implicit capture; without it a drag that + // leaves the surface drops pointermove/pointerup and strands the gesture. + try { + if (targetSurface.setPointerCapture) targetSurface.setPointerCapture(e.pointerId); + } catch (err) {} + mouseGesture = { + startX: e.clientX, startY: e.clientY, + lastX: e.clientX, lastY: e.clientY, + lastCellKey: null, + moved: false, + mode: 'pending', + dismissedSelection: false + }; + if (selMode === 'select') { + // Why: touch parity — pressing outside the pill dismisses the current + // selection; the same press may still start a new drag selection. + cancelSelect(); + mouseGesture.dismissedSelection = true; + } + }, true); + + targetSurface.addEventListener('pointermove', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || gesture.mode === 'cancelled') return; + if (!term) return; + gesture.lastX = e.clientX; + gesture.lastY = e.clientY; + if ((e.buttons & 1) === 0) { + // Why: a pointerup lost outside the WebView (capture unavailable) must + // end the gesture here, or a tracked press stays latched at the TUI. + // Coordinates first, so the synthesized release lands where the + // pointer re-entered rather than at the previous cell. + abandonMouseGesture(); + return; + } + if (!gesture.moved) { + var dx = Math.abs(e.clientX - gesture.startX); + var dy = Math.abs(e.clientY - gesture.startY); + if (dx + dy <= TAP_SLOP) return; + beginMouseDrag(gesture); + } + if (gesture.mode === 'tracking') { + // Why: one motion report per cell keeps drags bounded by grid size, not + // by pointermove cadence, so the RN rate limiter is never the bottleneck. + var cellKey = mouseReportCellKey(e.clientX, e.clientY); + if (cellKey && cellKey !== gesture.lastCellKey) { + gesture.lastCellKey = cellKey; + var motion = buildMouseButtonReport('motion', e.clientX, e.clientY); + if (motion) notify({ type: 'terminal-input', bytes: motion }); + } + } else if (gesture.mode === 'selecting') { + handleDragMove('end', e.clientX, e.clientY); + } + }, true); + + targetSurface.addEventListener('pointerup', function(e) { + var gesture = mouseGesture; + if (e.pointerType !== 'mouse' || !gesture || e.button !== 0) return; + mouseGesture = null; + if (gesture.mode === 'cancelled' || !term) return; + if (gesture.mode === 'tracking') { + var release = buildMouseButtonReport('release', e.clientX, e.clientY); + if (release) notify({ type: 'terminal-input', bytes: release }); + return; + } + if (gesture.mode === 'selecting') { + if (sel) sel.activeHandle = null; + stopEdgeScroll(); + repositionOverlay(); + return; + } + if (dispatcherShouldBlockSurface()) return; + // Why: a dismissing tap only clears the selection (touch parity); it must + // not also open a link or focus the keyboard underneath. + if (gesture.dismissedSelection) return; + // Plain click == touch tap: links and file paths win, then tracking-mode + // click reports, then keyboard focus. + notifyTerminalSurfaceTap(e.clientX, e.clientY); + }, true); + + targetSurface.addEventListener('pointercancel', function(e) { + if (e.pointerType !== 'mouse') return; + abandonMouseGesture(); + }, true); + + // Why: Android input injection can pair a mouse-flavored pointerdown with + // real touch events (SOURCE_MOUSE + TOOL_TYPE_FINGER). If touch arrives, + // the document touch dispatcher owns the gesture. + targetSurface.addEventListener('touchstart', function() { + if (mouseGesture) abandonMouseGesture(); + }, true); + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-click.test.ts b/mobile/src/terminal/terminal-webview-mouse-click.test.ts new file mode 100644 index 00000000000..bb2494b62e7 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-click.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { ESC, useTerminalMouseWebViewHarness } from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse click', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('reports a mouse click to a click-tracking TUI the way a touch tap does (#8818)', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.mouseClick(40, 60) + + // Default (non-SGR) encoding: press (32=' ') then release (35='#'), each + // ESC [ M btn col row. Cell bytes depend on the fit scale, not on routing. + const bytes = mouse.terminalInputBytes() + expect(bytes).toHaveLength(12) + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes.slice(6, 10)).toBe(`${ESC}[M#`) + expect(bytes.slice(4, 6)).toBe(bytes.slice(10, 12)) + }) + + it('dismisses an existing selection with a click without focusing the keyboard', () => { + mouse.boot() + mouse.mouseDrag(40, 60, 160, 90) + mouse.clearPostedMessages() + + mouse.mouseClick(240, 200) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'set-select-mode')).toEqual([ + { type: 'set-select-mode', enabled: false } + ]) + expect(messages.filter((message) => message.type === 'terminal-tap')).toEqual([]) + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(false) + }) + + it('routes a plain mouse click to the tap pipeline for keyboard focus', () => { + mouse.boot() + + mouse.mouseClick(40, 60) + + const messages = mouse.postedMessages() + expect(messages.filter((message) => message.type === 'terminal-tap')).toHaveLength(1) + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('ignores non-mouse pointers and non-left buttons', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 1 + }) + mouse.dispatchPointer('pointerup', { + pointerType: 'touch', + x: 40, + y: 60, + button: 0, + buttons: 0 + }) + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 2, buttons: 2 }) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 2, buttons: 0 }) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.postedMessages().filter((message) => message.type === 'terminal-tap')).toEqual([]) + }) + + it('lets the touch dispatcher own a gesture when touch events follow pointerdown', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'vt200' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + const touchStart = new Event('touchstart', { bubbles: true }) + // Why: the document tap dispatcher reads touches[0]; happy-dom's plain Event lacks it. + Object.defineProperty(touchStart, 'touches', { + value: [{ identifier: 7, clientX: 40, clientY: 60 }] + }) + mouse.terminalSurface().dispatchEvent(touchStart) + mouse.dispatchPointer('pointerup', { x: 40, y: 60, button: 0, buttons: 0 }) + + // Why: Android SOURCE_MOUSE injections can pair a mouse pointerdown with + // real touch events; double-handling would double the click report. + expect(mouse.terminalInputBytes()).toBe('') + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-drag.test.ts b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts new file mode 100644 index 00000000000..e4fac512873 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-drag.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + DEFAULT_MOUSE_REPORT_RE, + ESC, + useTerminalMouseWebViewHarness +} from './terminal-webview-mouse-test-harness' + +describe('terminal WebView external mouse drag', () => { + const mouse = useTerminalMouseWebViewHarness() + + it('sends press, per-cell motion, and release for a drag-tracking mouse drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.mouseDrag(40, 60, 160, 60) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.length).toBeGreaterThanOrEqual(3) + expect(reports[0]?.charCodeAt(3)).toBe(32) + for (const motion of reports.slice(1, -1)) { + expect(motion.charCodeAt(3)).toBe(64) + } + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + // Motion reports are deduped per cell, so a horizontal drag advances columns. + const motionCols = reports.slice(1, -1).map((report) => report.charCodeAt(4)) + expect(new Set(motionCols).size).toBe(motionCols.length) + }) + + it('does not report motion or release for an x10 click-only TUI drag', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'x10' + + mouse.mouseDrag(40, 60, 160, 60) + + // x10 reports presses only; the press goes out at drag start, no motion or release follows. + const bytes = mouse.terminalInputBytes() + expect(bytes.slice(0, 4)).toBe(`${ESC}[M `) + expect(bytes).toHaveLength(6) + }) + + it('selects character-anchored text on a mouse drag outside tracking mode', () => { + mouse.boot() + // Why: init itself emits a set-select-mode reset; only the drag matters here. + mouse.clearPostedMessages() + + mouse.mouseDrag(40, 60, 160, 90) + + expect(mouse.terminalInputBytes()).toBe('') + expect(mouse.selectionSpy()).toHaveBeenCalled() + expect(document.getElementById('selection-overlay')?.classList.contains('active')).toBe(true) + const modes = mouse.postedMessages().filter((message) => message.type === 'set-select-mode') + expect(modes).toEqual([{ type: 'set-select-mode', enabled: true }]) + }) + + it('releases a tracked drag when the button state shows the pointerup was lost', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + // Pointer re-enters with the button already up — the pointerup never arrived. + mouse.dispatchPointer('pointermove', { x: 200, y: 60, button: 0, buttons: 0 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + + mouse.clearPostedMessages() + mouse.dispatchPointer('pointermove', { x: 240, y: 60, button: 0, buttons: 1 }) + + // The lost pointerup ended the gesture; a later unrelated move must not emit motion. + expect(mouse.terminalInputBytes()).toBe('') + }) + + it('releases a tracked drag when the pointer is cancelled mid-gesture', () => { + mouse.boot() + mouse.activeTerminal().modes.mouseTrackingMode = 'drag' + + mouse.dispatchPointer('pointerdown', { x: 40, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointermove', { x: 160, y: 60, button: 0, buttons: 1 }) + mouse.dispatchPointer('pointercancel', { x: 160, y: 60 }) + + const reports = mouse.terminalInputBytes().match(DEFAULT_MOUSE_REPORT_RE) ?? [] + // Press went to the TUI, so the cancel must not leave the button latched. + expect(reports.at(-1)?.charCodeAt(3)).toBe(35) + }) +}) diff --git a/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts new file mode 100644 index 00000000000..14baeeaf325 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-report-cell-injected.ts @@ -0,0 +1,29 @@ +// Mouse-report coordinate mapping injected into XTERM_HTML. Closes over term, +// panX/panY, getCellWidth/Height, and getTotalScale. +export const TERMINAL_MOUSE_REPORT_CELL_JS = ` + function viewportToMouseReportCell(clientX, clientY) { + if (!term) return null; + var cellW = getCellWidth(); + var cellH = getCellHeight(); + if (cellW <= 0 || cellH <= 0) return null; + if (typeof clientX !== 'number') clientX = window.innerWidth / 2; + if (typeof clientY !== 'number') clientY = window.innerHeight / 2; + var total = getTotalScale(); + if (total <= 0) total = 1; + var sx = (clientX - panX) / total; + var sy = (clientY - panY) / total; + var maxX = Math.max(0, term.cols * cellW - 1); + var maxY = Math.max(0, term.rows * cellH - 1); + if (sx < 0) sx = 0; + if (sx > maxX) sx = maxX; + if (sy < 0) sy = 0; + if (sy > maxY) sy = maxY; + var col = Math.floor(sx / cellW); + var row = Math.floor(sy / cellH); + if (col < 0) col = 0; + if (col > term.cols - 1) col = term.cols - 1; + if (row < 0) row = 0; + if (row > term.rows - 1) row = term.rows - 1; + return { col: col, row: row, x: Math.floor(sx), y: Math.floor(sy) }; + } +` diff --git a/mobile/src/terminal/terminal-webview-mouse-test-harness.ts b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts new file mode 100644 index 00000000000..29500d3c710 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-mouse-test-harness.ts @@ -0,0 +1,248 @@ +import { runInThisContext } from 'node:vm' +import { afterEach, beforeEach, vi, type Mock } from 'vitest' +import { XTERM_HTML } from './terminal-webview-html' + +function iifeSource(): string { + const start = XTERM_HTML.indexOf('(function() {') + const end = XTERM_HTML.lastIndexOf('})();') + return XTERM_HTML.slice(start, end + '})();'.length) +} + +function bodyMarkup(): string { + const start = XTERM_HTML.indexOf('') + ''.length + const end = XTERM_HTML.indexOf('