Merge remote-tracking branch 'origin/main' into pr16868-head

# Conflicts:
#	src/renderer/src/i18n/locales/ko.json
#	src/renderer/src/i18n/locales/zh.json
This commit is contained in:
Brennan Benson
2026-08-27 23:15:24 -07:00
867 changed files with 57576 additions and 18392 deletions
@@ -1,5 +1,5 @@
name: Install Node dependencies
description: Installs the Node toolchain and repository dependencies for Linux CI jobs.
description: Installs the Node toolchain and repository dependencies for CI jobs.
inputs:
native-runtime:
@@ -10,6 +10,18 @@ inputs:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
persist-native-cache:
description: Save restored native modules at job end. Set false when a later step overwrites the same path with a different ABI.
required: false
default: 'true'
outputs:
node-version:
description: Resolved Node.js version used for the install.
value: ${{ steps.requested-node.outputs.node-version || steps.default-node.outputs.node-version }}
native-cache-scope:
description: Operating-system image scope used by the native module cache.
value: ${{ steps.native-cache-scope.outputs.scope }}
runs:
using: composite
@@ -18,6 +30,7 @@ runs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.24.0
run_install: false
- name: Setup Node.js
@@ -51,7 +64,7 @@ runs:
# pnpm's bundled gyp_main.py is not executable on fresh Linux runners.
- name: Use external node-gyp
if: inputs.native-runtime != 'none'
if: runner.os == 'Linux' && inputs.native-runtime != 'none'
shell: bash
run: |
npm install -g node-gyp@11.5.0
@@ -74,18 +87,50 @@ runs:
run: |
pnpm install --frozen-lockfile --ignore-scripts
# Job containers can run composite steps from a source mirror without .git.
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml
fi
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
# ensure-native-runtime node-gyp-compiles it in every job that asks for a runtime.
# The artifacts are ABI-bound, so the key carries the target runtime, the resolved
# Node version, and the patch whose contents the build has to match.
- name: Restore compiled native modules
# Windows extra globs are empty on Linux. No restore-keys: a partial-match key is
# an ABI-mismatched build, and ensure-native-runtime would recompile it anyway.
# Native addons built on a newer Linux image can require glibc symbols
# missing from an older runner/container. ImageOS distinguishes hosted
# Windows/macOS images; /etc/os-release also distinguishes Linux containers.
- name: Resolve native cache scope
id: native-cache-scope
if: inputs.native-runtime != 'none'
shell: bash
run: |
scope="${ImageOS:-$RUNNER_OS}"
if [ -r /etc/os-release ]; then
. /etc/os-release
scope="${ID:-linux}-${VERSION_ID:-unknown}"
fi
echo "scope=$scope" >> "$GITHUB_OUTPUT"
- name: Restore compiled native modules
if: inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'
uses: actions/cache@v5
with:
path: node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
key: native-modules-${{ runner.os }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch') }}
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Restore compiled native modules without saving
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
uses: actions/cache/restore@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
+5 -22
View File
@@ -71,30 +71,17 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core gedit xvfb xclip xdotool
# Why: pnpm's bundled node-gyp can ship gyp_main.py without execute
# permission on Linux runners; node-pty's install fallback then fails
# before this smoke job can exercise the native package.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs
@@ -159,13 +146,9 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
- uses: ./.github/actions/install-node-dependencies
with:
node-version-file: package.json
- uses: pnpm/action-setup@v6
with:
run_install: false
- run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Owner-loss benchmark process cleanup
run: >-
pnpm vitest run
+35 -102
View File
@@ -17,6 +17,10 @@ on:
description: JSON array of changed specs; empty runs the full suite
required: false
type: string
ssh_source_changed:
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -40,36 +44,10 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: the E2E build compiles native modules via node-gyp. Mirrors the
# install step in pr.yml's verify job so E2E doesn't hit missing-toolchain
# errors.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
# Why: the build's plain-Node daemon smoke load resolves node-pty.
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: node
# Why: building here avoids parallel builds inside Playwright globalSetup;
# paired-browser specs also need the standalone web bundle.
@@ -130,11 +108,11 @@ jobs:
with:
ref: ${{ inputs.ref || github.ref }}
# Why: pnpm install rebuilds native modules, and those postinstall
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
# Why: pnpm install used to rebuild native modules here; the composite
# action restores them from cache and only compiles on a miss. The
# toolchain is still required for that miss path, and for paired Quick
# Open coverage which exercises the resource-bounded host search.
- name: Install native build tools
# Why: paired Quick Open coverage exercises the resource-bounded host search.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk python3 ripgrep zsh
# Why: Electron on Linux needs an X display even when the app
@@ -143,33 +121,9 @@ jobs:
- name: Install xvfb
run: sudo apt-get install -y xvfb
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: this job runs the same pnpm install path as pr.yml's verify
# job, so it needs the same pinned node-gyp override to avoid pnpm's
# broken bundled gyp_main.py on Linux. Gate on runner.os matches
# release.yml so the invariant "this workaround is Linux-only" is
# consistent across all three workflows, even though this job
# currently pins runs-on: ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -222,25 +176,9 @@ jobs:
# lane now receives those specs from pr.yml's SSH source mapping.
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 ripgrep xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Use external node-gyp to avoid pnpm's bundled copy
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -287,14 +225,21 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
# Why ssh_source_changed first: this lane used to trigger on SSH source only as a side
# effect of one route listing a startup-readiness spec — pruning that spec would have
# silently retired the whole lane. The signal is now derived from the SSH routes directly.
# The two spec clauses stay for their honest purpose: changed-e2e hands these specs to this
# lane, so editing one must still run it here.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 35: the parking, retention, startup-exec, and paired parity specs run
# serially on isolated Electron/SSH fixtures after watcher isolation.
timeout-minutes: 35
# Why 60: this lane now also runs the remaining Docker-SSH specs serially. They average
# ~18s but several budget 4-10 minutes per test, so a slow run lands far above the old 35
# — and the sharded lanes already show that a lane which times out is a lane nobody trusts.
timeout-minutes: 60
steps:
- name: Checkout
@@ -305,29 +250,9 @@ jobs:
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential fonts-noto-cjk openssh-client python3 xvfb zsh
# Why pnpm first: setup-node needs pnpm on PATH to locate the store it caches.
# Without that cache every E2E job re-downloaded the whole dependency set.
- name: Setup pnpm
uses: pnpm/action-setup@v6
- uses: ./.github/actions/install-node-dependencies
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
@@ -346,6 +271,14 @@ jobs:
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
# Why here rather than the sharded lanes: the shards set no ORCA_E2E_SSH_DOCKER, so every
# spec below skipped itself while the shard still reported green. Running them on this one
# VM pays the fixture image build once instead of ten times, and keeps an SSH regression
# legible as an SSH-named failure.
- name: Run remaining Docker SSH E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
+20
View File
@@ -0,0 +1,20 @@
name: Node next compatibility
on:
schedule:
# Full future-runtime coverage is useful, but not worth doubling every PR matrix.
- cron: '0 10 * * *'
workflow_dispatch:
concurrency:
group: node-next-compat
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["26"]'
+127 -108
View File
@@ -16,15 +16,29 @@ permissions:
contents: read
jobs:
# Why: a README/docs-only PR used to start the full matrix (32 test shards,
# Why: a README/docs-only PR used to start the full matrix (test shards,
# two package jobs, typecheck, git compat, xterm, shell contracts). Path
# filters on `on.pull_request` would drop the `verify` check entirely; this
# detector keeps verify as the required aggregate and skips the expensive jobs.
# Per-job outputs also skip git-compat/xterm/packaging/shell when those
# inputs are unchanged; empty diffs fail closed and run everything.
code_paths:
name: detect code-relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
xterm_patch_sync: ${{ steps.filter.outputs.xterm_patch_sync }}
shell_contracts: ${{ steps.filter.outputs.shell_contracts }}
test: ${{ steps.filter.outputs.test }}
orcad_browser: ${{ steps.filter.outputs.orcad_browser }}
cross-version-wire: ${{ steps.filter.outputs.cross-version-wire }}
managed_hook_node18: ${{ steps.filter.outputs.managed_hook_node18 }}
package: ${{ steps.filter.outputs.package }}
package_windows: ${{ steps.filter.outputs.package_windows }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -48,14 +62,12 @@ jobs:
CHANGED="$(git diff --name-only --no-renames --diff-filter=ACDMR --merge-base "$BASE_SHA" "$HEAD_SHA")"
echo "Changed paths:"
printf '%s\n' "$CHANGED"
SHOULD_RUN="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs)"
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "should_run=$SHOULD_RUN"
printf '%s\n' "$CHANGED" | node config/scripts/pr-code-change-scope.mjs | tee -a "$GITHUB_OUTPUT"
static_analysis:
name: static analysis
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.static_analysis == 'true'
runs-on: ubuntu-latest
steps:
@@ -70,6 +82,8 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- name: Lint
run: pnpm exec oxlint --format github
@@ -188,7 +202,7 @@ jobs:
typecheck:
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.typecheck == 'true'
runs-on: ubuntu-latest
steps:
@@ -216,7 +230,7 @@ jobs:
git_compatibility:
name: Git compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.git_compatibility == 'true'
runs-on: ubuntu-latest
steps:
@@ -283,7 +297,7 @@ jobs:
xterm_patch_sync:
name: xterm patch sync
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.xterm_patch_sync == 'true'
runs-on: ubuntu-latest
steps:
@@ -313,7 +327,7 @@ jobs:
shell_contracts:
name: shell contracts
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.shell_contracts == 'true'
runs-on: ubuntu-latest
# Why: this job's cost is almost entirely package download, and a stalled mirror has
# no wall-clock bound of its own. A successful run finishes in ~4.5 minutes, so this
@@ -429,17 +443,13 @@ jobs:
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
# Cache-key input changes would otherwise make every shard compile the same
# native addon concurrently. Prime the supported Node ABI before the matrix fans out.
test_native_cache:
name: prepare test native cache node 24
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
steps:
- name: Checkout
@@ -450,39 +460,24 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
node-version: '24'
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
test:
needs: [code_paths, test_native_cache]
if: >-
always() &&
needs.code_paths.outputs.test == 'true' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
uses: ./.github/workflows/unit-tests.yml
with:
node_versions: '["24"]'
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
# Why a separate job: the test needs a real Chrome, and the 32-way `test` matrix
# would pay for it 32 times to run one file in whichever shard it landed in.
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
orcad_browser:
name: orcad browser provider
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.orcad_browser == 'true'
runs-on: ubuntu-latest
steps:
@@ -518,7 +513,7 @@ jobs:
cross-version-wire:
name: cross-version wire compatibility
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.cross-version-wire == 'true'
runs-on: ubuntu-latest
steps:
@@ -550,7 +545,7 @@ jobs:
managed_hook_node18:
name: managed hooks on Node 18
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.managed_hook_node18 == 'true'
runs-on: ubuntu-latest
steps:
@@ -575,7 +570,7 @@ jobs:
package:
name: package
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package == 'true'
runs-on: ubuntu-latest
steps:
@@ -647,7 +642,7 @@ jobs:
package_windows:
name: package (windows)
needs: [code_paths]
if: needs.code_paths.outputs.should_run == 'true'
if: needs.code_paths.outputs.package_windows == 'true'
runs-on: windows-2022
timeout-minutes: 30
@@ -657,17 +652,6 @@ jobs:
with:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
@@ -678,18 +662,22 @@ jobs:
restore-keys: |
electron-builder-windows-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why persist-native-cache false: this job later rebuilds the same path for
# Electron. A post-job save would store the Electron ABI under the Node key.
- uses: ./.github/actions/install-node-dependencies
id: deps
with:
native-runtime: node
persist-native-cache: 'false'
# Why: node-pty prefers its upstream prebuild, which does not contain
# Orca's Windows patch, so the job-object exports would be absent and the
# suite below would test an unpatched binary. build_from_source removes
# the prebuild, and the package's postinstall restores the ConPTY runtime
# files that a bare node-gyp rebuild would miss.
- name: Rebuild node-pty from patched source
env:
npm_config_build_from_source: 'true'
run: pnpm rebuild node-pty
- name: Save compiled Node native modules
uses: actions/cache/save@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -724,9 +712,26 @@ jobs:
# Why the :parallel variant: identical to build:release except the three
# electron-vite targets overlap instead of running back to back. The Linux package
# job already packages and smoke-tests an AppImage built that way.
- name: Cache Windows CLI launcher
uses: actions/cache@v5
with:
path: native/windows-cli-launcher/.build
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs') }}
- name: Build package inputs
env:
ORCA_REUSE_WINDOWS_CLI_LAUNCHER: '1'
run: pnpm run build:release:parallel
- name: Restore compiled Electron native modules
uses: actions/cache@v5
with:
path: |
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
@@ -754,6 +759,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
test_files: ${{ steps.filter.outputs.test_files }}
ssh_source_changed: ${{ steps.filter.outputs.ssh_source_changed }}
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -776,6 +782,11 @@ jobs:
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
# spec name surviving in a route's list. Same routes, so the two cannot drift.
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "SSH source changed: $SSH_SOURCE_CHANGED"
if [ "$TEST_FILES_JSON" != '[]' ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
echo "Changed E2E specs: $TEST_FILES_JSON"
@@ -794,6 +805,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
test_files: ${{ needs.e2e-paths.outputs.test_files }}
ssh_source_changed: ${{ needs.e2e-paths.outputs.ssh_source_changed }}
verify:
if: always()
@@ -807,6 +819,7 @@ jobs:
- shell_contracts
- test
- orcad_browser
- cross-version-wire
- managed_hook_node18
- package
- package_windows
@@ -826,57 +839,63 @@ jobs:
CODE_PATHS: ${{ needs.code_paths.result }}
SHOULD_RUN: ${{ needs.code_paths.outputs.should_run }}
STATIC_ANALYSIS: ${{ needs.static_analysis.result }}
STATIC_ANALYSIS_SHOULD_RUN: ${{ needs.code_paths.outputs.static_analysis }}
ROOT_DIRECTORY_GUARD: ${{ needs.root_directory_guard.result }}
TYPECHECK: ${{ needs.typecheck.result }}
TYPECHECK_SHOULD_RUN: ${{ needs.code_paths.outputs.typecheck }}
GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }}
GIT_COMPATIBILITY_SHOULD_RUN: ${{ needs.code_paths.outputs.git_compatibility }}
XTERM_PATCH_SYNC: ${{ needs.xterm_patch_sync.result }}
XTERM_PATCH_SYNC_SHOULD_RUN: ${{ needs.code_paths.outputs.xterm_patch_sync }}
SHELL_CONTRACTS: ${{ needs.shell_contracts.result }}
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
MANAGED_HOOK_NODE18_SHOULD_RUN: ${{ needs.code_paths.outputs.managed_hook_node18 }}
PACKAGE: ${{ needs.package.result }}
PACKAGE_SHOULD_RUN: ${{ needs.code_paths.outputs.package }}
PACKAGE_WINDOWS: ${{ needs.package_windows.result }}
PACKAGE_WINDOWS_SHOULD_RUN: ${{ needs.code_paths.outputs.package_windows }}
run: |
if [ "$CODE_PATHS" != "success" ]; then
exit 1
fi
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
if [ "$SHOULD_RUN" != "true" ]; then
echo "Docs-only change; expensive PR checks skipped."
if [ "$ROOT_DIRECTORY_GUARD" != "success" ]; then
exit 1
fi
for result in \
"$STATIC_ANALYSIS" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "skipped" ]; then
exit 1
fi
done
exit 0
fi
# Require success when the PR has code-relevant changes
for result in \
"$CODE_PATHS" \
"$STATIC_ANALYSIS" \
"$ROOT_DIRECTORY_GUARD" \
"$TYPECHECK" \
"$GIT_COMPATIBILITY" \
"$XTERM_PATCH_SYNC" \
"$SHELL_CONTRACTS" \
"$TEST" \
"$ORCAD_BROWSER" \
"$MANAGED_HOOK_NODE18" \
"$PACKAGE" \
"$PACKAGE_WINDOWS"; do
if [ "$result" != "success" ]; then
exit 1
failed=0
check_job() {
local name="$1" result="$2" should="$3"
if [ "$should" = "true" ]; then
if [ "$result" != "success" ]; then
echo "$name: expected success, got $result"
failed=1
fi
else
if [ "$result" != "skipped" ]; then
echo "$name: expected skipped, got $result"
failed=1
fi
fi
done
}
# Require success when the PR has code-relevant changes
check_job static_analysis "$STATIC_ANALYSIS" "$STATIC_ANALYSIS_SHOULD_RUN"
check_job typecheck "$TYPECHECK" "$TYPECHECK_SHOULD_RUN"
check_job git_compatibility "$GIT_COMPATIBILITY" "$GIT_COMPATIBILITY_SHOULD_RUN"
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
check_job package_windows "$PACKAGE_WINDOWS" "$PACKAGE_WINDOWS_SHOULD_RUN"
exit "$failed"
+59
View File
@@ -0,0 +1,59 @@
name: Unit tests
on:
workflow_call:
inputs:
node_versions:
description: JSON array of Node.js major versions to test.
required: true
type: string
permissions:
contents: read
jobs:
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ${{ fromJSON(inputs.node_versions) }}
shard: [1, 2, 3, 4, 5, 6, 7, 8]
shard_total: [8]
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready-zsh-launch-environment.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/main/shell-startup-feature-channel.test.ts \
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
--exclude=src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
--exclude=src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
---
-30
View File
@@ -2,17 +2,13 @@
# This is a RATCHET: the list may only SHRINK. Do NOT add entries to get CI green —
# split the oversized file instead (AGENTS.md → "Do Not Disable Max Lines").
# Regenerate/prune: pnpm check:max-lines-ratchet --prune (removes stale entries only)
inline src/cli/handlers/automations.ts
inline src/cli/help.ts
inline src/main/agent-hooks/server.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.ts
inline src/main/browser/browser-cookie-import.ts
inline src/main/browser/browser-manager.ts
inline src/main/browser/cdp-bridge.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
@@ -23,14 +19,7 @@ inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
inline src/main/ipc/filesystem.ts
inline src/main/ipc/worktree-remote.ts
inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/memory/collector.ts
inline src/main/ports/advertised-url-watcher.ts
inline src/main/ports/local-workspace-port-scanner.ts
inline src/main/project-groups/nested-repo-discovery.ts
inline src/main/providers/local-pty-provider.ts
inline src/main/rate-limits/claude-pty.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
@@ -52,7 +41,6 @@ inline src/relay/git-handler.ts
inline src/relay/pty-handler.ts
inline src/relay/workspace-space-scan.ts
inline src/renderer/src/components/GitLabItemDialog.tsx
inline src/renderer/src/components/JiraIssueWorkspace.tsx
inline src/renderer/src/components/LinearItemDrawer.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
@@ -61,15 +49,12 @@ inline src/renderer/src/components/activity/ActivityPrototypePage.tsx
inline src/renderer/src/components/automations/AutomationsPage.tsx
inline src/renderer/src/components/editor/CombinedDiffViewer.tsx
inline src/renderer/src/components/editor/MarkdownPreview.tsx
inline src/renderer/src/components/feature-wall/EditorAnimatedVisual.tsx
inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
inline src/renderer/src/components/onboarding/use-onboarding-flow.ts
inline src/renderer/src/components/right-sidebar/PortsPanel.tsx
inline src/renderer/src/components/settings/AccountsPane.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -82,40 +67,25 @@ inline src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts
inline src/renderer/src/lib/pane-manager/pane-tree-ops.ts
inline src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts
inline src/renderer/src/runtime/runtime-file-client.ts
inline src/renderer/src/runtime/runtime-linear-client.ts
inline src/renderer/src/runtime/sync-runtime-graph.ts
inline src/renderer/src/runtime/web-runtime-session.ts
inline src/renderer/src/runtime/web-session-tabs-sync.ts
inline src/renderer/src/store/slices/agent-status.ts
inline src/renderer/src/store/slices/browser.ts
inline src/renderer/src/store/slices/diffComments.ts
inline src/renderer/src/store/slices/hosted-review.ts
inline src/renderer/src/store/slices/linear.ts
inline src/renderer/src/store/slices/tabs.ts
inline src/renderer/src/store/slices/ui.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline tests/e2e/helpers/terminal.ts
mobile-config app/h/*/files/*.tsx
mobile-config app/h/*/index.tsx
mobile-config app/h/*/session/*.tsx
mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
mobile-config scripts/repro-worktree-startup-stream.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/CustomKeyModal.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/components/mobile-rich-markdown-editor-html.ts
mobile-config src/terminal/terminal-accessory-keys.ts
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
@@ -1,26 +1,31 @@
diff --git a/binding.gyp b/binding.gyp
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..5f15551cb1520af996f216500a83ac68b57f5104 100644
index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e773638bf4 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -3,7 +3,7 @@
@@ -3,7 +3,6 @@
{
"target_name": "windows_process_tree",
"dependencies": [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
"conditions": [
['OS=="win"', {
@@ -16,9 +16,6 @@
@@ -15,14 +14,13 @@
"src/process_commandline.cc"
],
"include_dirs": [],
- "include_dirs": [],
+ "include_dirs": ["deps/node-addon-api"],
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
"libraries": [ 'psapi.lib' ],
- "msvs_configuration_attributes": {
- "SpectreMitigation": "Spectre"
- },
"msvs_settings": {
"VCCLCompilerTool": {
+ "ExceptionHandling": 1,
"AdditionalOptions": [
"/guard:cf",
"/sdl",
diff --git a/src/process.cc b/src/process.cc
index 3eea92077c4d1d433119361d5c432881859131e9..1998f4addd4d7e9aba946ea6f7f7a4a5d13291bc 100644
--- a/src/process.cc
+9 -1
View File
@@ -2,13 +2,21 @@ diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -1,13 +1,10 @@
@@ -1,13 +1,18 @@
{
'target_defaults': {
'dependencies': [
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
+ "<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except",
],
+ # Orca: GCC 9 (Ubuntu 20.04 floor) accepts C++20 as gnu++2a, but rejects
+ # the newer gnu++20 spelling emitted by Node 24's gyp flags.
+ 'cflags_cc!': [
+ '-std=gnu++20'
+ ],
+ 'cflags_cc': [
+ '-std=gnu++2a'
+ ],
'conditions': [
['OS=="win"', {
- 'msvs_configuration_attributes': {
File diff suppressed because one or more lines are too long
+60 -33
View File
@@ -1,41 +1,24 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { existsSync, mkdirSync, statSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
export function shouldReuseCompiledWindowsCliLauncher(
outputPath,
sourcePath,
{ reuseCached = false } = {}
) {
if (!existsSync(outputPath)) {
return false
}
// Why reuseCached: Actions cache keys already hash the C# source, but restore
// does not preserve mtimes, so a hit would look stale and recompile anyway.
if (reuseCached) {
return true
}
return statSync(outputPath).mtimeMs >= statSync(sourcePath).mtimeMs
}
function defaultOutputPath(projectRoot) {
@@ -58,3 +41,47 @@ function readArg(name) {
const index = process.argv.indexOf(name)
return index !== -1 ? process.argv[index + 1] : undefined
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
if (
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, {
reuseCached: process.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER === '1'
})
) {
console.log(`[native-build] reusing Windows CLI launcher at ${outputPath}`)
process.exit(0)
}
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
@@ -5,12 +5,15 @@ import {
mkdtempSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { shouldReuseCompiledWindowsCliLauncher } from './build-windows-cli-launcher.mjs'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +40,33 @@ function itWindows(name, test) {
}
describe('Windows CLI launcher', () => {
it('reuses a compiled launcher that is at least as new as the C# source', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
try {
const sourcePath = join(root, 'OrcaCliLauncher.cs')
const outputPath = join(root, '.build', 'orca.exe')
mkdirSync(join(root, '.build'))
writeFileSync(sourcePath, 'source\n')
writeFileSync(outputPath, 'binary\n')
const later = new Date(statSync(sourcePath).mtimeMs + 1_000)
utimesSync(outputPath, later, later)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(true)
writeFileSync(sourcePath, 'changed\n')
const sourceLater = new Date(statSync(outputPath).mtimeMs + 1_000)
utimesSync(sourcePath, sourceLater, sourceLater)
expect(shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath)).toBe(false)
expect(
shouldReuseCompiledWindowsCliLauncher(outputPath, sourcePath, { reuseCached: true })
).toBe(true)
expect(shouldReuseCompiledWindowsCliLauncher(join(root, 'missing.exe'), sourcePath)).toBe(
false
)
} finally {
removeFixtureTree(root)
}
})
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
@@ -26,9 +26,11 @@ import {
mkdirSync,
openSync,
readFileSync,
readSync
readSync,
writeFileSync
} from 'node:fs'
import { join, resolve } from 'node:path'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
const ROOT = resolve(import.meta.dirname, '..', '..')
@@ -67,13 +69,16 @@ function assertPatchApplied() {
'config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
if (!bindingGyp.includes("require.resolve('node-addon-api/node_addon_api.gyp')")) {
if (bindingGyp.includes('node_addon_api.gyp')) {
throw new Error(
'binding.gyp still uses require("node-addon-api").targets. That path is ' +
'cwd-relative and misses node_addon_api.gyp under pnpm on Windows. ' +
'binding.gyp still depends on node_addon_api.gyp. pnpm and node-gyp rewrite that ' +
'project path incorrectly on Windows. ' +
'pnpm did not apply config/patches/@vscode__windows-process-tree@0.8.0.patch; run pnpm install.'
)
}
if (!bindingGyp.includes('"include_dirs": ["deps/node-addon-api"]')) {
throw new Error('binding.gyp does not use the staged node-addon-api headers.')
}
const processCc = readFileSync(join(PACKAGE_DIR, 'src', 'process.cc'), 'utf8')
if (processCc.includes('process_count < 1024')) {
throw new Error(
@@ -83,6 +88,58 @@ function assertPatchApplied() {
}
}
// pnpm can materialize this CRLF package without applying its patch. Repair the
// load-bearing build settings before node-gyp so the release build stays safe.
function applyWindowsProcessTreeBuildFixes() {
const bindingPath = join(PACKAGE_DIR, 'binding.gyp')
const processPath = join(PACKAGE_DIR, 'src', 'process.cc')
const nodeAddonApiDir = dirname(
createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')
)
const stagedHeaderDir = join(PACKAGE_DIR, 'deps', 'node-addon-api')
let bindingGyp = readFileSync(bindingPath, 'utf8')
let processCc = readFileSync(processPath, 'utf8')
const originalBinding = bindingGyp
const originalProcess = processCc
for (const dynamicDependency of [
String.raw`<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except`,
String.raw`<!(node -p \"require.resolve('node-addon-api/node_addon_api.gyp')\"):node_addon_api_except`,
'../../node-addon-api/node_addon_api.gyp:node_addon_api_except'
]) {
bindingGyp = bindingGyp.replace(`"${dynamicDependency}",`, '')
}
bindingGyp = bindingGyp.replace(
'"include_dirs": []',
'"include_dirs": ["deps/node-addon-api"],\n "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"]'
)
if (!bindingGyp.includes('"ExceptionHandling": 1')) {
bindingGyp = bindingGyp.replace(
'"VCCLCompilerTool": {',
'"VCCLCompilerTool": {\n "ExceptionHandling": 1,'
)
}
bindingGyp = bindingGyp.replace(
/\r?\n\s*"msvs_configuration_attributes": \{\s*"SpectreMitigation": "Spectre"\s*\},?/s,
''
)
processCc = processCc.replace(/process_count < 1024 && /, '')
if (bindingGyp !== originalBinding) {
writeFileSync(bindingPath, bindingGyp)
}
if (processCc !== originalProcess) {
writeFileSync(processPath, processCc)
}
mkdirSync(stagedHeaderDir, { recursive: true })
for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h']) {
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
}
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
}
}
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
function readPeMachine(binaryPath) {
const fd = openSync(binaryPath, 'r')
@@ -109,6 +166,7 @@ function main() {
if (!existsSync(PACKAGE_DIR)) {
throw new Error(`${PACKAGE_DIR} is missing. Run pnpm install first.`)
}
applyWindowsProcessTreeBuildFixes()
assertPatchApplied()
console.log(`[windows-process-tree] building ${arch} from ${PACKAGE_DIR}`)
@@ -76,7 +76,6 @@ describe('computer-use e2e workflow', () => {
)
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-group-recovery.test.mjs',
'config/scripts/macos-computer-helper-owner-loss-processes.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
@@ -126,10 +125,14 @@ describe('computer-use e2e workflow', () => {
const job = workflow.jobs['mac-native-owner-smoke']
const runs = job.steps.map((step) => step.run).filter((run) => typeof run === 'string')
const checkout = job.steps.find((step) => step.uses === 'actions/checkout@v6')
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(job.if).toBe("github.event_name == 'pull_request'")
expect(job['runs-on']).toBe('macos-15')
expect(checkout.with['persist-credentials']).toBe(false)
expect(install.with['native-runtime']).toBe('electron')
expect(runs).toContain('pnpm bench:macos-computer-helper-owner-loss --expect reaped --trials 1')
const cleanupRun = runs.find((run) =>
run.includes('config/scripts/macos-computer-helper-owner-loss-processes.test.mjs')
+46 -16
View File
@@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
import { basename, resolve } from 'node:path'
import { basename, dirname, resolve } from 'node:path'
const require = createRequire(import.meta.url)
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
@@ -67,7 +67,12 @@ function ensureNodeRuntime() {
if (!initial.ok) {
printCheckError(initial)
}
runPnpm(['rebuild', 'node-pty'])
const failedModules = initial.failures.map((failure) => failure.moduleName)
const rebuildModules = [
'node-pty',
...failedModules.filter((moduleName) => moduleName !== 'node-pty')
]
rebuildNodeRuntimeModules(rebuildModules)
verifyNodeRuntimeAfterRebuild()
return
}
@@ -77,7 +82,7 @@ function ensureNodeRuntime() {
`[native-runtime] ${formatRuntimeLabel('node')} cannot load native modules; rebuilding ${failedModules.join(', ')} for Node.`
)
printCheckError(initial)
runPnpm(['rebuild', ...failedModules])
rebuildNodeRuntimeModules(failedModules)
verifyNodeRuntimeAfterRebuild()
}
@@ -312,14 +317,10 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix
// patch only lands in the source-built build/Release artifacts.
// Why: a loadable upstream node-pty prebuild is not enough; Orca's Unix and
// Windows patches only land in the source-built build/Release artifacts.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths = patchedNodePtyArtifactPaths(nodePtyDir)
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -329,11 +330,24 @@ function getPatchedNodePtyRebuildReason() {
return 'Patched node-pty build artifacts are missing; rebuilding native deps.'
}
function requiresPatchedNodePtySourceBuild() {
function patchedNodePtyArtifactPaths(nodePtyDir) {
if (process.platform === 'win32') {
return false
const releaseDir = resolve(nodePtyDir, 'build', 'Release')
return [
resolve(releaseDir, 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) => resolve(releaseDir, 'conpty', filename))
]
}
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
return artifactPaths
}
function requiresPatchedNodePtySourceBuild() {
const nodePtyPatchPath = resolve(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch')
if (!existsSync(nodePtyPatchPath)) {
return false
@@ -351,16 +365,32 @@ function getWindowsBuildNumber() {
return match && match.length === 4 ? Number.parseInt(match[3], 10) : 0
}
function runPnpm(args) {
function rebuildNodeRuntimeModules(moduleNames) {
for (const moduleName of moduleNames) {
const moduleDir = dirname(require.resolve(`${moduleName}/package.json`))
console.warn(`[native-runtime] Rebuilding ${moduleName} with node-gyp.`)
runPnpm(['exec', 'node-gyp', 'rebuild'], { cwd: moduleDir })
if (moduleName === 'node-pty' && process.platform === 'win32') {
runNodeScript([resolve(moduleDir, 'scripts', 'post-install.js')])
}
}
}
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env =
process.platform === 'linux' && args.includes('node-gyp')
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
: process.env
const result = spawnSync(command, args, {
cwd: projectDir,
cwd,
stdio: 'inherit',
shell: process.platform === 'win32'
shell: process.platform === 'win32',
env
})
if (result.error || result.status !== 0) {
console.error(`[native-runtime] ${command} ${args.join(' ')} failed.`)
console.error(`[native-runtime] ${command} ${args.join(' ')} failed in ${cwd}.`)
if (result.error) {
console.error(formatError(result.error))
}
+103 -10
View File
@@ -29,6 +29,7 @@ describe('ensure-native-runtime', () => {
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir)
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
@@ -42,7 +43,11 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log).toContain('pnpm exec node-gyp rebuild\n')
expect(log).toContain(join('node_modules', 'node-pty'))
if (process.platform === 'linux') {
expect(log).toMatch(/^cxxflags=(?:.*\s)?-std=gnu\+\+2a$/m)
}
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
@@ -52,6 +57,41 @@ describe('ensure-native-runtime', () => {
}
})
it.skipIf(process.platform !== 'win32')(
'rebuilds other failed Windows addons with patched node-pty',
() => {
const projectDir = mkTempProject()
try {
const scriptPath = join(projectDir, 'config', 'scripts', 'ensure-native-runtime.mjs')
const logPath = join(projectDir, 'native-runtime.log')
const markerPath = join(projectDir, 'rebuilt.marker')
const binDir = join(projectDir, 'bin')
copyFileSync(sourceScriptPath, scriptPath)
writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker: true })
writeNodePtyPatchFile(projectDir)
writeFakePnpm(binDir)
const result = spawnSync(process.execPath, [scriptPath, '--runtime=node'], {
cwd: projectDir,
encoding: 'utf8',
env: envWithPrependedPath(binDir, {
ORCA_NATIVE_TEST_LOG: logPath,
ORCA_NATIVE_TEST_MARKER: markerPath
})
})
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log.match(/pnpm exec node-gyp rebuild\n/g)).toHaveLength(2)
expect(log).toContain(join('node_modules', 'node-pty'))
expect(log).toContain(join('node_modules', 'windows-native-registry'))
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'rebuilds patched node-pty artifacts even when the Node load check passes',
() => {
@@ -80,7 +120,7 @@ describe('ensure-native-runtime', () => {
expect(result.stderr).toContain(
'Patched node-pty build artifacts are missing; rebuilding native deps.'
)
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -114,7 +154,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).toContain("expected build/Release so Orca's node-pty patch is active")
expect(readFileSync(logPath, 'utf8')).toContain('pnpm rebuild node-pty\n')
expect(readFileSync(logPath, 'utf8')).toContain('pnpm exec node-gyp rebuild\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -148,7 +188,7 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
expect(result.stderr).not.toContain('Patched node-pty build artifacts are missing')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm rebuild node-pty')
expect(readFileSync(logPath, 'utf8')).not.toContain('pnpm exec node-gyp rebuild')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
@@ -178,9 +218,15 @@ function envWithPrependedPath(binDir, extraEnv) {
}
}
function writeFakeNativeModules(projectDir) {
function writeFakeNativeModules(projectDir, { windowsRegistryRequiresMarker = false } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -197,15 +243,29 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
return {
dir: '../build/Release/',
module: {
listJobProcessIds() {},
terminateJob() {},
assignCurrentProcessToJob() {}
}
}
}
`
)
writeFakeWindowsRegistry(projectDir)
writeFakeWindowsRegistry(projectDir, { requiresMarker: windowsRegistryRequiresMarker })
}
function writeLoadableNativeModules(projectDir, { nativeDir = null } = {}) {
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(nodePtyDir, 'package.json'),
'{"name":"node-pty","version":"1.1.0","main":"index.js"}\n'
)
mkdirSync(join(nodePtyDir, 'scripts'), { recursive: true })
writeFileSync(join(nodePtyDir, 'scripts', 'post-install.js'), '')
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
@@ -218,23 +278,40 @@ exports.loadNativeModule = function loadNativeModule(nativeName) {
const dir = ${JSON.stringify(nativeDir)} ??
(rebuilt ? '../build/Release/' : '../prebuilds/' + process.platform + '-' + process.arch + '/')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`node-pty load \${nativeName} dir=\${dir}\\n\`)
return { dir, module: {} }
return {
dir,
module: {
listJobProcessIds: () => [],
terminateJob: () => true,
assignCurrentProcessToJob: () => true
}
}
}
`
)
writeFakeWindowsRegistry(projectDir)
}
function writeFakeWindowsRegistry(projectDir) {
function writeFakeWindowsRegistry(projectDir, { requiresMarker = false } = {}) {
if (process.platform !== 'win32') {
return
}
const registryDir = join(projectDir, 'node_modules', 'windows-native-registry')
mkdirSync(registryDir, { recursive: true })
writeFileSync(
join(registryDir, 'index.js'),
'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n'
join(registryDir, 'package.json'),
'{"name":"windows-native-registry","version":"3.2.2","main":"index.js"}\n'
)
const markerGate = requiresMarker
? `if (!require('node:fs').existsSync(process.env.ORCA_NATIVE_TEST_MARKER)) { throw new Error('registry ABI mismatch sentinel') }`
: ''
writeFileSync(
join(registryDir, 'index.js'),
`exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => { ${markerGate}; return {} }\n`
)
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
mkdirSync(processTreeDir, { recursive: true })
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeNodePtyPatchFile(projectDir) {
@@ -245,6 +322,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
@@ -260,6 +344,15 @@ function writeFakePnpm(binDir) {
const { appendFileSync, writeFileSync } = require('node:fs')
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`pnpm \${process.argv.slice(2).join(' ')}\\n\`)
appendFileSync(process.env.ORCA_NATIVE_TEST_LOG, \`cwd=\${process.cwd()}\\n\`)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`npm_config_build_from_source=\${process.env.npm_config_build_from_source || ''}\\n\`
)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`cxxflags=\${process.env.CXXFLAGS || ''}\\n\`
)
writeFileSync(process.env.ORCA_NATIVE_TEST_MARKER, 'rebuilt')
`
)
@@ -47,6 +47,33 @@ function executeInstallScript(fixture) {
}
describe('install-node-dependencies action', () => {
it('skips the lockfile diff when a job container has no Git metadata', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it('skips the lockfile diff for a bare repository', () => {
const fixture = createFixture()
try {
rmSync(join(fixture.workspace, '.git'), { recursive: true, force: true })
expect(run('git', ['init', '--bare', '-q'], { cwd: fixture.workspace }).status).toBe(0)
const result = executeInstallScript(fixture)
expect(result.status, result.stderr || result.stdout).toBe(0)
expect(`${result.stdout}\n${result.stderr}`).not.toMatch(/^diff --git /m)
} finally {
rmSync(fixture.root, { recursive: true, force: true })
}
})
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
@@ -467,10 +467,11 @@ describe('Electron runtime package contract', () => {
expect(copyStep.run).toContain('git add "$CASK_PATH"')
})
it('installs the Electron package binary in PR checks without changing native module ABI', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const installStep = parsedWorkflow.jobs.test.steps.find(
it('installs the Electron package binary in the shared unit-test workflow', () => {
const unitTestWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/unit-tests.yml'), 'utf8')
)
const installStep = unitTestWorkflow.jobs.test.steps.find(
(step) => step.name === 'Install Electron package binary for tests'
)
+273 -2
View File
@@ -16,6 +16,196 @@ const DOCS_ONLY_FILES = new Set([
const DOCS_ONLY_PREFIXES = ['docs/', '.github/ISSUE_TEMPLATE/']
export const PR_CHECK_JOBS = [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test'])
const GLOBAL_FORCE_PREFIXES = [
'.github/workflows/pr.yml',
'.github/actions/install-node-dependencies/',
'config/scripts/pr-code-change-scope'
]
const GLOBAL_FORCE_FILES = new Set(['package.json', 'pnpm-lock.yaml'])
const GIT_COMPAT_PREFIXES = [
'src/shared/git-',
'src/shared/review-head-tracking-ref',
'src/main/git/',
'src/relay/git-',
'config/scripts/git-binary-compatibility'
]
const XTERM_PREFIXES = [
'config/patches/xterm-upstream.json',
'config/patches/@xterm',
'config/patches/xterm-src/',
'config/scripts/regenerate-xterm-patches'
]
const SHELL_PREFIXES = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec',
'src/main/daemon/shell-ready',
'src/main/daemon/daemon-bash-shell-ready',
'src/main/daemon/daemon-shell-ready-wrapper',
'src/main/daemon/node-pty-fd-leak',
'src/main/providers/local-pty-shell-ready',
'src/main/providers/__tests__/shell-ready-framework-example',
'src/main/pty/',
'src/main/shell-templates',
'src/main/shell-startup-',
'src/main/shell-wrapper-',
'src/main/terminal-history-fish',
'src/main/zsh-',
'src/renderer/src/components/terminal-pane/fish-color-scheme',
'src/shared/fish-',
'src/shared/pty-reply-echo-shapes',
'src/shared/startup-shell-portability',
'src/shared/posix-command-path-lookup',
'config/patches/node-pty@',
'config/scripts/ensure-native-runtime',
'config/scripts/node-pty-job-ownership'
]
const ORCAD_BROWSER_PREFIXES = [
'src/main/orcad/external-chromium-',
'src/main/orcad/orcad-browser-provider',
'src/main/orcad/orcad-agent-browser-binary',
'src/main/orcad/electron-serve-browser-process'
]
const CROSS_VERSION_WIRE_PREFIXES = [
'tests/e2e/cross-version-wire/',
'src/shared/protocol-version',
'src/shared/terminal-stream-protocol',
'src/shared/browser-client-host-protocol',
'src/shared/browser-network-tunnel-protocol',
'src/shared/browser-client-host-placement',
'src/main/runtime/rpc/dispatcher',
'src/main/runtime/rpc/methods/browser-tab-create-schema',
'src/main/runtime/rpc/methods/terminal',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer'
]
const MANAGED_HOOK_PREFIXES = [
'config/scripts/smoke-managed-hook-runtime-node18',
'config/scripts/build-relay',
'src/relay/',
'src/shared/agent-hook',
'src/main/agent-hooks/'
]
const NATIVE_RUNTIME_PREFIXES = [
'config/scripts/ensure-native-runtime',
'config/scripts/rebuild-native-deps',
'config/scripts/node-pty-job-ownership',
'config/scripts/electron-builder-native-rebuild',
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
]
const NATIVE_CACHE_FILES = new Set([
'package.json',
'pnpm-lock.yaml',
'.github/actions/install-node-dependencies/action.yml',
'config/scripts/ensure-native-runtime.mjs',
'config/scripts/rebuild-native-deps.mjs'
])
const NATIVE_CACHE_PREFIXES = [
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
]
const SHARED_PACKAGE_PREFIXES = [
'electron.vite.config.ts',
'config/electron-builder',
'config/packaged-runtime',
'config/build-plugins/',
'config/scripts/build-',
'config/scripts/smoke-packaged',
'config/scripts/install-electron-package-binary',
'config/scripts/verify-packaged',
'config/scripts/verify-linux-glibc',
'config/scripts/run-electron-vite',
'skills/',
'skill-guides/',
'resources/build/',
'resources/onboarding/',
'resources/plugins/',
'resources/skills/',
...NATIVE_RUNTIME_PREFIXES
]
const LINUX_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/computer-use-linux/',
'resources/linux/',
'config/scripts/run-headless-serve'
]
const WINDOWS_PACKAGE_PREFIXES = [
...SHARED_PACKAGE_PREFIXES,
'native/windows-cli-launcher/',
'native/computer-use-windows/',
'resources/win32/',
'config/scripts/build-windows-cli-launcher',
'config/scripts/windows-pty-native-capability',
'tests/tools/windows-pty-native-capability-smoke/'
]
const LINUX_PACKAGE_TESTS = [
'src/main/browser/browser-client-page-renderer-lifecycle.electron.test.ts',
'src/main/browser/browser-route-tcp-egress.electron.test.ts',
'src/main/browser/browser-route-webrtc-egress.electron.test.ts',
'src/main/browser/browser-route-h3-egress.electron.test.ts',
'src/main/browser/browser-route-dns-prefetch.electron.test.ts'
]
const WINDOWS_PACKAGE_TESTS = [
...LINUX_PACKAGE_TESTS,
'config/scripts/rebuild-native-deps.test.mjs',
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
'src/shared/child-process/windows-command-line.win32.test.ts',
'src/main/agent-hooks/windows-hook-payload-delivery.test.ts',
'src/main/windows/windows-pty-job.win32.test.ts',
'src/main/windows/windows-host-job.win32.test.ts',
'src/main/wsl/wsl-runner.test.ts',
'src/main/wsl/wsl-guest-environment.test.ts',
'src/main/wsl/wsl-invocation-boundary.test.ts',
'src/main/wsl/wsl-executable-path.win32.test.ts',
'src/main/wsl/wsl-w1-w3-contract.test.ts',
'src/shared/source-scan/source-tree-scan.test.ts',
'src/main/cli/wsl-cli-powershell-boundary.test.ts',
'src/main/cursor/hook-service.test.ts',
'src/main/orca-profiles/profile-index-store.test.ts',
'src/main/runtime/repo-worktree-admin-fingerprint.test.ts',
'src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts',
'src/shared/secure-file-fsync-flags.test.ts',
'src/main/ipc/pty-codex-account-attribution.test.ts',
'src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts'
]
const DESKTOP_IRRELEVANT_PREFIXES = [
'mobile/',
'.github/workflows/mobile.yml',
'.github/workflows/mobile-ios-release.yml',
'.github/workflows/mobile-android-release.yml'
]
export function isDocsOnlyPath(file) {
if (DOCS_ONLY_FILES.has(file)) {
return true
@@ -32,10 +222,91 @@ export function shouldRunPrChecks(changedFiles) {
if (changedFiles.length === 0) {
return true
}
return changedFiles.some((file) => !isDocsOnlyPath(file))
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
}
export function classifyPrJobs(changedFiles) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
const forceAll = emptyDiff || changedFiles.some(isGlobalForcePath)
const jobs = Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
])
)
return {
should_run: shouldRun,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
...jobs
}
}
function jobDetector(job) {
switch (job) {
case 'git_compatibility':
return (files) => files.some((file) => matchesPrefix(file, GIT_COMPAT_PREFIXES))
case 'xterm_patch_sync':
return (files) => files.some((file) => matchesPrefix(file, XTERM_PREFIXES))
case 'shell_contracts':
return (files) => files.some((file) => matchesPrefix(file, SHELL_PREFIXES))
case 'orcad_browser':
return (files) => files.some((file) => matchesPrefix(file, ORCAD_BROWSER_PREFIXES))
case 'cross-version-wire':
return (files) => files.some((file) => matchesPrefix(file, CROSS_VERSION_WIRE_PREFIXES))
case 'managed_hook_node18':
return (files) => files.some((file) => matchesPrefix(file, MANAGED_HOOK_PREFIXES))
case 'package':
return (files) => files.some(isLinuxPackagePath)
case 'package_windows':
return (files) => files.some(isWindowsPackagePath)
default:
return () => true
}
}
function isLinuxPackagePath(file) {
return LINUX_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, LINUX_PACKAGE_PREFIXES)
}
function isWindowsPackagePath(file) {
return WINDOWS_PACKAGE_TESTS.includes(file) || isProductBundlePath(file, WINDOWS_PACKAGE_PREFIXES)
}
function isProductBundlePath(file, extraPrefixes) {
if (isTestFile(file)) {
return false
}
if (file.startsWith('src/')) {
return true
}
return matchesPrefix(file, extraPrefixes)
}
function isTestFile(file) {
return /\.(?:test|spec)\.(?:js|cjs|mjs|ts|tsx)$/.test(file) || file.includes('/__tests__/')
}
function isDesktopIrrelevantPath(file) {
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
}
function isNativeCacheInputPath(file) {
return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
}
function isGlobalForcePath(file) {
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
}
function matchesPrefix(file, prefixes) {
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix))
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const files = readFileSync(0, 'utf8').split('\n').filter(Boolean)
process.stdout.write(shouldRunPrChecks(files) ? 'true\n' : 'false\n')
const classification = classifyPrJobs(files)
for (const [name, value] of Object.entries(classification)) {
process.stdout.write(`${name}=${value ? 'true' : 'false'}\n`)
}
}
+227 -17
View File
@@ -1,13 +1,18 @@
import { spawnSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { isDocsOnlyPath, shouldRunPrChecks } from './pr-code-change-scope.mjs'
import {
classifyPrJobs,
isDocsOnlyPath,
PR_CHECK_JOBS,
shouldRunPrChecks
} from './pr-code-change-scope.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const gatedIf = "needs.code_paths.outputs.should_run == 'true'"
const expensiveJobs = [
'static_analysis',
'typecheck',
@@ -15,12 +20,34 @@ const expensiveJobs = [
'xterm_patch_sync',
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
]
const ALWAYS_ON = ['static_analysis', 'typecheck', 'test']
function expectedJobs(overrides, { alwaysOn = true } = {}) {
return Object.fromEntries(
PR_CHECK_JOBS.map((job) => [
job,
(alwaysOn && ALWAYS_ON.includes(job)) || Boolean(overrides[job])
])
)
}
function expectClassification(files, overrides) {
const result = classifyPrJobs(files)
const shouldRun = shouldRunPrChecks(files)
expect(result.should_run).toBe(shouldRun)
expect(result).toMatchObject({
should_run: shouldRun,
...expectedJobs(overrides, { alwaysOn: shouldRun })
})
}
describe('docs-only path classification', () => {
it('treats the WeChat README PR files as docs-only', () => {
expect(
@@ -54,9 +81,170 @@ describe('docs-only path classification', () => {
it('runs PR Checks when the diff is empty rather than skipping by accident', () => {
expect(shouldRunPrChecks([])).toBe(true)
})
it('does not start desktop PR Checks for mobile-only diffs', () => {
expect(shouldRunPrChecks(['mobile/src/App.tsx', 'mobile/package.json'])).toBe(false)
})
})
describe('PR Checks docs-only skip wiring', () => {
describe('per-job path classification', () => {
it('runs every expensive job on an empty diff rather than skipping by accident', () => {
const result = classifyPrJobs([])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
})
it('skips every expensive job for docs-only diffs', () => {
expectClassification(['README.md', 'docs/readme/README.zh-CN.md'], {})
})
it('runs packaging and always-on jobs for product source, not git/xterm/shell lanes', () => {
expectClassification(['src/renderer/src/components/tab-bar/TabBar.tsx'], {
package: true,
package_windows: true
})
})
it('runs Git compatibility only when git capability inputs change', () => {
expectClassification(['src/shared/git-capability-cache.ts'], {
git_compatibility: true,
package: true,
package_windows: true
})
expectClassification(['src/shared/git-binary-compatibility.test.ts'], {
git_compatibility: true
})
})
it('runs xterm patch sync only when xterm inputs change', () => {
expectClassification(['config/patches/xterm-upstream.json'], {
xterm_patch_sync: true
})
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
xterm_patch_sync: true
})
})
it('runs native package jobs only for the platform that ships the changed native', () => {
expectClassification(['native/windows-cli-launcher/OrcaCliLauncher.cs'], {
package_windows: true
})
expectClassification(['native/computer-use-linux/runtime.py'], {
package: true
})
expectClassification(['native/computer-use-macos/Package.swift'], {})
})
it('runs shell contracts when live-shell inputs change', () => {
expectClassification(['src/main/daemon/shell-ready.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs shell contracts when wrapper templates or live-shell fixtures change', () => {
expectClassification(['src/main/shell-templates.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
expectClassification(['src/main/shell-startup-launch-intent-fixtures.ts'], {
shell_contracts: true,
package: true,
package_windows: true
})
})
it('runs orcad browser when Chrome launch, session, or tab modules change', () => {
for (const file of [
'src/main/orcad/external-chromium-browser-session.ts',
'src/main/orcad/external-chromium-command-arguments.ts',
'src/main/orcad/external-chromium-tab-registry.ts',
'src/main/orcad/external-chromium-tab-projection.ts'
]) {
expectClassification([file], {
orcad_browser: true,
package: true,
package_windows: true
})
}
expectClassification(['src/main/orcad/orcad-native-preflight.ts'], {
package: true,
package_windows: true
})
})
it('runs cross-version wire checks for every working-tree wire module', () => {
for (const file of [
'src/shared/protocol-version.ts',
'src/shared/terminal-stream-protocol.ts',
'src/main/runtime/rpc/dispatcher.ts',
'src/main/runtime/rpc/methods/browser-tab-create-schema.ts',
'src/main/runtime/rpc/methods/terminal.ts',
'src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts'
]) {
expectClassification([file], {
'cross-version-wire': true,
package: true,
package_windows: true
})
}
expectClassification(
['tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts'],
{ 'cross-version-wire': true }
)
})
it('runs workflow-self-change and lockfile diffs as force-all', () => {
const result = classifyPrJobs(['.github/workflows/pr.yml'])
expect(result.should_run).toBe(true)
for (const job of PR_CHECK_JOBS) {
expect(result[job], job).toBe(true)
}
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
})
it('primes native caches only when their immutable inputs change', () => {
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
for (const file of [
'package.json',
'pnpm-lock.yaml',
'.github/actions/install-node-dependencies/action.yml',
'config/scripts/ensure-native-runtime.mjs',
'config/scripts/rebuild-native-deps.mjs',
'config/patches/node-pty@1.1.0.patch'
]) {
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
}
})
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
})
})
it('emits GitHub output pairs from the shipped CLI', () => {
const result = spawnSync(process.execPath, ['config/scripts/pr-code-change-scope.mjs'], {
cwd: projectDir,
encoding: 'utf8',
input: 'config/patches/xterm-upstream.json\n'
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('should_run=true\n')
expect(result.stdout).toContain('xterm_patch_sync=true\n')
expect(result.stdout).toContain('git_compatibility=false\n')
expect(result.stdout).toContain('package=false\n')
expect(result.stdout).toContain('test=true\n')
})
})
describe('PR Checks skip wiring', () => {
it('classifies the PR range with a tested script and expands renames', () => {
const classify = prWorkflow.jobs.code_paths.steps.find(
(step) => step.name === 'Classify changed paths'
@@ -65,9 +253,12 @@ describe('PR Checks docs-only skip wiring', () => {
expect(classify.run).toContain('--no-renames')
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(prWorkflow.jobs.code_paths.outputs.should_run).toBe(
'${{ steps.filter.outputs.should_run }}'
)
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
`\${{ steps.filter.outputs.${jobName} }}`
)
}
})
it('keeps the cheap root-directory guard on docs-only PRs', () => {
@@ -75,11 +266,26 @@ describe('PR Checks docs-only skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
it('skips expensive jobs unless the detector says the PR has code', () => {
for (const jobName of expensiveJobs) {
it('gates each expensive job on its classifier and cache prerequisite', () => {
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
expect(prWorkflow.jobs[jobName].if, jobName).toBe(gatedIf)
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
`needs.code_paths.outputs.${jobName} == 'true'`
)
}
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
expect(prWorkflow.jobs.test_native_cache.if).toBe(
"needs.code_paths.outputs.native_cache_changed == 'true'"
)
expect(prWorkflow.jobs.test_native_cache.strategy).toBeUndefined()
const primerInstall = prWorkflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(primerInstall.with['node-version']).toBe('24')
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
@@ -89,19 +295,23 @@ describe('PR Checks docs-only skip wiring', () => {
)
})
it('lets verify pass when expensive jobs are skipped for docs-only PRs', () => {
it('lets verify pass skipped jobs the classifier turned off', () => {
const verifyStep = prWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
expect(prWorkflow.jobs.verify.needs[0]).toBe('code_paths')
expect(verifyStep.env.SHOULD_RUN).toBe('${{ needs.code_paths.outputs.should_run }}')
expect(verifyStep.run).toContain('"$SHOULD_RUN" != "true"')
const docsOnlyBranch = verifyStep.run.slice(
0,
verifyStep.run.indexOf('# Require success when the PR has code-relevant changes')
)
expect(docsOnlyBranch).toContain('if [ "$result" != "skipped" ]')
expect(docsOnlyBranch).not.toContain('"$result" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
expect(verifyStep.run).toContain('# Require success when the PR has code-relevant changes')
expect(verifyStep.run).toContain('expected skipped')
expect(verifyStep.run).toContain('expected success')
for (const job of prWorkflow.jobs.verify.needs) {
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
const envVar = `${job.replaceAll('-', '_').toUpperCase()}_SHOULD_RUN`
expect(verifyStep.env[envVar]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
expect(verifyStep.run).toContain(`"$${envVar}"`)
}
})
})
+177 -8
View File
@@ -1,9 +1,14 @@
import { existsSync, readFileSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parse as parseJsonc } from 'jsonc-parser'
import { describe, expect, it } from 'vitest'
import { parse as parseYaml } from 'yaml'
import { PR_E2E_SOURCE_ROUTES, selectPrE2eSpecs } from './pr-e2e-source-routing.mjs'
import {
hasSshSourceChange,
PR_E2E_SOURCE_ROUTES,
selectPrE2eSpecs,
SSH_SOURCE_ROUTE_IDS
} from './pr-e2e-source-routing.mjs'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parseYaml(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
@@ -100,17 +105,19 @@ describe('PR E2E gate contract', () => {
// job without adding it to the strict loop fails here instead of silently
// leaving that job unenforced. This is what caught GIT_COMPATIBILITY and
// SHELL_CONTRACTS being absent from an earlier hardcoded list.
// Why lastIndexOf: the docs-only branch has its own loop that allows skipped.
const successMarker = '# Require success when the PR has code-relevant changes'
const successLoop = verifyStep.run.slice(
verifyStep.run.indexOf(successMarker),
verifyStep.run.lastIndexOf('done')
)
const successLoop = verifyStep.run.slice(verifyStep.run.indexOf(successMarker))
expect(successLoop.length).toBeGreaterThan(0)
expect(verifyStep.run).toContain('"$CODE_PATHS" != "success"')
expect(verifyStep.run).toContain('"$ROOT_DIRECTORY_GUARD" != "success"')
for (const job of prWorkflow.jobs.verify.needs) {
const envVar = job.toUpperCase()
const envVar = job.replaceAll('-', '_').toUpperCase()
expect(verifyStep.env[envVar]).toBe(`\${{ needs.${job}.result }}`)
if (job === 'code_paths' || job === 'root_directory_guard') {
continue
}
expect(successLoop).toContain(`"$${envVar}"`)
expect(verifyStep.env[`${envVar}_SHOULD_RUN`]).toBe(`\${{ needs.code_paths.outputs.${job} }}`)
}
})
@@ -157,6 +164,18 @@ describe('PR E2E gate contract', () => {
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('reuses the composite install action instead of duplicating pnpm setup', () => {
const installFor = (jobName) =>
e2eWorkflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(installFor('build').with['native-runtime']).toBe('node')
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(installFor(jobName).with['native-runtime'], jobName).toBe('electron')
}
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
@@ -195,6 +214,8 @@ describe('PR E2E gate contract', () => {
'src/main/providers/ssh-',
'src/main/ipc/pty',
'src/relay/',
'src/shared/ssh-',
'src/renderer/src/store/slices/direct-ssh-',
'src/renderer/src/components/terminal-pane/remote-runtime-'
]
for (const authority of sshSourceAuthorities) {
@@ -203,10 +224,24 @@ describe('PR E2E gate contract', () => {
)
}
// Why named files rather than prefixes: these seams are single modules, and a prefix
// here would route their unrelated neighbours.
for (const file of [
'src/main/runtime/public-ssh-state.ts',
'src/renderer/src/startup/ssh-startup-reconnect.ts',
'src/renderer/src/store/slices/ssh.ts'
]) {
expect(selectPrE2eSpecs([file]), file).toContain(
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts'
)
}
const mappedSpecs = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
]
@@ -235,6 +270,140 @@ describe('PR E2E gate contract', () => {
expect(changedInstall.run).toContain('openssh-client')
})
it('routes direct-SSH workspace and tab restore from its unnamed source seams', () => {
// Why by name: none of these carry "ssh", so the SSH authorities above never reach them
// — a closed-tab tombstone and a dropped default-tabs marker both shipped through it.
for (const file of [
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts',
'src/renderer/src/lib/worktree-initial-terminal-seeding.ts',
'src/renderer/src/lib/worktree-default-terminal-tabs.ts',
'src/shared/remote-workspace-session-projection.ts',
'src/renderer/src/components/terminal/initial-terminal.ts'
]) {
const specs = selectPrE2eSpecs([file])
expect(specs, file).toContain('tests/e2e/ssh-cold-activation-restore.spec.ts')
expect(specs, file).toContain('tests/e2e/ssh-reconnect-tab-destruction.spec.ts')
}
expect(
selectPrE2eSpecs(['src/renderer/src/hooks/remote-workspace-session-merge.test.ts'])
).toEqual([])
})
it('triggers the Docker-SSH lane from SSH source, not from a spec name', () => {
// The behavioural half of the invariant, and the part that actually matters: an SSH source
// edit is recognised as one, through the same routes that select the specs.
for (const file of [
'src/main/ssh/connection.ts',
'src/relay/pty-handler.ts',
'src/renderer/src/store/slices/direct-ssh-pane-retry-ledger.ts',
'src/renderer/src/hooks/remote-workspace-session-merge.ts',
'src/main/ipc/remote-workspace-snapshot-normalization.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(true)
}
for (const file of [
'src/main/git/git-status.ts',
'src/renderer/src/components/tab-bar/BrowserTab.tsx',
'src/main/ssh/connection.test.ts'
]) {
expect(hasSshSourceChange([file]), file).toBe(false)
}
// Why: the signal must stay derived from the routes. A route id that no longer exists would
// silently narrow it to nothing.
for (const id of SSH_SOURCE_ROUTE_IDS) {
expect(
PR_E2E_SOURCE_ROUTES.map((route) => route.id),
id
).toContain(id)
}
// Why text and not structure: a job `if:` is only ever available as a string. The strongest
// available assertion is that the source signal is its own disjunct, so the lane no longer
// depends on a spec name surviving in a route's spec list.
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.ssh_source_changed == 'true' ||")
expect(e2eWorkflow.on.workflow_call.inputs.ssh_source_changed.type).toBe('string')
expect(prWorkflow.jobs['e2e-paths'].outputs.ssh_source_changed).toBe(
'${{ steps.filter.outputs.ssh_source_changed }}'
)
expect(prWorkflow.jobs.e2e.with.ssh_source_changed).toBe(
'${{ needs.e2e-paths.outputs.ssh_source_changed }}'
)
expect(filterStep.run).toContain('pr-e2e-source-routing.mjs --ssh-source')
expect(filterStep.run).toContain('ssh_source_changed=$SSH_SOURCE_CHANGED')
})
it('gives every Docker-gated SSH spec a lane that runs it', () => {
// Why this shape: the sharded lanes set no ORCA_E2E_SSH_DOCKER, so a Docker-gated spec
// that no runner names runs nowhere and still reports green — the silent skip this file
// exists to prevent. Asserting reachability rather than a literal keeps that true when
// the lanes move.
// Why these two are exempt: each needs something CI cannot give it, recorded in
// run-ssh-docker-e2e.mjs so the gap stays legible rather than looking like coverage.
const unreachableSpecs = new Set([
'tests/e2e/ssh-docker-relay-perf.spec.ts',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts'
])
// Why comments are stripped: this file's own runner lists the two exempt specs by name in a
// prose comment. A substring scan over raw text would count any spec merely *discussed* in a
// runner as claimed by it -- the silent skip this assertion exists to catch, re-entering
// through the documentation.
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
const laneRunners = [
'run-ssh-docker-e2e.mjs',
'run-ssh-docker-watcher-isolation-e2e.mjs',
'run-ssh-docker-terminal-parking-e2e.mjs'
].map((file) => stripComments(readFileSync(join(projectDir, 'config/scripts', file), 'utf8')))
// Why a comparison and not the bare name: preview and demo specs cite the flag in a
// "how to run me" comment without gating on it. Why a regex rather than one literal: an
// equally-valid spelling (double quotes, or a `!==` guard) would escape a fixed-string scan
// and the spec would silently leave the contract.
const dockerGateExpression = /ORCA_E2E_SSH_DOCKER\s*[!=]==\s*['"]1['"]/
const dockerGatedSpecs = readdirSync(join(projectDir, 'tests/e2e'))
.filter((file) => file.endsWith('.spec.ts'))
.map((file) => `tests/e2e/${file}`)
.filter((spec) => dockerGateExpression.test(readFileSync(join(projectDir, spec), 'utf8')))
expect(dockerGatedSpecs.length).toBeGreaterThan(0)
const unclaimed = dockerGatedSpecs.filter(
(spec) => !unreachableSpecs.has(spec) && !laneRunners.some((runner) => runner.includes(spec))
)
expect(
unclaimed,
`Docker-gated specs claimed by no lane runner: ${unclaimed.join(', ')}`
).toEqual([])
// Why: an exemption that outlives its spec would quietly excuse a real gap.
for (const spec of unreachableSpecs) {
expect(dockerGatedSpecs, spec).toContain(spec)
// Why also assert absence from every runner: `unreachableSpecs` short-circuits the
// unclaimed check above, so a spec could be documented as exempt while a runner still
// invokes it -- an exemption that reads as coverage removal but changes nothing, and a
// lane that stays red for a reason the file says it excluded.
for (const runner of laneRunners) {
expect(runner.includes(spec), `${spec} is exempt but still invoked by a lane runner`).toBe(
false
)
}
}
const laneStep = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].steps.find(
(step) => step.name === 'Run remaining Docker SSH E2E'
)
expect(laneStep.run).toContain('test:e2e:ssh-docker')
// Why: the added serial tests, several budgeting 4-10 minutes each, do not fit the old 35.
expect(
e2eWorkflow.jobs['ssh-docker-watcher-isolation']['timeout-minutes']
).toBeGreaterThanOrEqual(60)
})
it('scopes the VM rollback oracle to the PR range and recipe schema authorities', () => {
expect(rollbackStep.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(rollbackStep.run).toContain('src/shared/ephemeral-vm-recipes.ts')
+39 -3
View File
@@ -18,12 +18,32 @@ export const PR_E2E_SOURCE_ROUTES = [
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts'
],
// Why the store/startup/shared additions: the SSH-named authorities stop at the main
// process and the pane component, but the reconnect ledgers and retained-payload
// admission that decide whether a pane rebinds live in the renderer store.
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/relay\/|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
/^(?:src\/main\/ssh\/|src\/main\/providers\/ssh-|src\/main\/ipc\/(?:ssh-|pty)|src\/main\/runtime\/(?:public-ssh-state|ssh-file-explorer-chunk-read)\.ts|src\/relay\/|src\/shared\/(?:ssh-|skill-ssh-relay-contract)|src\/renderer\/src\/startup\/(?:ssh-startup-reconnect|startup-ssh-connection-restore)\.ts|src\/renderer\/src\/store\/slices\/(?:ssh|direct-ssh-)|src\/renderer\/src\/components\/terminal-pane\/(?:pty-|ssh-|remote-runtime-|terminal-parked-pty))/.test(
file
)
},
{
// Why a sibling route rather than more paths on ssh-terminal-source: these modules carry
// no "ssh" in their names, and only the two restore specs gate them. Folding them in
// would run the whole SSH terminal list for a tab-tombstone edit.
id: 'ssh-workspace-session-restore',
specs: [
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts'
],
matches: (file) =>
isProductSource(file) &&
/^(?:src\/main\/ipc\/remote-workspace|src\/shared\/remote-workspace-|src\/renderer\/src\/hooks\/remote-workspace-|src\/renderer\/src\/lib\/worktree-(?:initial-terminal-seeding|default-terminal-tabs)\.ts|src\/renderer\/src\/components\/terminal\/initial-terminal)/.test(
file
)
},
@@ -131,6 +151,18 @@ export function selectPrE2eSpecs(changedPaths, reportRoute = () => undefined) {
return [...specs].sort((left, right) => left.localeCompare(right))
}
/** Routes whose authorities are SSH execution source, and so require the Docker-SSH lane. */
export const SSH_SOURCE_ROUTE_IDS = ['ssh-terminal-source', 'ssh-workspace-session-restore']
// Why derive this from the routes instead of a second path list: the Docker-SSH lane used to
// trigger only because one route happened to list a startup-readiness spec, so pruning that
// spec would have silently retired the lane. Two lists that must agree is how that drifted.
export function hasSshSourceChange(changedPaths) {
return PR_E2E_SOURCE_ROUTES.filter((route) => SSH_SOURCE_ROUTE_IDS.includes(route.id)).some(
(route) => changedPaths.some(route.matches)
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
let input = ''
process.stdin.setEncoding('utf8')
@@ -138,6 +170,10 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
input += chunk
}
const changedPaths = input.split(/\r?\n/).filter(Boolean)
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
if (process.argv.includes('--ssh-source')) {
process.stdout.write(`${hasSshSourceChange(changedPaths)}\n`)
} else {
const specs = selectPrE2eSpecs(changedPaths, (message) => console.error(message))
process.stdout.write(`${JSON.stringify(specs)}\n`)
}
}
+71 -21
View File
@@ -3,6 +3,8 @@ import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const unitTestWorkflow = parse(readFileSync('.github/workflows/unit-tests.yml', 'utf8'))
const nodeNextWorkflow = parse(readFileSync('.github/workflows/node-next-compat.yml', 'utf8'))
const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
@@ -47,22 +49,35 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
it('runs Node 24 on PRs and the same eight-shard suite on Node 26 daily', () => {
const sharedTest = unitTestWorkflow.jobs.test
const testStep = sharedTest.steps.find((step) => step.name === 'Test shard')
const installStep = sharedTest.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(workflow.jobs.test.with.node_versions)).toEqual(['24'])
expect(nodeNextWorkflow.jobs.test.uses).toBe('./.github/workflows/unit-tests.yml')
expect(JSON.parse(nodeNextWorkflow.jobs.test.with.node_versions)).toEqual(['26'])
expect(nodeNextWorkflow.on.schedule).toHaveLength(1)
expect(nodeNextWorkflow.on.workflow_dispatch).toBeNull()
expect(sharedTest.strategy.matrix.node).toBe('${{ fromJSON(inputs.node_versions) }}')
expect(sharedTest.strategy.matrix.shard).toEqual(
Array.from({ length: 8 }, (_, index) => index + 1)
)
expect(sharedTest.strategy.matrix.shard_total).toEqual([8])
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
expect(primerInstall.with['native-runtime']).toBe('node')
expect(primerInstall.with['node-version']).toBe('24')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -208,6 +223,8 @@ describe('PR workflow parallelism', () => {
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -232,22 +249,24 @@ describe('PR workflow parallelism', () => {
workflow.jobs[jobName].steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const sharedTestInstall = unitTestWorkflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
for (const jobName of [
'static_analysis',
'typecheck',
'git_compatibility',
'xterm_patch_sync'
]) {
for (const jobName of ['typecheck', 'git_compatibility', 'xterm_patch_sync']) {
expect(installFor(jobName).with, jobName).toBeUndefined()
}
expect(installFor('static_analysis').with['native-runtime']).toBe('node')
expect(installFor('shell_contracts').with['native-runtime']).toBe('node')
expect(installFor('test').with['native-runtime']).toBe('node')
expect(sharedTestInstall.with['native-runtime']).toBe('node')
expect(installFor('package').with['native-runtime']).toBe('electron')
expect(installFor('package_windows').with['native-runtime']).toBe('node')
expect(installFor('package_windows').with['persist-native-cache']).toBe('false')
expect(dependencyAction.inputs['persist-native-cache'].default).toBe('true')
expect(
dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if
).toBe("inputs.native-runtime != 'none'")
).toBe("runner.os == 'Linux' && inputs.native-runtime != 'none'")
const dependencyInstall = dependencyAction.runs.steps.find(
(step) => step.name === 'Install dependencies'
)
@@ -295,14 +314,42 @@ describe('PR workflow parallelism', () => {
// overwritten and one after the rebuild would never save a hit.
expect(installIndex).toBeLessThan(cacheIndex)
expect(cacheIndex).toBeLessThan(prepareIndex)
expect(steps[cacheIndex].if).toBe("inputs.native-runtime != 'none'")
expect(steps[cacheIndex].if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache != 'false'"
)
const restoreOnly = steps.find(
(step) => step.name === 'Restore compiled native modules without saving'
)
expect(restoreOnly.if).toBe(
"inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'"
)
expect(restoreOnly.uses).toBe('actions/cache/restore@v5')
// Native artifacts are ABI-bound: a key missing either dimension serves a build
// that cannot load, and ensure-native-runtime would recompile it anyway.
expect(steps[cacheIndex].with.key).toContain('${{ inputs.native-runtime }}')
expect(steps[cacheIndex].with.key).toContain('steps.requested-node.outputs.node-version')
expect(steps[cacheIndex].with.key).toContain('config/patches/node-pty@1.1.0.patch')
// No restore-keys: a partial-match key is exactly the ABI-mismatched build above.
expect(steps[cacheIndex].with['restore-keys']).toBeUndefined()
for (const cacheStep of [steps[cacheIndex], restoreOnly]) {
expect(cacheStep.with.key).toContain('${{ inputs.native-runtime }}')
expect(cacheStep.with.key).toContain('${{ runner.os }}')
expect(cacheStep.with.key).toContain('${{ runner.arch }}')
expect(cacheStep.with.key).toContain('steps.requested-node.outputs.node-version')
expect(cacheStep.with.key).toContain('steps.native-cache-scope.outputs.scope')
expect(cacheStep.with.key).toContain('config/patches/node-pty@1.1.0.patch')
expect(cacheStep.with.key).toContain(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
expect(cacheStep.with.path).toContain('windows-native-registry@')
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
expect(cacheStep.with['restore-keys']).toBeUndefined()
}
const cacheScope = steps.find((step) => step.name === 'Resolve native cache scope')
expect(cacheScope.if).toBe("inputs.native-runtime != 'none'")
expect(cacheScope.run).toContain('/etc/os-release')
expect(dependencyAction.outputs['native-cache-scope'].value).toBe(
'${{ steps.native-cache-scope.outputs.scope }}'
)
})
it('reuses TypeScript incremental state across typecheck runs', () => {
@@ -346,6 +393,7 @@ describe('PR workflow parallelism', () => {
'shell_contracts',
'test',
'orcad_browser',
'cross-version-wire',
'managed_hook_node18',
'package',
'package_windows'
@@ -359,5 +407,7 @@ describe('PR workflow parallelism', () => {
// ORCA_BROWSER_EXECUTABLE, so it only guards anything if verify actually reads it.
expect(verifyStep.env.ORCAD_BROWSER).toBe('${{ needs.orcad_browser.result }}')
expect(verifyStep.run).toContain('"$ORCAD_BROWSER"')
expect(verifyStep.env.CROSS_VERSION_WIRE).toBe('${{ needs.cross-version-wire.result }}')
expect(verifyStep.run).toContain('"$CROSS_VERSION_WIRE"')
})
})
+16 -10
View File
@@ -401,14 +401,23 @@ function getPatchedNodePtyRebuildReason() {
return null
}
// Why: Orca patches node-pty's native Unix spawn path; upstream prebuilds can
// load successfully in Electron while missing the patched fd/error handling.
// Why: Orca patches node-pty's native Unix spawn path and Windows job-object
// exports; upstream prebuilds can load while missing those patches.
const nodePtyDir = resolve(projectDir, 'node_modules', 'node-pty')
const artifactPaths = [resolve(nodePtyDir, 'build', 'Release', 'pty.node')]
// Why: node-pty only builds spawn-helper on macOS; Linux builds only pty.node.
if (process.platform === 'darwin') {
artifactPaths.push(resolve(nodePtyDir, 'build', 'Release', 'spawn-helper'))
}
const artifactPaths =
rebuildPlatform === 'win32'
? [
resolve(nodePtyDir, 'build', 'Release', 'conpty.node'),
...NODE_PTY_CONPTY_RUNTIME_FILES.map((filename) =>
resolve(nodePtyDir, 'build', 'Release', 'conpty', filename)
)
]
: [
resolve(nodePtyDir, 'build', 'Release', 'pty.node'),
...(osPlatform() === 'darwin'
? [resolve(nodePtyDir, 'build', 'Release', 'spawn-helper')]
: [])
]
const missingArtifact = artifactPaths.find((artifactPath) => !existsSync(artifactPath))
if (!missingArtifact) {
@@ -422,9 +431,6 @@ function requiresPatchedNodePtySourceBuild() {
if (!onlyModules.includes('node-pty')) {
return false
}
if (rebuildPlatform === 'win32') {
return false
}
if (rebuildPlatform !== osPlatform() || rebuildArch !== process.arch) {
return false
}
@@ -586,6 +586,13 @@ function writeNodePtyPatchFile(projectDir) {
function writePatchedNodePtyBuildArtifacts(projectDir) {
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
mkdirSync(buildDir, { recursive: true })
if (process.platform === 'win32') {
writeFileSync(join(buildDir, 'conpty.node'), '')
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
return
}
writeFileSync(join(buildDir, 'pty.node'), '')
if (process.platform === 'darwin') {
writeFileSync(join(buildDir, 'spawn-helper'), '')
+95
View File
@@ -0,0 +1,95 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why one explicit list: these specs self-skip without ORCA_E2E_SSH_DOCKER and no sharded lane
// sets it, so a spec in no runner runs nowhere. The gate contract proves every flag-reading
// spec is claimed here, by the watcher-isolation or parking runner, or by a listed exclusion.
//
// Deliberately absent, and therefore still covered by no CI trigger:
// ssh-docker-relay-perf.spec.ts — wall-clock latency thresholds; flaky budgets here would
// cost the lane its credibility. NOTE: a runner script test:e2e:ssh-docker-perf exists in
// package.json but NO workflow invokes it, so this spec currently runs in no CI lane at
// all. Recorded as a real gap, not as coverage living somewhere else.
// ssh-codex-display-artifacts-repro.spec.ts — installs a real remote codex binary that CI
// runners do not have (observed as `spawn codex ENOENT`). Runs in no CI lane at all.
// ssh-docker-bulk-open-freeze-repro.spec.ts — two reasons, both disqualifying:
// (a) it is a perf oracle, not a correctness one: SOFT_FREEZE_LAG_MS=2500 /
// HARD_FREEZE_LAG_MS=5000 measured by a renderer lag probe under a deliberate
// 5-pane output flood on a 420s budget. Same rule as ssh-docker-relay-perf above.
// (b) it is ROTTED: four call sites are out of date against terminal.ts's current
// helpers — execInTerminal gained a ptyId parameter and splitActiveTerminalPane
// gained a direction, so it cannot compile, let alone pass. Repairing it needs two
// semantic decisions (which ptyId to capture, which split direction) that change
// what the repro measures. Tracked in stablyai/orca#16764.
//
// Why both projects: ssh-port-forward-lifecycle is @headful, which the headless project
// grep-inverts away.
//
// Known gaps in SSH e2e coverage, recorded here because nothing else names them:
// - The job that runs this is still called `ssh-docker-watcher-isolation`, though watcher
// isolation is now one spec of many. Renaming it changes the GitHub check name and can
// break required-check config, so the name understates the job on purpose.
// - E2E does not gate merges: `verify.needs` in pr.yml omits `e2e` while the suite is red on
// main. Nothing in this lane blocks a PR yet. pr.yml's Require-successful-checks comment
// has the exact wiring to flip it, and the gate contract asserts the current state.
// - Five specs and one unit test are gated on env vars no workflow sets, so they run nowhere
// and are not Docker-gated, which puts them outside this file's contract:
// local-ssh-browser-routing (ORCA_E2E_LOCAL_SSH_BROWSER)
// ssh-client-hosted-browser-drop-reconnect (ORCA_E2E_SSH_CLIENT_HOSTED_BROWSER)
// nested-runtime-ssh-lifecycle, nested-runtime-ssh-routing (ORCA_E2E_NESTED_RUNTIME_SSH)
// ssh-localhost (ORCA_E2E_SSH_LOCALHOST)
// ssh-browser-network-execution-route.docker.unit.test.ts (ORCA_RUN_DOCKER_SSH_BROWSER_E2E)
// Runner scripts for the first four sit unused in package.json; no workflow calls them.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/pty-input-write-queue-ssh.spec.ts',
'tests/e2e/ssh-ai-vault-session-history.spec.ts',
'tests/e2e/ssh-cold-activation-restore.spec.ts',
'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts',
'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts',
'tests/e2e/ssh-external-image-preview.spec.ts',
'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts',
'tests/e2e/ssh-pi-compatible-agent-title.spec.ts',
'tests/e2e/ssh-port-forward-lifecycle.spec.ts',
'tests/e2e/ssh-reconnect-tab-destruction.spec.ts',
'tests/e2e/ssh-restart-tab-accumulation.spec.ts',
'tests/e2e/ssh-skill-installation.spec.ts',
'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--project',
'electron-headful',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -14,6 +14,7 @@ describe('packaged skills CLI PR gates', () => {
expect(job['runs-on']).toBe('windows-2022')
expect(buildStep.run).toBe('pnpm run build:release:parallel')
expect(buildStep.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepareStep.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.run).toContain('electron-builder')
expect(packageStep.run).toContain('--dir')
@@ -9,26 +9,30 @@ const PATCH = readFileSync(
'utf8'
)
const PACKAGE_DIR = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
const ABSOLUTE_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
const RESOLVED_GYP = "require.resolve('node-addon-api/node_addon_api.gyp')"
describe('windows-process-tree node-addon-api gyp path', () => {
it('keeps the gyp project path absolute so pnpm Windows source builds find it', () => {
expect(PATCH).toContain(
`+ "<!(node -p \\"require.resolve('node-addon-api/node_addon_api.gyp')\\"):node_addon_api_except"`
it('stages headers without a pnpm-sensitive gyp dependency', () => {
expect(PATCH).not.toContain('+ "../../node-addon-api')
expect(PATCH).toContain('+ "include_dirs": ["deps/node-addon-api"],')
expect(PATCH).toContain('+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],')
const buildScript = readFileSync(
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
const bindingGyp = readFileSync(join(PACKAGE_DIR, 'binding.gyp'), 'utf8')
expect(bindingGyp).toContain(ABSOLUTE_GYP)
expect(bindingGyp).not.toContain("require('node-addon-api').targets")
expect(
readFileSync(
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
).toContain(ABSOLUTE_GYP)
expect(buildScript).toContain(
"for (const header of ['napi.h', 'napi-inl.h', 'napi-inl.deprecated.h'])"
)
expect(buildScript).toContain("import { createRequire } from 'node:module'")
expect(buildScript).toContain("import { dirname, join, resolve } from 'node:path'")
expect(buildScript).toContain(
"createRequire(join(PACKAGE_DIR, 'package.json')).resolve('node-addon-api/package.json')"
)
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
const resolved = execFileSync(process.execPath, ['-p', ABSOLUTE_GYP], {
const resolved = execFileSync(process.execPath, ['-p', RESOLVED_GYP], {
cwd: PACKAGE_DIR,
encoding: 'utf8'
}).trim()
@@ -28,8 +28,14 @@ describe('packaged Windows PTY native capability routing', () => {
it('keeps patched source rebuild, release build, runtime reuse, and aggregate routing intact', () => {
const job = workflow.jobs.package_windows
const sourceRebuild = job.steps.find(
(step) => step.name === 'Rebuild node-pty from patched source'
const install = job.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
const nodeCacheSave = job.steps.find(
(step) => step.name === 'Save compiled Node native modules'
)
const electronCache = job.steps.find(
(step) => step.name === 'Restore compiled Electron native modules'
)
const build = job.steps.find((step) => step.name === 'Build package inputs')
const prepare = job.steps.find((step) => step.name === 'Prepare Electron native runtime')
@@ -37,10 +43,22 @@ describe('packaged Windows PTY native capability routing', () => {
const verify = workflow.jobs.verify.steps.find(
(step) => step.name === 'Require successful checks'
)
const ensureNativeRuntime = readFileSync('config/scripts/ensure-native-runtime.mjs', 'utf8')
expect(sourceRebuild.env.npm_config_build_from_source).toBe('true')
expect(sourceRebuild.run).toBe('pnpm rebuild node-pty')
expect(install.with['native-runtime']).toBe('node')
expect(install.with['persist-native-cache']).toBe('false')
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
expect(nodeCacheSave.with.key).toContain('-node-node')
expect(electronCache.with.key).toContain('-electron-node')
for (const cache of [nodeCacheSave, electronCache]) {
expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
}
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
expect(build.env.ORCA_REUSE_WINDOWS_CLI_LAUNCHER).toBe('1')
expect(prepare.run).toBe('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1')
expect(workflow.jobs.verify.needs).toContain('package_windows')
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 30m">
<title>downloads: 30m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 31m">
<title>downloads: 31m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">30m</text>
<text x="90" y="14">30m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">31m</text>
<text x="90" y="14">31m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+2 -2
View File
@@ -36,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
+2 -2
View File
@@ -40,7 +40,7 @@
Surveillez et pilotez vos agents depuis votre téléphone — soyez notifié quand un agent termine, et envoyez des instructions de suivi où que vous soyez.
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
Associez-la à l'app de bureau pour surveiller et piloter vos agents depuis votre téléphone.
- **iOS :** [Télécharger sur l'App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [rejoindre TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android :** [Télécharger l'APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
- **Android :** [Télécharger l'APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.44 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
- **Android:** [APK 0.0.46 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [설치 가이드](https://www.onorca.dev/docs/android-apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.44](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
- **Android:** [Baixar APK 0.0.46](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.44/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.46/app-release.apk)
---
@@ -326,8 +326,6 @@ the Windows CI job rebuilds from source before running the win32 suites.
it is true before asserting anything else, so an unpatched binary fails loudly
instead of passing every case vacuously. That guard is what caught this.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` still
exempts win32, on the premise that the patch is Unix-only. That premise is now
false, but lifting the exemption also needs `pnpm rebuild` to force a source
build — otherwise the assertion fires and the remedy does not fix it. Left as a
follow-up rather than changed blind.
`requiresPatchedNodePtySourceBuild()` in `ensure-native-runtime.mjs` now covers
win32 as well, and `pnpm rebuild node-pty` sets `npm_config_build_from_source`
so the patched source build actually replaces the upstream prebuild.
+1
View File
@@ -209,6 +209,7 @@ export const electronViteConfig: UserConfig = {
index: resolve('src/main/index.ts'),
// Why: sandboxed webview preloads cannot load Rollup helper chunks.
'browser-window-close-preload': resolve('src/preload/browser-window-close.ts'),
'doc-preview-link-preload': resolve('src/preload/doc-preview-link.ts'),
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'),
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
-36
View File
@@ -75,54 +75,18 @@
"max-lines": ["error", { "max": 1074, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/mobile-rich-markdown-editor-html.ts"],
"rules": {
"max-lines": ["error", { "max": 648, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/CustomKeyModal.tsx"],
"rules": {
"max-lines": ["error", { "max": 645, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/pair-scan.tsx"],
"rules": {
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/terminal/terminal-accessory-keys.ts"],
"rules": {
"max-lines": ["error", { "max": 388, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-worktree-startup-stream.ts"],
"rules": {
"max-lines": ["error", { "max": 326, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/repro-terminal-colors.ts"],
"rules": {
"max-lines": ["error", { "max": 319, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
+209 -23
View File
@@ -1,20 +1,35 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useLocalSearchParams, useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
import { ChevronLeft, Copy, Check, Send } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import { useHostClient } from '../src/transport/client-context'
import { connectionLogStore } from '../src/transport/persisted-connection-log-store'
import { useHostClient, useRpcClientContext } from '../src/transport/client-context'
import {
useLastConnectedAt,
useConnectionPathStatus,
useReconnectAttempt
} from '../src/transport/client-context-connection-metrics'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from '../src/diagnostics/connection-diagnostics-analysis'
import { submitConnectionDiagnostics } from '../src/diagnostics/connection-diagnostics-submission'
import {
readHydratedConnectionLog,
readConnectionDiagnosticsSnapshot,
resolveDiagnosticsHostId,
getDiagnosticsSubmissionState,
updateDiagnosticsSubmissionState,
type DiagnosticsSubmissionStates
} from '../src/diagnostics/connection-diagnostics-screen-data'
import { useHostStatusGates } from '../src/transport/host-status-gates'
import { loadHostAppVersion } from '../src/transport/host-app-version-store'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
@@ -25,11 +40,19 @@ const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
const clientContext = useRpcClientContext()
const router = useRouter()
const params = useLocalSearchParams<{ hostId?: string }>()
const insets = useSafeAreaInsets()
const routeKey = useMemo(() => ({}), [params.hostId])
const [hosts, setHosts] = useState<HostProfile[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [manualSelection, setManualSelection] = useState<{
hostId: string
requestedHostId: string | undefined
routeKey: object
} | null>(null)
const [copiedHostId, setCopiedHostId] = useState<string | null>(null)
const [submissionStates, setSubmissionStates] = useState<DiagnosticsSubmissionStates>({})
useEffect(() => {
let stale = false
@@ -38,17 +61,28 @@ export default function ConnectionLogScreen() {
return
}
setHosts(loaded)
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
const selectedId = resolveDiagnosticsHostId(hosts, params.hostId, manualSelection, routeKey)
const selected = hosts.find((h) => h.id === selectedId) ?? null
const { state } = useHostClient(selected?.id)
const { client, state } = useHostClient(selected?.id)
const { desktopAppVersion: liveDesktopAppVersion } = useHostStatusGates({
hostId: selected?.id,
client,
connState: state
})
const reconnectAttempts = useReconnectAttempt(selected?.id)
const lastConnectedAt = useLastConnectedAt(selected?.id)
const { activePath, pendingPath } = useConnectionPathStatus(selected?.id)
useEffect(() => {
if (selectedId) {
void readHydratedConnectionLog(connectionLogStore, selectedId)
}
}, [selectedId])
const subscribe = useCallback(
(listener: () => void) =>
@@ -60,25 +94,93 @@ export default function ConnectionLogScreen() {
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const diagnosis = selected
? diagnoseConnection({ endpoint: selected.endpoint, state, activePath, pendingPath, entries })
: null
const incidentId = selected
? getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state,
activePath,
pendingPath,
entries
})
: null
const submissionKey = selected && incidentId ? `${selected.id}:${incidentId}` : null
const submissionState = getDiagnosticsSubmissionState(submissionStates, submissionKey)
const copied = copiedHostId === selectedId
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state,
reconnectAttempts,
lastConnectedAt,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
entries
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
await Clipboard.setStringAsync(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
setCopiedHostId(selected.id)
setTimeout(() => setCopiedHostId((hostId) => (hostId === selected.id ? null : hostId)), 2000)
}, [selected, liveDesktopAppVersion, clientContext])
const sendDiagnostics = useCallback(async () => {
if (!selected || !submissionKey || submissionState === 'sending') {
return
}
const startedKey = submissionKey
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, 'sending'))
const appVersion = Constants.expoConfig?.version ?? 'unknown'
const platform = `${Platform.OS} ${Platform.Version ?? ''}`.trim()
const desktopAppVersion = liveDesktopAppVersion ?? (await loadHostAppVersion(selected.id))
const snapshot = await readConnectionDiagnosticsSnapshot(
clientContext,
connectionLogStore,
selected.id
)
const currentIncidentId = getReportableConnectionIncidentId({
endpoint: selected.endpoint,
state: snapshot.state,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath,
entries: snapshot.entries
})
if (`${selected.id}:${currentIncidentId ?? ''}` !== startedKey) {
setSubmissionStates((states) => updateDiagnosticsSubmissionState(states, startedKey, null))
return
}
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state: snapshot.state,
reconnectAttempts: snapshot.reconnectAttempts,
lastConnectedAt: snapshot.lastConnectedAt,
platform,
appVersion,
desktopAppVersion,
entries: snapshot.entries,
activePath: snapshot.activePath,
pendingPath: snapshot.pendingPath
})
const result = await submitConnectionDiagnostics({ report, appVersion, platform })
setSubmissionStates((states) =>
updateDiagnosticsSubmissionState(states, startedKey, result.ok ? 'sent' : 'failed')
)
}, [selected, submissionKey, submissionState, liveDesktopAppVersion, clientContext])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -86,7 +188,7 @@ export default function ConnectionLogScreen() {
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Connection log</Text>
<Text style={styles.heading}>Network diagnostics</Text>
</View>
{hosts.length > 1 && (
@@ -95,7 +197,9 @@ export default function ConnectionLogScreen() {
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => setSelectedId(host.id)}
onPress={() =>
setManualSelection({ hostId: host.id, requestedHostId: params.hostId, routeKey })
}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
@@ -121,14 +225,49 @@ export default function ConnectionLogScreen() {
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy report'}</Text>
</Pressable>
</View>
{diagnosis && (
<View style={styles.diagnosisCard}>
<Text style={styles.diagnosisHeading}>What this suggests</Text>
<Text style={styles.diagnosisText}>{diagnosis.likelyCause}</Text>
<Text style={styles.diagnosisNext}>{diagnosis.nextStep}</Text>
{diagnosis.reportability === 'orca-relay' && (
<>
<Text style={styles.privacyHint}>
Sends a size-limited redacted report including host name, endpoint, versions,
connection state, and events—never terminal contents or credentials.
</Text>
<Pressable
style={styles.sendButton}
onPress={() => void sendDiagnostics()}
disabled={submissionState === 'sending'}
>
{submissionState === 'sent' ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Send size={14} color={colors.textPrimary} />
)}
<Text style={styles.sendButtonText}>
{submissionState === 'sending'
? 'Sending…'
: submissionState === 'sent'
? 'Diagnostics sent'
: submissionState === 'failed'
? 'Retry sending'
: 'Send diagnostics to Orca'}
</Text>
</Pressable>
</>
)}
</View>
)}
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} />
<ConnectionLog entries={[...entries]} title={selected.name} fillAvailableHeight />
) : (
<Text style={styles.emptyText}>
No connection events yet this session. Events appear as the app dials this host.
No connection events yet. Events appear as the app dials this host.
</Text>
)}
</>
@@ -199,6 +338,53 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
color: colors.textSecondary
},
diagnosisCard: {
backgroundColor: colors.bgPanel,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: 10,
padding: spacing.md,
marginBottom: spacing.md
},
diagnosisHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.xs
},
diagnosisText: {
fontSize: typography.metaSize,
color: colors.textPrimary,
lineHeight: 18
},
diagnosisNext: {
fontSize: typography.metaSize,
color: colors.textSecondary,
lineHeight: 18,
marginTop: spacing.xs
},
privacyHint: {
marginTop: spacing.sm,
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
},
sendButton: {
marginTop: spacing.md,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
sendButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
+15 -309
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -63,14 +63,16 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../../../src/components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
@@ -1090,6 +1092,17 @@ export function HostScreen({
/>
)}
{connState !== 'connected' &&
!relayRecovery.pairingRejected &&
reconnectAttempts >= 3 &&
hostId ? (
<HostDiagnosticsLink
onPress={() =>
router.push({ pathname: '/connection-log', params: { hostId: String(hostId) } })
}
/>
) : null}
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
{routeNotice && (
<HostRouteNoticeBanner
@@ -1410,310 +1423,3 @@ export default function HostWorktreeRoute() {
function ListSeparator() {
return <View style={styles.separator} />
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 34,
paddingTop: spacing.xs,
paddingHorizontal: spacing.lg
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
sidebarCollapseButton: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginLeft: spacing.xs
},
hostIdentity: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
minWidth: 0,
marginRight: spacing.md
},
hostNameText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
gap: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbar: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbarRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
embeddedFilterChip: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
embeddedModeButton: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle
},
filterChipActive: {
borderColor: colors.textSecondary,
backgroundColor: colors.bgRaised
},
filterChipText: {
fontSize: 12,
color: colors.textSecondary
},
filterChipTextActive: {
color: colors.textPrimary
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
toolbarSpacer: {
flex: 1
},
floatingWorkspaceHeaderButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
toolbarIconDisabled: {
opacity: 0.6
},
searchToggle: {
padding: spacing.xs
},
searchBar: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize
},
list: {
paddingBottom: spacing.lg
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs
},
sectionIcon: {
marginRight: spacing.xs
},
sectionRepoIcon: {
marginRight: spacing.xs
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
sectionCount: {
fontSize: 11,
color: colors.textMuted,
marginLeft: spacing.xs
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
marginLeft: spacing.lg + 24,
marginRight: spacing.lg
},
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
},
filterSectionLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
filterGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.md
},
filterRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2,
gap: spacing.sm
},
filterRowText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
filterSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
borderRadius: 4
},
confirmContent: {
paddingBottom: spacing.lg
},
confirmTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
confirmMessage: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
confirmButtons: {
flexDirection: 'row',
gap: spacing.sm
},
confirmBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
confirmBtnCancel: {
backgroundColor: colors.bgPanel
},
confirmBtnDestructive: {
backgroundColor: colors.statusRed
},
confirmBtnPressed: {
opacity: 0.7
},
confirmBtnCancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
confirmBtnDestructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
+43 -30
View File
@@ -117,6 +117,10 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender
} from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
async function handleAccessoryKey(
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
targetHandle = activeHandleRef.current,
isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
) {
if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
return false
}
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
if (!isDeliveryTargetCurrent()) {
return false
}
await sendTerminalLiveAccessoryRawBytes({
if (accessoryCommit.kind !== 'allow-raw') {
return accessoryCommit.kind === 'handled'
}
return sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
}
}
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
const accessoryRepeatRef = useRef(
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
)
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
accessoryRepeatRef.current.stop()
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
// Why: a held repeat must not resume through a replacement client or connection.
const targetClient = clientRef.current
const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
const isDeliveryTargetCurrent = (targetHandle: string) =>
activeHandleRef.current === targetHandle &&
targetClient !== null &&
clientRef.current === targetClient &&
connStateRef.current === 'connected' &&
targetClient.getLastConnectedAt() === targetConnectedAt
accessoryRepeatRef.current.start(
input,
createTerminalAccessoryRepeatSender(
activeHandleRef.current,
isDeliveryTargetCurrent,
(nextInput, targetHandle, isTargetCurrent) =>
handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
)
)
},
[stopAccessoryRepeat]
[]
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
accessoryRepeatRef.current.cancel()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
clearToastHideTimer,
stopAccessoryRepeat
clearToastHideTimer
]
)
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
return
}
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
+2 -185
View File
@@ -2,7 +2,6 @@ import { useState, useRef, useCallback } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ActivityIndicator,
Linking,
@@ -19,13 +18,14 @@ import {
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import { pairScanStyles as styles } from '../src/pair-scan-styles'
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
// route surfaces as a real error with the log visible instead of a
@@ -356,186 +356,3 @@ export default function PairScanScreen() {
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+1 -1
View File
@@ -222,7 +222,7 @@ export default function TroubleshootScreen() {
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
<Text style={styles.diagnosticButtonLabel}>View network diagnostics</Text>
</Pressable>
{checks.length > 0 && (
@@ -0,0 +1,60 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type TerminalColorSnapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
export function summarizeTerminalColorSnapshot(
snapshot: TerminalColorSnapshot
): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
export function saveTerminalColorSnapshots(snapshots: TerminalColorSnapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(snapshots.map(summarizeTerminalColorSnapshot), null, 2)
)
return dir
}
+17 -59
View File
@@ -10,10 +10,13 @@
* ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 \
* pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]
*/
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveTerminalColorSnapshots,
summarizeTerminalColorSnapshot,
type TerminalColorSnapshot
} from './repro-terminal-color-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const token = process.argv[2]
@@ -21,7 +24,6 @@ const serverPublicKeyB64 = process.argv[3]
const worktreeSelector = process.argv[4]
const explicitHandleA = process.argv[5]
const explicitHandleB = process.argv[6]
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -37,15 +39,6 @@ type PendingRequest = {
reject: (error: Error) => void
}
type Snapshot = {
label: string
handle: string
cols: number | null
rows: number | null
serialized: string
lines: string
}
if (!token || !serverPublicKeyB64 || !worktreeSelector) {
console.error(
'Usage: pnpm exec tsx scripts/repro-terminal-colors.ts <deviceToken> <serverPublicKeyB64> <worktreeSelector> [handleA] [handleB]'
@@ -158,9 +151,13 @@ async function ensureSecondHandle(ws: WebSocket, handleA: string): Promise<strin
return handle
}
async function captureSnapshot(ws: WebSocket, label: string, handle: string): Promise<Snapshot> {
async function captureSnapshot(
ws: WebSocket,
label: string,
handle: string
): Promise<TerminalColorSnapshot> {
const id = nextId()
const snapshot = await new Promise<Snapshot>((resolve, reject) => {
const snapshot = await new Promise<TerminalColorSnapshot>((resolve, reject) => {
const timeout = setTimeout(() => {
pending.delete(id)
streamListeners.delete(id)
@@ -207,48 +204,6 @@ async function captureSnapshot(ws: WebSocket, label: string, handle: string): Pr
return snapshot
}
function countMatches(value: string, pattern: RegExp): number {
return value.match(pattern)?.length ?? 0
}
function summarize(snapshot: Snapshot): Record<string, string | number | null> {
const data = snapshot.serialized
return {
label: snapshot.label,
handle: snapshot.handle,
cols: snapshot.cols,
rows: snapshot.rows,
serializedBytes: Buffer.byteLength(data),
sgrTotal: countMatches(data, new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')),
sgrColor: countMatches(
data,
new RegExp(
`${ESC}\\[(?:[0-9;:]*[;:])?(?:3[0-7]|4[0-7]|9[0-7]|10[0-7]|38[;:]|48[;:])[0-9;:]*m`,
'g'
)
),
sgrReset: countMatches(data, new RegExp(`${ESC}\\[(?:0|39|49|0;39;49)m`, 'g')),
altScreen: data.includes(`${ESC}[?1049h`) ? 'yes' : 'no',
containsTruecolor: data.includes('38;2') || data.includes('38:2') ? 'yes' : 'no',
containsPaletteColor: data.includes('38;5') || data.includes('38:5') ? 'yes' : 'no'
}
}
function saveSnapshots(snapshots: Snapshot[]): string {
const dir = join(process.cwd(), 'terminal-color-repro')
mkdirSync(dir, { recursive: true })
for (const snapshot of snapshots) {
const base = snapshot.label.replace(/[^a-z0-9_-]/gi, '-')
writeFileSync(join(dir, `${base}.ansi`), snapshot.serialized || snapshot.lines)
writeFileSync(
join(dir, `${base}.escaped.txt`),
JSON.stringify(snapshot.serialized || snapshot.lines)
)
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(snapshots.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -303,11 +258,14 @@ async function run(ws: WebSocket): Promise<void> {
const firstB = await captureSnapshot(ws, 'b-during-switch', handleB)
const secondA = await captureSnapshot(ws, 'a-after-switch', handleA)
const snapshots = [firstA, firstB, secondA]
const dir = saveSnapshots(snapshots)
const dir = saveTerminalColorSnapshots(snapshots)
console.table(snapshots.map(summarize))
console.table(snapshots.map(summarizeTerminalColorSnapshot))
console.log(`saved: ${dir}`)
if (summarize(firstA).sgrColor !== summarize(secondA).sgrColor) {
if (
summarizeTerminalColorSnapshot(firstA).sgrColor !==
summarizeTerminalColorSnapshot(secondA).sgrColor
) {
console.log(
'color SGR count changed between A snapshots: serialization/state changed on desktop'
)
@@ -0,0 +1,75 @@
import { mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
const ESC = String.fromCharCode(27)
export type StartupTerminalCapture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
export function summarizeStartupCapture(capture: StartupTerminalCapture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
export function saveStartupCaptures(
captures: StartupTerminalCapture[],
worktreeName: string
): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(
join(dir, 'summary.json'),
JSON.stringify(captures.map(summarizeStartupCapture), null, 2)
)
return dir
}
@@ -4,10 +4,15 @@
* Usage:
* pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import nacl from 'tweetnacl'
import WebSocket from 'ws'
import {
saveStartupCaptures,
summarizeStartupCapture,
type StartupTerminalCapture
} from './repro-worktree-startup-output'
const WS_URL = process.env.ORCA_MOBILE_WS_URL ?? 'ws://127.0.0.1:6768'
const USER_DATA =
@@ -15,7 +20,6 @@ const USER_DATA =
const repoSelector = process.argv[2]
const worktreeName = process.argv[3]
const startupCommand = process.argv[4] || 'claude'
const ESC = String.fromCharCode(27)
type RpcResponse = {
id: string
@@ -36,13 +40,6 @@ type TerminalInfo = {
title: string | null
}
type Capture = {
handle: string
title: string | null
scrollback: Record<string, unknown> | null
chunks: string[]
}
if (!repoSelector || !worktreeName) {
console.error(
'Usage: pnpm exec tsx mobile/scripts/repro-worktree-startup-stream.ts <repoSelector> <worktreeName> [startupCommand]'
@@ -141,26 +138,6 @@ function formatError(response: RpcResponse): string {
return JSON.stringify(response.error ?? response.result ?? response).slice(0, 1000)
}
function stripAnsi(value: string): string {
return (
value
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, '')
// eslint-disable-next-line no-control-regex -- intentional terminal escape stripping for repro summaries
.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
.replace(/\r/g, '\n')
)
}
function normalizePreview(value: string): string {
return stripAnsi(value)
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.slice(0, 8)
.join('\n')
}
async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<TerminalInfo[]> {
const deadline = Date.now() + 60_000
while (Date.now() < deadline) {
@@ -185,7 +162,7 @@ async function waitForTerminals(ws: WebSocket, worktreeId: string): Promise<Term
throw new Error('Timed out waiting for startup terminals')
}
async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
async function subscribe(ws: WebSocket, capture: StartupTerminalCapture): Promise<void> {
const id = nextId()
streamListeners.set(id, (result) => {
if (result.type === 'scrollback') {
@@ -204,44 +181,6 @@ async function subscribe(ws: WebSocket, capture: Capture): Promise<void> {
})
}
function summarize(capture: Capture): Record<string, unknown> {
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
const live = capture.chunks.join('')
const serializedPreview = normalizePreview(serialized)
const livePreview = normalizePreview(live)
return {
handle: capture.handle,
title: capture.title,
cols: capture.scrollback?.cols ?? null,
rows: capture.scrollback?.rows ?? null,
serializedBytes: Buffer.byteLength(serialized),
liveBytes: Buffer.byteLength(live),
liveChunks: capture.chunks.length,
serializedSgr: (serialized.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
liveSgr: (live.match(new RegExp(`${ESC}\\[[0-9;:]*m`, 'g')) ?? []).length,
livePreviewContainedInSerialized:
livePreview.length > 0 && stripAnsi(serialized).includes(livePreview.split('\n')[0] ?? ''),
serializedPreview,
livePreview
}
}
function save(captures: Capture[], worktreeName: string): string {
const dir = join(process.cwd(), 'terminal-startup-repro', worktreeName)
mkdirSync(dir, { recursive: true })
for (const capture of captures) {
const base = capture.handle.replace(/[^a-z0-9_-]/gi, '-')
const serialized =
typeof capture.scrollback?.serialized === 'string' ? capture.scrollback.serialized : ''
writeFileSync(join(dir, `${base}.serialized.ansi`), serialized)
writeFileSync(join(dir, `${base}.live.ansi`), capture.chunks.join(''))
writeFileSync(join(dir, `${base}.json`), JSON.stringify(capture, null, 2))
}
writeFileSync(join(dir, 'summary.json'), JSON.stringify(captures.map(summarize), null, 2))
return dir
}
async function run(ws: WebSocket): Promise<void> {
ws.send(
JSON.stringify({
@@ -312,8 +251,8 @@ async function run(ws: WebSocket): Promise<void> {
for (const capture of captures) {
await send(ws, 'terminal.unsubscribe', { subscriptionId: capture.handle }).catch(() => null)
}
const dir = save(captures, worktreeName)
console.table(captures.map(summarize))
const dir = saveStartupCaptures(captures, worktreeName)
console.table(captures.map(summarizeStartupCapture))
console.log(`saved: ${dir}`)
}
+23 -372
View File
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
// 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -10,7 +7,6 @@ import {
PanResponder,
PixelRatio,
Pressable,
StyleSheet,
Text,
TextInput,
View,
@@ -24,7 +20,7 @@ import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest,
@@ -47,11 +43,32 @@ import {
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import {
MAX_ZOOM,
MIN_ZOOM,
assertRpcOk,
browserErrorMessage,
browserFrameMetadataEqual,
buttonColor,
cacheBrowserFrame,
clearCachedBrowserFramesForWorktree,
createBrowserFrameDataUri,
createPinchGesture,
getCachedBrowserFrame,
makeBrowserFrameCacheKey,
peekCachedBrowserFrame,
shouldSurfaceBrowserError,
updateBrowserImageSource,
updateBrowserLayerVisibility,
updatePinchZoom,
type FrameLayer,
type PinchGesture
} from './mobile-browser-frame-state'
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
onToast: (message: string, durationMs?: number) => void
}
type FrameLayer = 0 | 1
type PinchGesture = {
distance: number
scale: number
anchorX: number
anchorY: number
}
type PanGesture = {
x: number
y: number
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
type BrowserPageParams = {
worktree: string
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
</View>
)
}
function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
viewMode: MobileBrowserViewMode
): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
): void {
for (const [index, layer] of layers.entries()) {
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
}
}
function updateBrowserImageSource(image: Image | null, uri: string): void {
// Why: browser frames are large strings; mutating only the native Image
// source avoids re-rendering the whole tab view for every streamed frame.
const source = [{ uri }]
image?.setNativeProps({ source, src: source })
}
function assertRpcOk(
response: RpcSuccess | RpcFailure,
fallbackMessage: string
): asserts response is RpcSuccess {
if (!response.ok) {
throw new Error(response.error.message || fallbackMessage)
}
}
function browserFrameMetadataEqual(
a: BrowserScreencastFrameMetadata | null,
b: BrowserScreencastFrameMetadata
): boolean {
return (
a?.deviceWidth === b.deviceWidth &&
a?.deviceHeight === b.deviceHeight &&
a?.pageScaleFactor === b.pageScaleFactor
)
}
function browserErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function shouldSurfaceBrowserError(message: string): boolean {
const normalized = message.toLowerCase()
// Why: selector_not_found can be emitted by in-flight page automation while
// the browser is still usable; replacing the frame with it feels like a crash.
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
}
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
const touches = event.nativeEvent.touches
if (!touches || touches.length < 2) {
return null
}
const a = readLocalTouchPoint(touches[0])
const b = readLocalTouchPoint(touches[1])
return a && b ? { a, b } : null
}
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function createPinchGesture(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
zoom: BrowserZoomState
): PinchGesture | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const distance = pointDistance(pair.a, pair.b)
if (distance < 8) {
return null
}
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
return {
distance,
scale: zoom.scale,
anchorX: (centerX - frameCenterX) / zoom.scale,
anchorY: (centerY - frameCenterY) / zoom.scale
}
}
function updatePinchZoom(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
pinch: PinchGesture
): BrowserZoomState | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const nextScale = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
)
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
return clampBrowserZoomState(
{
scale: nextScale,
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
},
geometry,
MIN_ZOOM,
MAX_ZOOM
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
viewport: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
backgroundColor: colors.bgBase
},
browserImageHost: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImageFill: {
width: '100%',
height: '100%'
},
browserImageLayer: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
browserImageLayerHidden: {
opacity: 0
},
browserZoomOffset: {
alignItems: 'center',
justifyContent: 'center'
},
browserFrameBox: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImage: {
backgroundColor: colors.bgBase
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.sm,
backgroundColor: 'rgba(13, 15, 24, 0.2)'
},
errorText: {
color: colors.textPrimary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 13,
textAlign: 'center',
overflow: 'hidden'
},
dialogOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 30,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: 'rgba(13, 15, 24, 0.5)'
},
dialogCard: {
width: '100%',
maxWidth: 360,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
padding: spacing.lg
},
dialogTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '600'
},
dialogMessage: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginTop: spacing.sm
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg
},
dialogButton: {
minHeight: 34,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
dialogButtonPrimary: {
backgroundColor: colors.textPrimary
},
dialogButtonPressed: {
opacity: 0.75
},
dialogButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
dialogButtonPrimaryText: {
color: colors.bgBase
},
keyboardDock: {
zIndex: 20,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs + 2
},
keyboardInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
sendButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
@@ -0,0 +1,210 @@
import { Buffer } from 'buffer'
import type { GestureResponderEvent, Image, View } from 'react-native'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { colors } from '../theme/mobile-theme'
import {
clampBrowserZoomState,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserPoint,
type BrowserZoomState
} from './browser-touch-geometry'
import type { MobileBrowserViewMode } from './browser-screencast-request'
export type FrameLayer = 0 | 1
export type PinchGesture = {
distance: number
scale: number
anchorX: number
anchorY: number
}
export type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
export const MIN_ZOOM = 1
export const MAX_ZOOM = 3.5
const BROWSER_FRAME_CACHE_LIMIT = 4
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
export function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
export function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
viewMode: MobileBrowserViewMode
): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
export function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
): void {
for (const [index, layer] of layers.entries()) {
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
}
}
export function updateBrowserImageSource(image: Image | null, uri: string): void {
// Why: browser frames are large strings; mutating only the native Image
// source avoids re-rendering the whole tab view for every streamed frame.
const source = [{ uri }]
image?.setNativeProps({ source, src: source })
}
export function assertRpcOk(
response: RpcSuccess | RpcFailure,
fallbackMessage: string
): asserts response is RpcSuccess {
if (!response.ok) {
throw new Error(response.error.message || fallbackMessage)
}
}
export function browserFrameMetadataEqual(
a: BrowserScreencastFrameMetadata | null,
b: BrowserScreencastFrameMetadata
): boolean {
return (
a?.deviceWidth === b.deviceWidth &&
a?.deviceHeight === b.deviceHeight &&
a?.pageScaleFactor === b.pageScaleFactor
)
}
export function browserErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
export function shouldSurfaceBrowserError(message: string): boolean {
const normalized = message.toLowerCase()
// Why: selector_not_found can be emitted by in-flight page automation while
// the browser is still usable; replacing the frame with it feels like a crash.
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
}
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
const touches = event.nativeEvent.touches
if (!touches || touches.length < 2) {
return null
}
const a = readLocalTouchPoint(touches[0])
const b = readLocalTouchPoint(touches[1])
return a && b ? { a, b } : null
}
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function createPinchGesture(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
zoom: BrowserZoomState
): PinchGesture | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const distance = pointDistance(pair.a, pair.b)
if (distance < 8) {
return null
}
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
return {
distance,
scale: zoom.scale,
anchorX: (centerX - frameCenterX) / zoom.scale,
anchorY: (centerY - frameCenterY) / zoom.scale
}
}
export function updatePinchZoom(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
pinch: PinchGesture
): BrowserZoomState | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const nextScale = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
)
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
return clampBrowserZoomState(
{
scale: nextScale,
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
},
geometry,
MIN_ZOOM,
MAX_ZOOM
)
}
@@ -0,0 +1,171 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const mobileBrowserPaneStyles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
viewport: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
backgroundColor: colors.bgBase
},
browserImageHost: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImageFill: {
width: '100%',
height: '100%'
},
browserImageLayer: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
browserImageLayerHidden: {
opacity: 0
},
browserZoomOffset: {
alignItems: 'center',
justifyContent: 'center'
},
browserFrameBox: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImage: {
backgroundColor: colors.bgBase
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.sm,
backgroundColor: 'rgba(13, 15, 24, 0.2)'
},
errorText: {
color: colors.textPrimary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 13,
textAlign: 'center',
overflow: 'hidden'
},
dialogOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 30,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: 'rgba(13, 15, 24, 0.5)'
},
dialogCard: {
width: '100%',
maxWidth: 360,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
padding: spacing.lg
},
dialogTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '600'
},
dialogMessage: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginTop: spacing.sm
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg
},
dialogButton: {
minHeight: 34,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
dialogButtonPrimary: {
backgroundColor: colors.textPrimary
},
dialogButtonPressed: {
opacity: 0.75
},
dialogButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
dialogButtonPrimaryText: {
color: colors.bgBase
},
keyboardDock: {
zIndex: 20,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs + 2
},
keyboardInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
sendButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
@@ -0,0 +1,81 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionLogEntry } from '../transport/types'
import { ConnectionLog } from './ConnectionLog'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
const entries: ConnectionLogEntry[] = [
{ id: 'event-1', ts: 1, level: 'info', message: 'Opening WebSocket' }
]
const duplicateEntries: ConnectionLogEntry[] = [
{ id: 'relay-1', ts: 1, level: 'info', message: 'Relay recovery started' },
{ id: 'relay-1', ts: 2, level: 'warn', message: 'Relay retry scheduled' }
]
describe('ConnectionLog', () => {
type RenderedNode = { props: { style?: unknown } }
type Renderer = {
root: {
findAllByType: (type: unknown) => RenderedNode[]
findByType: (type: unknown) => RenderedNode
}
unmount: () => void
}
let renderer: Renderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function renderLog(fillAvailableHeight = false): Renderer {
act(() => {
renderer = create(
createElement(ConnectionLog, { entries, fillAvailableHeight })
) as unknown as Renderer
})
return renderer as Renderer
}
it('keeps the compact height in pairing flows', () => {
const instance = renderLog()
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
expect(containerStyles).toContainEqual({ maxHeight: 240 })
})
it('fills the available diagnostics viewport', () => {
const instance = renderLog(true)
const containerStyles = instance.root.findAllByType('View')[0]!.props.style
const scroll = instance.root.findByType('ScrollView')
expect(containerStyles).toContainEqual({ flex: 1 })
expect(scroll.props.style).toEqual({ flex: 1 })
})
it('does not emit duplicate-key warnings for repeated persisted event IDs', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
act(() => {
renderer = create(
createElement(ConnectionLog, { entries: duplicateEntries })
) as unknown as Renderer
})
expect(
consoleError.mock.calls.some(([message]) =>
String(message).includes('Encountered two children with the same key')
)
).toBe(false)
consoleError.mockRestore()
})
})
+40 -20
View File
@@ -8,6 +8,7 @@ type Props = {
// Tag printed before the first entry so it's clear what's being logged
// (e.g. 'Pairing' vs 'Reconnect').
title?: string
fillAvailableHeight?: boolean
}
const LEVEL_COLOR: Record<ConnectionLogEntry['level'], string> = {
@@ -37,42 +38,53 @@ function formatTime(ts: number, baseTs: number): string {
return `+${Math.round(elapsed)}s`
}
export function ConnectionLog({ entries, title }: Props) {
export function ConnectionLog({ entries, title, fillAvailableHeight = false }: Props) {
const scrollRef = useRef<ScrollView | null>(null)
if (entries.length === 0) {
return null
}
const baseTs = entries[0]!.ts
const keyOccurrences = new Map<string, number>()
return (
<View style={styles.container}>
<View
style={[
styles.container,
fillAvailableHeight ? styles.fillContainer : styles.boundedContainer
]}
>
{title && <Text style={styles.title}>{title}</Text>}
<ScrollView
ref={scrollRef}
style={styles.scroll}
style={fillAvailableHeight ? styles.fillScroll : styles.boundedScroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
{entries.map((entry) => {
const occurrence = keyOccurrences.get(entry.id) ?? 0
keyOccurrences.set(entry.id, occurrence + 1)
const renderKey = occurrence === 0 ? entry.id : `${entry.id}:${occurrence}`
return (
<View key={renderKey} style={styles.row}>
<Text style={styles.timestamp}>{formatTime(entry.ts, baseTs)}</Text>
<Text style={[styles.glyph, { color: LEVEL_COLOR[entry.level] }]}>
{LEVEL_GLYPH[entry.level]}
</Text>
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
<View style={styles.rowText}>
<Text style={[styles.message, { color: LEVEL_COLOR[entry.level] }]}>
{entry.message}
</Text>
)}
{entry.detail && (
<Text style={styles.detail} numberOfLines={2}>
{entry.detail}
</Text>
)}
</View>
</View>
</View>
))}
)
})}
</ScrollView>
</View>
)
@@ -81,7 +93,6 @@ export function ConnectionLog({ entries, title }: Props) {
const styles = StyleSheet.create({
container: {
width: '100%',
maxHeight: 240,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
@@ -89,6 +100,12 @@ const styles = StyleSheet.create({
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md
},
boundedContainer: {
maxHeight: 240
},
fillContainer: {
flex: 1
},
title: {
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
@@ -97,9 +114,12 @@ const styles = StyleSheet.create({
letterSpacing: 1,
marginBottom: spacing.xs
},
scroll: {
boundedScroll: {
maxHeight: 200
},
fillScroll: {
flex: 1
},
scrollContent: {
gap: 6
},
@@ -0,0 +1,276 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
export const customKeyModalStyles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
+3 -276
View File
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { View, Text, Pressable, TextInput, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
buildTerminalShortcutKey,
@@ -11,6 +11,7 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import { customKeyModalStyles as styles } from './CustomKeyModal.styles'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
@@ -409,277 +410,3 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingBottom: spacing.sm
},
backButton: {
width: 30,
height: 30,
borderRadius: 15,
alignItems: 'center',
justifyContent: 'center'
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
backSpacer: {
width: 30
},
title: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary,
textAlign: 'center'
},
group: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden'
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
row: {
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
rowLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary,
marginBottom: 1
},
rowHint: {
fontSize: 12,
color: colors.textMuted
},
shortcutForm: {
paddingTop: spacing.sm
},
preview: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingVertical: spacing.lg + spacing.xs,
flexWrap: 'wrap'
},
previewKeycapRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
previewPlus: {
color: colors.textMuted,
fontSize: 16
},
keycap: {
minWidth: 48,
height: 48,
paddingHorizontal: spacing.md,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
alignItems: 'center',
justifyContent: 'center'
},
keycapModifier: {
minWidth: 0
},
keycapWarn: {
borderColor: colors.statusAmber
},
keycapText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 17,
fontWeight: '600'
},
keycapTextWarn: {
color: colors.statusAmber
},
keycapModifierText: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: 14,
fontWeight: '600'
},
section: {
marginTop: spacing.md
},
sectionLabel: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: spacing.sm,
paddingLeft: 2
},
mods: {
flexDirection: 'row',
gap: spacing.sm
},
chip: {
flex: 1,
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4
},
chipSelected: {
backgroundColor: colors.textPrimary
},
chipPressed: {
backgroundColor: colors.bgRaised
},
chipText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '500'
},
chipTextSelected: {
color: colors.bgBase
},
chipGlyph: {
color: colors.textMuted,
fontSize: 13,
fontFamily: typography.monoFamily
},
chipGlyphSelected: {
color: 'rgba(10,10,10,0.5)'
},
keyInput: {
width: '100%',
height: 56,
borderRadius: 10,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 22,
fontWeight: '600',
textAlign: 'center'
},
moreLink: {
paddingVertical: spacing.sm,
alignItems: 'center'
},
moreLinkPressed: {
opacity: 0.6
},
moreLinkText: {
color: colors.textSecondary,
fontSize: 13,
textDecorationLine: 'underline'
},
specialKeysForm: {
paddingTop: spacing.xs,
paddingBottom: spacing.md,
gap: spacing.md
},
specialGroup: {
gap: spacing.xs
},
specialGroupTitle: {
fontSize: 11,
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.8,
paddingLeft: 2,
marginBottom: spacing.xs
},
keyGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginHorizontal: -spacing.xs / 2
},
keyCellWrap: {
paddingHorizontal: spacing.xs / 2,
paddingVertical: spacing.xs / 2
},
keyCell: {
height: 40,
borderRadius: 8,
backgroundColor: colors.bgPanel,
alignItems: 'center',
justifyContent: 'center'
},
keyCellPressed: {
backgroundColor: colors.bgRaised
},
keyCellSelected: {
backgroundColor: colors.textPrimary
},
keyCellText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary,
fontFamily: typography.monoFamily
},
keyCellTextSelected: {
color: colors.bgBase
},
macroForm: {
padding: spacing.md,
gap: spacing.sm
},
fieldLabel: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary
},
fieldInput: {
backgroundColor: colors.bgBase,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 14,
fontFamily: typography.monoFamily,
borderWidth: 1,
borderColor: colors.borderSubtle
},
switchRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs
},
switchLabel: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
saveButton: {
marginTop: spacing.md,
backgroundColor: colors.textPrimary,
paddingVertical: spacing.md,
borderRadius: 10,
alignItems: 'center'
},
saveButtonDisabled: {
backgroundColor: colors.bgRaised
},
saveButtonText: {
color: colors.bgBase,
fontSize: 15,
fontWeight: '600'
},
saveButtonTextDisabled: {
color: colors.textMuted
}
})
@@ -0,0 +1,38 @@
import { ChevronRight } from 'lucide-react-native'
import { Pressable, StyleSheet, Text } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export function HostDiagnosticsLink({ onPress }: { onPress: () => void }): React.JSX.Element {
return (
<Pressable
style={styles.link}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel="View network diagnostics"
>
<Text style={styles.text}>View network diagnostics</Text>
<ChevronRight size={16} color={colors.textSecondary} />
</Pressable>
)
}
const styles = StyleSheet.create({
link: {
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
},
text: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,279 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY = [
';',
' --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;',
' --font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;',
' }',
' * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }',
' html, body {',
' width: 100%;',
' min-height: 100%;',
' margin: 0;',
' background: var(--editor-surface);',
' color: var(--foreground);',
' font-family: var(--font-sans);',
' overscroll-behavior: contain;',
' }',
' body { overflow: auto; }',
' #editor {',
' min-height: 100vh;',
' padding: 18px 16px 112px;',
' outline: none;',
' font-size: 14px;',
' line-height: 1.7;',
' word-wrap: break-word;',
' overflow-wrap: anywhere;',
' caret-color: var(--foreground);',
' }',
' #editor[contenteditable="false"] {',
' opacity: 0.78;',
' }',
' #editor:empty::before,',
' #editor p.is-empty:first-child::before {',
' content: attr(data-placeholder);',
' color: var(--muted-foreground);',
' pointer-events: none;',
' }',
' #editor > :first-child { margin-top: 0; }',
' h1, h2, h3, h4, h5, h6 {',
' margin: 1.5em 0 0.5em;',
' font-weight: 600;',
' line-height: 1.3;',
' letter-spacing: 0;',
' }',
' h1 { font-size: 1.85em; font-weight: 700; }',
' h2 { font-size: 1.4em; }',
' h3 { font-size: 1.15em; }',
' p, ul, ol, blockquote { margin: 0.75em 0; }',
' ul, ol { padding-left: 1.5em; }',
' ul { list-style: disc; }',
' ol { list-style: decimal; }',
' li { margin: 0.15em 0; }',
' li > p { margin: 0; }',
' ul[data-type="taskList"] {',
' padding-left: 0;',
' list-style: none;',
' }',
' ul[data-type="taskList"] > li {',
' display: flex;',
' align-items: flex-start;',
' gap: 6px;',
' }',
' ul[data-type="taskList"] > li > label {',
' flex-shrink: 0;',
' display: flex;',
' align-items: center;',
' height: 1.55em;',
' user-select: none;',
' }',
' ul[data-type="taskList"] input[type="checkbox"] {',
' appearance: none;',
' width: 16px;',
' height: 16px;',
' margin: 0;',
' border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);',
' border-radius: 4px;',
' background: transparent;',
' position: relative;',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked {',
' background: var(--primary);',
' border-color: var(--primary);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:checked::after {',
' content: "";',
' position: absolute;',
' left: 4px;',
' top: 1px;',
' width: 5px;',
' height: 9px;',
' border: solid var(--primary-foreground);',
' border-width: 0 2px 2px 0;',
' transform: rotate(45deg);',
' }',
' ul[data-type="taskList"] input[type="checkbox"]:disabled {',
' opacity: 0.65;',
' }',
' ul[data-type="taskList"] > li > div {',
' flex: 1;',
' min-width: 0;',
' }',
' ul[data-type="taskList"] > li[data-checked="true"] > div {',
' text-decoration: line-through;',
' color: var(--muted-foreground);',
' }',
' blockquote {',
' padding: 0.5em 1em;',
' border-left: 3px solid var(--border);',
' border-radius: 0 6px 6px 0;',
' color: var(--muted-foreground);',
' background: color-mix(in srgb, var(--foreground) 2%, transparent);',
' }',
' table {',
' width: 100%;',
' margin: 1em 0;',
' border-collapse: collapse;',
' font-size: 0.95em;',
' }',
' th, td {',
' padding: 8px 14px;',
' border: 1px solid var(--border);',
' text-align: left;',
' vertical-align: top;',
' }',
' th {',
' font-weight: 600;',
' background: color-mix(in srgb, var(--foreground) 4%, transparent);',
' }',
' tr:nth-child(odd) td {',
' background: color-mix(in srgb, var(--foreground) 1.5%, transparent);',
' }',
' code {',
' padding: 0.2em 0.4em;',
' border-radius: 5px;',
' background: color-mix(in srgb, var(--foreground) 8%, transparent);',
' font-size: 0.88em;',
' font-family: var(--font-mono);',
' }',
' pre {',
' margin: 0.75em 0;',
' padding: 14px 18px;',
' border-radius: 8px;',
' border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);',
' overflow-x: auto;',
' line-height: 1.55;',
' background: color-mix(in srgb, var(--foreground) 6%, transparent);',
' font-family: var(--font-mono);',
' white-space: pre-wrap;',
' }',
' pre::before {',
' content: attr(data-language);',
' display: block;',
' min-height: 13px;',
' margin-bottom: 4px;',
' color: var(--muted-foreground);',
' font-size: 11px;',
' text-transform: uppercase;',
' }',
' pre code {',
' padding: 0;',
' border-radius: 0;',
' background: transparent;',
' font-size: 0.92em;',
' white-space: pre-wrap;',
' }',
' hr {',
' margin: 1.5em 0;',
' border: none;',
' border-top: 1px solid var(--border);',
' }',
' a {',
' color: var(--accent-link);',
' text-decoration: underline;',
' text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);',
' text-underline-offset: 2px;',
' }',
' img {',
' display: block;',
' max-width: 100%;',
' margin: 0.75em 0;',
' border-radius: 8px;',
' }',
' </style>',
'</head>',
'<body>',
' <main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>',
' <script>',
' (function () {',
" var editor = document.getElementById('editor');",
" var lastMarkdown = '';",
' var inputTimer = null;',
' var documentGeneration = 0;',
' var editable = true;',
' var suppressInput = false;',
'',
' function post(message) {',
' window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));',
' }',
'',
' function decodeMarkdownEntities(value) {',
' return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {',
' var lower = String(entity).toLowerCase();',
" if (lower === 'amp') return '&';",
" if (lower === 'lt') return '<';",
" if (lower === 'gt') return '>';",
" if (lower === 'quot') return '\"';",
" if (lower === 'apos') return \"'\";",
" if (lower.indexOf('#x') === 0) {",
' var hex = Number.parseInt(lower.slice(2), 16);',
' return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;',
' }',
" if (lower.indexOf('#') === 0) {",
' var code = Number.parseInt(lower.slice(1), 10);',
' return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;',
' }',
' return match;',
' });',
' }',
'',
' function escapeHtml(value) {',
' return decodeMarkdownEntities(value).replace(/[&<>"\']/g, function (char) {',
" return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' })[char];",
' });',
' }',
'',
' function escapeAttr(value) {',
" return escapeHtml(value).replace(/\\n/g, ' ');",
' }',
'',
' function isSafeUrl(value) {',
" var trimmed = String(value || '').trim();",
' return !/^javascript:/i.test(trimmed);',
' }',
'',
' function splitTableRow(line) {',
" return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {",
' return cell.trim();',
' });',
' }',
'',
' function isTableSeparator(line) {',
' var cells = splitTableRow(line);',
' return cells.length > 0 && cells.every(function (cell) {',
' return /^:?-{3,}:?$/.test(cell);',
' });',
' }',
'',
' function renderInline(text) {',
" var output = '';",
' var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|`[^`]+`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;',
' var lastIndex = 0;',
' var match;',
' while ((match = pattern.exec(text))) {',
' output += escapeHtml(text.slice(lastIndex, match.index));',
' var token = match[0];',
' var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);',
' var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);',
' if (image && isSafeUrl(image[2])) {',
" output += '<img src=\"' + escapeAttr(image[2]) + '\" alt=\"' + escapeAttr(image[1] || '') + '\" />';",
' } else if (link && isSafeUrl(link[2])) {',
" output += '<a href=\"' + escapeAttr(link[2]) + '\">' + renderInline(link[1]) + '</a>';",
' } else if (/^https?:\\/\\//i.test(token)) {',
" output += '<a href=\"' + escapeAttr(token) + '\">' + escapeHtml(token) + '</a>';",
" } else if (token.indexOf('`') === 0) {",
" output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';",
" } else if (token.indexOf('~~') === 0) {",
" output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';",
" } else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {",
" output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';",
' } else {',
" output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';",
' }',
' lastIndex = pattern.lastIndex;',
' }',
' output += escapeHtml(text.slice(lastIndex));',
' return output;',
' }',
'',
' function isBlockStart(line) {',
' return /^(```|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);'
].join('\n')
@@ -0,0 +1,278 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY = [
' }',
'',
' function indentationWidth(value) {',
" return String(value || '').replace(/\\t/g, ' ').length;",
' }',
'',
' function parseListLine(line) {',
" var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);",
' if (!match) return null;',
" var rawText = match[3] || '';",
' var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);',
' return {',
" indent: indentationWidth(match[1] || ''),",
" ordered: /^\\d/.test(match[2] || ''),",
" orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,",
" task: task ? task[1].toLowerCase() === 'x' : null,",
' text: task ? task[2] : rawText,',
' children: []',
' };',
' }',
'',
' function listKind(item) {',
" if (item.task !== null) return 'task';",
" return item.ordered ? 'ol' : 'ul';",
' }',
'',
' function parseListTree(lines, startIndex) {',
' var root = { indent: -1, children: [] };',
' var stack = [root];',
' var index = startIndex;',
' while (index < lines.length) {',
" var item = parseListLine(lines[index] || '');",
' if (!item) break;',
' while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {',
' stack.pop();',
' }',
' stack[stack.length - 1].children.push(item);',
' stack.push(item);',
' index += 1;',
' }',
' return { items: root.children, nextIndex: index };',
' }',
'',
' function renderListItems(items) {',
' var html = [];',
' var index = 0;',
' while (index < items.length) {',
' var kind = listKind(items[index]);',
' var group = [];',
' while (index < items.length && listKind(items[index]) === kind) {',
' group.push(items[index]);',
' index += 1;',
' }',
" var tag = kind === 'ol' ? 'ol' : 'ul';",
" var attrs = kind === 'task' ? ' data-type=\"taskList\"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start=\"' + group[0].orderedNumber + '\"' : '';",
" html.push('<' + tag + attrs + '>' + group.map(function (item) {",
" var children = item.children.length ? renderListItems(item.children) : '';",
" if (kind === 'task') {",
' var checked = item.task === true;',
" return '<li data-checked=\"' + String(checked) + '\"><label contenteditable=\"false\"><input type=\"checkbox\" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';",
' }',
" var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value=\"' + item.orderedNumber + '\" data-list-number=\"' + item.orderedNumber + '\"' : '';",
" return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';",
" }).join('') + '</' + tag + '>');",
' }',
" return html.join('');",
' }',
'',
' function markdownToHtml(markdown) {',
" var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');",
' var html = [];',
' var index = 0;',
' while (index < lines.length) {',
" var line = lines[index] || '';",
' if (!line.trim()) {',
' index += 1;',
' continue;',
' }',
' var fence = line.match(/^\\```([^\\s`]*)\\s*$/);',
' if (fence) {',
' index += 1;',
' var code = [];',
" while (index < lines.length && !/^\\```\\s*$/.test(lines[index] || '')) {",
" code.push(lines[index] || '');",
' index += 1;',
' }',
' if (index < lines.length) index += 1;',
" html.push('<pre data-language=\"' + escapeAttr(fence[1] || '') + '\"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');",
' continue;',
' }',
' if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {',
" html.push('<hr />');",
' index += 1;',
' continue;',
' }',
" if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {",
' var headers = splitTableRow(line);',
' index += 2;',
' var rows = [];',
" while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {",
" rows.push(splitTableRow(lines[index] || ''));",
' index += 1;',
' }',
" html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {",
" return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';",
" }).join('') + '</tbody></table>');",
' continue;',
' }',
' var heading = line.match(/^(#{1,6})\\s+(.+)$/);',
' if (heading) {',
" html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');",
' index += 1;',
' continue;',
' }',
' if (/^>\\s?/.test(line)) {',
' var quote = [];',
" while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {",
" quote.push((lines[index] || '').replace(/^>\\s?/, ''));",
' index += 1;',
' }',
" html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');",
' continue;',
' }',
' if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {',
' var list = parseListTree(lines, index);',
' html.push(renderListItems(list.items));',
' index = list.nextIndex;',
' continue;',
' }',
' var paragraph = [];',
" while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {",
" paragraph.push(lines[index] || '');",
' index += 1;',
' }',
" html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');",
' }',
" return html.join('\\n') || '<p class=\"is-empty\"><br /></p>';",
' }',
'',
' function textContent(node) {',
" return (node.textContent || '').replace(/\\u00a0/g, ' ');",
' }',
'',
' function inlineMarkdown(node) {',
" if (!node) return '';",
' if (node.nodeType === Node.TEXT_NODE) return textContent(node);',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag === 'br') return '\\n';",
" if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';",
" if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';",
" if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';",
" if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '`' + textContent(el) + '`';",
" if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';",
" if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';",
" if (tag === 'label') return '';",
' return inlineChildren(el);',
' }',
'',
' function inlineChildren(el) {',
" return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');",
' }',
'',
' function listItemText(li) {',
' var clone = li.cloneNode(true);',
" Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });",
" Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });",
' return inlineChildren(clone).trim();',
' }',
'',
' function directNestedLists(li) {',
" return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {",
" return list.closest('li') === li;",
' });',
' }',
'',
' function listMarkdown(el, depth) {',
' var tag = el.tagName.toLowerCase();',
" var isTask = el.getAttribute('data-type') === 'taskList';",
" var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;",
' if (!Number.isFinite(orderedStart)) orderedStart = 1;',
" var indent = ' '.repeat(depth);",
' return Array.prototype.map.call(el.children, function (li, index) {',
" if (li.tagName.toLowerCase() !== 'li') return '';",
' var marker;',
' if (isTask) {',
' var input = li.querySelector(\'input[type="checkbox"]\');',
" marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';",
' } else {',
' var listNumber =',
' li.getAttribute &&',
" (li.getAttribute('data-list-number') || li.getAttribute('value'));",
" marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';",
' }',
' var line = indent + marker + listItemText(li);',
' var nested = directNestedLists(li).map(function (list) {',
' return listMarkdown(list, depth + 1);',
" }).filter(Boolean).join('\\n');",
" return nested ? line + '\\n' + nested : line;",
" }).filter(Boolean).join('\\n');",
' }',
'',
' function blockMarkdown(node) {',
' if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();',
" if (node.nodeType !== Node.ELEMENT_NODE) return '';",
' var el = node;',
' var tag = el.tagName.toLowerCase();',
" if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();",
" if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();",
" if (tag === 'blockquote') {",
" return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');",
' }',
" if (tag === 'pre') {",
" var lang = el.getAttribute('data-language') || '';",
" var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');",
" return '```' + lang + '\\n' + code + '\\n```';",
' }',
" if (tag === 'ul' || tag === 'ol') {",
' return listMarkdown(el, 0);',
' }',
" if (tag === 'table') {",
" var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));",
" if (rows.length === 0) return '';",
' var cellsFor = function (row) {',
' return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });',
' };',
' var headers = cellsFor(rows[0]);',
' var bodyRows = rows.slice(1).map(cellsFor);',
" return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');",
' }',
" if (tag === 'hr') return '---';",
" if (tag === 'img') return inlineMarkdown(el);",
' return inlineChildren(el).trim();',
' }',
'',
' function currentMarkdown() {',
' return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {',
' return block.trim().length > 0;',
" }).join('\\n\\n').trimEnd();",
' }',
'',
' function syncTaskCheckboxesDisabled() {',
' Array.prototype.forEach.call(editor.querySelectorAll(\'input[type="checkbox"]\'), function (input) {',
' input.disabled = !editable;',
' });',
' }',
'',
' function emitChange() {',
' if (suppressInput || !editable) return;',
' window.clearTimeout(inputTimer);',
' var pendingGeneration = documentGeneration;',
' lastMarkdown = currentMarkdown();',
" post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });",
' }',
'',
' function setMarkdown(markdown, generation) {',
' window.clearTimeout(inputTimer);',
' documentGeneration = Number(generation) || 0;',
' suppressInput = true;',
" // Why: replacing innerHTML detaches the remembered caret's nodes.",
' savedSelectionRange = null;',
' selectionDroppedOnBlur = false;',
" lastMarkdown = String(markdown || '');",
' editor.innerHTML = markdownToHtml(lastMarkdown);',
' syncTaskCheckboxesDisabled();',
' suppressInput = false;',
' }',
'',
' function setEditable(nextEditable) {',
' editable = Boolean(nextEditable);',
" editor.setAttribute('contenteditable', editable ? 'true' : 'false');",
' syncTaskCheckboxesDisabled();',
' }',
'',
''
].join('\n')
@@ -0,0 +1,93 @@
export const MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS = [
'',
' function runCommand(command) {',
" if (!editable || editor.getAttribute('contenteditable') !== 'true') return;",
' restoreSelectionOrEnd();',
" if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');",
" else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');",
" else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');",
" else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');",
" else if (command === 'bold') document.execCommand('bold');",
" else if (command === 'italic') document.execCommand('italic');",
" else if (command === 'strike') document.execCommand('strikeThrough');",
" else if (command === 'bulletList') document.execCommand('insertUnorderedList');",
" else if (command === 'orderedList') document.execCommand('insertOrderedList');",
" else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');",
" else if (command === 'inlineCode') wrapSelection('code');",
" else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=\"\"><code>code</code></pre><p><br></p>');",
' else if (command === \'taskList\') document.execCommand(\'insertHTML\', false, \'<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>\');',
" else if (command === 'link') {",
" var href = window.prompt('Link URL');",
" if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);",
" } else if (command === 'image') {",
" var src = window.prompt('Image URL');",
" if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);",
' }',
' syncTaskCheckboxesDisabled();',
' emitChange();',
' }',
'',
" editor.addEventListener('input', function () {",
' selectionDroppedOnBlur = false;',
' if (editable) emitChange();',
' });',
" editor.addEventListener('change', function (event) {",
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (input) {',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' }',
' if (editable) emitChange();',
' });',
" editor.addEventListener('click', function (event) {",
" var link = event.target && event.target.closest && event.target.closest('a[href]');",
' if (link) {',
' event.preventDefault();',
" post({ type: 'openLink', url: link.getAttribute('href') || '' });",
' return;',
' }',
' var input = event.target && event.target.closest && event.target.closest(\'input[type="checkbox"]\');',
' if (!input) {',
' if (!editable) return;',
' // Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.',
' var uneditable = event.target && event.target.closest && event.target.closest(\'[contenteditable="false"]\');',
' if (uneditable && uneditable !== editor) return;',
' selectionDroppedOnBlur = false;',
' if (document.activeElement === editor) return;',
' // Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.',
' var caret = caretRangeAtPoint(event.clientX, event.clientY);',
' focusEditor();',
' if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);',
' return;',
' }',
' if (!editable) {',
' event.preventDefault();',
' return;',
' }',
" var li = input.closest('li');",
" if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');",
' emitChange();',
' });',
" editor.addEventListener('keydown', function (event) {",
" if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {",
' event.preventDefault();',
" runCommand('bold');",
' }',
' });',
'',
' window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };',
''
].join('\n')
export const MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END = [
'',
" post({ type: 'ready' });",
' })();',
' </script>',
'</body>',
'</html>'
].join('\n')
@@ -2,6 +2,12 @@ import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY } from './mobile-rich-markdown-editor-body-primary'
import { MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY } from './mobile-rich-markdown-editor-body-secondary'
import {
MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS,
MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END
} from './mobile-rich-markdown-editor-document-suffix'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -24,644 +30,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
--border: ${colors.borderSubtle};
--primary: ${colors.textPrimary};
--primary-foreground: ${colors.bgBase};
--accent-link: ${colors.accentBlue};
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans: Geist, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%;
min-height: 100%;
margin: 0;
background: var(--editor-surface);
color: var(--foreground);
font-family: var(--font-sans);
overscroll-behavior: contain;
}
body { overflow: auto; }
#editor {
min-height: 100vh;
padding: 18px 16px 112px;
outline: none;
font-size: 14px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: anywhere;
caret-color: var(--foreground);
}
#editor[contenteditable="false"] {
opacity: 0.78;
}
#editor:empty::before,
#editor p.is-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
}
#editor > :first-child { margin-top: 0; }
h1, h2, h3, h4, h5, h6 {
margin: 1.5em 0 0.5em;
font-weight: 600;
line-height: 1.3;
letter-spacing: 0;
}
h1 { font-size: 1.85em; font-weight: 700; }
h2 { font-size: 1.4em; }
h3 { font-size: 1.15em; }
p, ul, ol, blockquote { margin: 0.75em 0; }
ul, ol { padding-left: 1.5em; }
ul { list-style: disc; }
ol { list-style: decimal; }
li { margin: 0.15em 0; }
li > p { margin: 0; }
ul[data-type="taskList"] {
padding-left: 0;
list-style: none;
}
ul[data-type="taskList"] > li {
display: flex;
align-items: flex-start;
gap: 6px;
}
ul[data-type="taskList"] > li > label {
flex-shrink: 0;
display: flex;
align-items: center;
height: 1.55em;
user-select: none;
}
ul[data-type="taskList"] input[type="checkbox"] {
appearance: none;
width: 16px;
height: 16px;
margin: 0;
border: 1.5px solid color-mix(in srgb, var(--foreground) 55%, transparent);
border-radius: 4px;
background: transparent;
position: relative;
}
ul[data-type="taskList"] input[type="checkbox"]:checked {
background: var(--primary);
border-color: var(--primary);
}
ul[data-type="taskList"] input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid var(--primary-foreground);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
ul[data-type="taskList"] input[type="checkbox"]:disabled {
opacity: 0.65;
}
ul[data-type="taskList"] > li > div {
flex: 1;
min-width: 0;
}
ul[data-type="taskList"] > li[data-checked="true"] > div {
text-decoration: line-through;
color: var(--muted-foreground);
}
blockquote {
padding: 0.5em 1em;
border-left: 3px solid var(--border);
border-radius: 0 6px 6px 0;
color: var(--muted-foreground);
background: color-mix(in srgb, var(--foreground) 2%, transparent);
}
table {
width: 100%;
margin: 1em 0;
border-collapse: collapse;
font-size: 0.95em;
}
th, td {
padding: 8px 14px;
border: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
font-weight: 600;
background: color-mix(in srgb, var(--foreground) 4%, transparent);
}
tr:nth-child(odd) td {
background: color-mix(in srgb, var(--foreground) 1.5%, transparent);
}
code {
padding: 0.2em 0.4em;
border-radius: 5px;
background: color-mix(in srgb, var(--foreground) 8%, transparent);
font-size: 0.88em;
font-family: var(--font-mono);
}
pre {
margin: 0.75em 0;
padding: 14px 18px;
border-radius: 8px;
border: 1px solid color-mix(in srgb, var(--foreground) 6%, transparent);
overflow-x: auto;
line-height: 1.55;
background: color-mix(in srgb, var(--foreground) 6%, transparent);
font-family: var(--font-mono);
white-space: pre-wrap;
}
pre::before {
content: attr(data-language);
display: block;
min-height: 13px;
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 11px;
text-transform: uppercase;
}
pre code {
padding: 0;
border-radius: 0;
background: transparent;
font-size: 0.92em;
white-space: pre-wrap;
}
hr {
margin: 1.5em 0;
border: none;
border-top: 1px solid var(--border);
}
a {
color: var(--accent-link);
text-decoration: underline;
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
text-underline-offset: 2px;
}
img {
display: block;
max-width: 100%;
margin: 0.75em 0;
border-radius: 8px;
}
</style>
</head>
<body>
<main id="editor" contenteditable="true" data-placeholder="Start writing..."></main>
<script>
(function () {
var editor = document.getElementById('editor');
var lastMarkdown = '';
var inputTimer = null;
var documentGeneration = 0;
var editable = true;
var suppressInput = false;
function post(message) {
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify(message));
}
function decodeMarkdownEntities(value) {
return String(value).replace(/&(#x[0-9a-f]+|#\\d+|amp|lt|gt|quot|apos);/gi, function (match, entity) {
var lower = String(entity).toLowerCase();
if (lower === 'amp') return '&';
if (lower === 'lt') return '<';
if (lower === 'gt') return '>';
if (lower === 'quot') return '"';
if (lower === 'apos') return "'";
if (lower.indexOf('#x') === 0) {
var hex = Number.parseInt(lower.slice(2), 16);
return Number.isFinite(hex) && hex >= 0 && hex <= 0x10ffff ? String.fromCodePoint(hex) : match;
}
if (lower.indexOf('#') === 0) {
var code = Number.parseInt(lower.slice(1), 10);
return Number.isFinite(code) && code >= 0 && code <= 0x10ffff ? String.fromCodePoint(code) : match;
}
return match;
});
}
function escapeHtml(value) {
return decodeMarkdownEntities(value).replace(/[&<>"']/g, function (char) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char];
});
}
function escapeAttr(value) {
return escapeHtml(value).replace(/\\n/g, ' ');
}
function isSafeUrl(value) {
var trimmed = String(value || '').trim();
return !/^javascript:/i.test(trimmed);
}
function splitTableRow(line) {
return line.trim().replace(/^\\|/, '').replace(/\\|$/, '').split('|').map(function (cell) {
return cell.trim();
});
}
function isTableSeparator(line) {
var cells = splitTableRow(line);
return cells.length > 0 && cells.every(function (cell) {
return /^:?-{3,}:?$/.test(cell);
});
}
function renderInline(text) {
var output = '';
var pattern = /(!\\[[^\\]]*\\]\\([^)]+\\)|\`[^\`]+\`|~~[^~]+~~|\\*\\*[^*]+\\*\\*|__[^_]+__|\\*[^*\\n]+\\*|_[^_\\n]+_|\\[[^\\]]+\\]\\([^)]+\\)|https?:\\/\\/[^\\s<]+)/g;
var lastIndex = 0;
var match;
while ((match = pattern.exec(text))) {
output += escapeHtml(text.slice(lastIndex, match.index));
var token = match[0];
var image = token.match(/^!\\[([^\\]]*)\\]\\(([^)]+)\\)$/);
var link = token.match(/^\\[([^\\]]+)\\]\\(([^)]+)\\)$/);
if (image && isSafeUrl(image[2])) {
output += '<img src="' + escapeAttr(image[2]) + '" alt="' + escapeAttr(image[1] || '') + '" />';
} else if (link && isSafeUrl(link[2])) {
output += '<a href="' + escapeAttr(link[2]) + '">' + renderInline(link[1]) + '</a>';
} else if (/^https?:\\/\\//i.test(token)) {
output += '<a href="' + escapeAttr(token) + '">' + escapeHtml(token) + '</a>';
} else if (token.indexOf('\`') === 0) {
output += '<code>' + escapeHtml(token.slice(1, -1)) + '</code>';
} else if (token.indexOf('~~') === 0) {
output += '<s>' + renderInline(token.slice(2, -2)) + '</s>';
} else if (token.indexOf('**') === 0 || token.indexOf('__') === 0) {
output += '<strong>' + renderInline(token.slice(2, -2)) + '</strong>';
} else {
output += '<em>' + renderInline(token.slice(1, -1)) + '</em>';
}
lastIndex = pattern.lastIndex;
}
output += escapeHtml(text.slice(lastIndex));
return output;
}
function isBlockStart(line) {
return /^(\`\`\`|#{1,6}\\s+|>\\s?|\\s*(?:[-*+]|\\d+[.)])\\s+|\\s*(-{3,}|\\*{3,}|_{3,})\\s*$)/.test(line);
}
function indentationWidth(value) {
return String(value || '').replace(/\\t/g, ' ').length;
}
function parseListLine(line) {
var match = String(line || '').match(/^(\\s*)((?:[-*+])|(?:\\d+[.)]))\\s+(.+)$/);
if (!match) return null;
var rawText = match[3] || '';
var task = rawText.match(/^\\[([ xX])\\]\\s+(.+)$/);
return {
indent: indentationWidth(match[1] || ''),
ordered: /^\\d/.test(match[2] || ''),
orderedNumber: /^\\d/.test(match[2] || '') ? Number.parseInt(match[2], 10) : null,
task: task ? task[1].toLowerCase() === 'x' : null,
text: task ? task[2] : rawText,
children: []
};
}
function listKind(item) {
if (item.task !== null) return 'task';
return item.ordered ? 'ol' : 'ul';
}
function parseListTree(lines, startIndex) {
var root = { indent: -1, children: [] };
var stack = [root];
var index = startIndex;
while (index < lines.length) {
var item = parseListLine(lines[index] || '');
if (!item) break;
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
stack.pop();
}
stack[stack.length - 1].children.push(item);
stack.push(item);
index += 1;
}
return { items: root.children, nextIndex: index };
}
function renderListItems(items) {
var html = [];
var index = 0;
while (index < items.length) {
var kind = listKind(items[index]);
var group = [];
while (index < items.length && listKind(items[index]) === kind) {
group.push(items[index]);
index += 1;
}
var tag = kind === 'ol' ? 'ol' : 'ul';
var attrs = kind === 'task' ? ' data-type="taskList"' : kind === 'ol' && group[0].orderedNumber !== null ? ' start="' + group[0].orderedNumber + '"' : '';
html.push('<' + tag + attrs + '>' + group.map(function (item) {
var children = item.children.length ? renderListItems(item.children) : '';
if (kind === 'task') {
var checked = item.task === true;
return '<li data-checked="' + String(checked) + '"><label contenteditable="false"><input type="checkbox" ' + (checked ? 'checked ' : '') + (editable ? '' : 'disabled ') + '/></label><div><p>' + renderInline(item.text) + '</p>' + children + '</div></li>';
}
var orderedAttrs = kind === 'ol' && item.orderedNumber !== null ? ' value="' + item.orderedNumber + '" data-list-number="' + item.orderedNumber + '"' : '';
return '<li' + orderedAttrs + '><p>' + renderInline(item.text) + '</p>' + children + '</li>';
}).join('') + '</' + tag + '>');
}
return html.join('');
}
function markdownToHtml(markdown) {
var lines = String(markdown || '').replace(/\\r\\n?/g, '\\n').split('\\n');
var html = [];
var index = 0;
while (index < lines.length) {
var line = lines[index] || '';
if (!line.trim()) {
index += 1;
continue;
}
var fence = line.match(/^\\\`\`\`([^\\s\`]*)\\s*$/);
if (fence) {
index += 1;
var code = [];
while (index < lines.length && !/^\\\`\`\`\\s*$/.test(lines[index] || '')) {
code.push(lines[index] || '');
index += 1;
}
if (index < lines.length) index += 1;
html.push('<pre data-language="' + escapeAttr(fence[1] || '') + '"><code>' + escapeHtml(code.join('\\n')) + '</code></pre>');
continue;
}
if (/^\\s*(-{3,}|\\*{3,}|_{3,})\\s*$/.test(line)) {
html.push('<hr />');
index += 1;
continue;
}
if (line.indexOf('|') >= 0 && index + 1 < lines.length && isTableSeparator(lines[index + 1] || '')) {
var headers = splitTableRow(line);
index += 2;
var rows = [];
while (index < lines.length && (lines[index] || '').indexOf('|') >= 0 && (lines[index] || '').trim()) {
rows.push(splitTableRow(lines[index] || ''));
index += 1;
}
html.push('<table><thead><tr>' + headers.map(function (cell) { return '<th>' + renderInline(cell) + '</th>'; }).join('') + '</tr></thead><tbody>' + rows.map(function (row) {
return '<tr>' + headers.map(function (_, cellIndex) { return '<td>' + renderInline(row[cellIndex] || '') + '</td>'; }).join('') + '</tr>';
}).join('') + '</tbody></table>');
continue;
}
var heading = line.match(/^(#{1,6})\\s+(.+)$/);
if (heading) {
html.push('<h' + heading[1].length + '>' + renderInline(heading[2].trim()) + '</h' + heading[1].length + '>');
index += 1;
continue;
}
if (/^>\\s?/.test(line)) {
var quote = [];
while (index < lines.length && /^>\\s?/.test(lines[index] || '')) {
quote.push((lines[index] || '').replace(/^>\\s?/, ''));
index += 1;
}
html.push('<blockquote><p>' + renderInline(quote.join('\\n').trim()).replace(/\\n/g, '<br />') + '</p></blockquote>');
continue;
}
if (/^\\s*(?:[-*+]|\\d+[.)])\\s+/.test(line)) {
var list = parseListTree(lines, index);
html.push(renderListItems(list.items));
index = list.nextIndex;
continue;
}
var paragraph = [];
while (index < lines.length && (lines[index] || '').trim() && !isBlockStart(lines[index] || '') && !(index + 1 < lines.length && (lines[index] || '').indexOf('|') >= 0 && isTableSeparator(lines[index + 1] || ''))) {
paragraph.push(lines[index] || '');
index += 1;
}
html.push('<p>' + renderInline(paragraph.join('\\n')).replace(/\\n/g, '<br />') + '</p>');
}
return html.join('\\n') || '<p class="is-empty"><br /></p>';
}
function textContent(node) {
return (node.textContent || '').replace(/\\u00a0/g, ' ');
}
function inlineMarkdown(node) {
if (!node) return '';
if (node.nodeType === Node.TEXT_NODE) return textContent(node);
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag === 'br') return '\\n';
if (tag === 'strong' || tag === 'b') return '**' + inlineChildren(el) + '**';
if (tag === 'em' || tag === 'i') return '*' + inlineChildren(el) + '*';
if (tag === 's' || tag === 'del' || tag === 'strike') return '~~' + inlineChildren(el) + '~~';
if (tag === 'code' && el.parentElement && el.parentElement.tagName.toLowerCase() !== 'pre') return '\`' + textContent(el) + '\`';
if (tag === 'a') return '[' + inlineChildren(el) + '](' + (el.getAttribute('href') || '') + ')';
if (tag === 'img') return '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')';
if (tag === 'label') return '';
return inlineChildren(el);
}
function inlineChildren(el) {
return Array.prototype.map.call(el.childNodes, inlineMarkdown).join('');
}
function listItemText(li) {
var clone = li.cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('label'), function (label) { label.remove(); });
Array.prototype.forEach.call(clone.querySelectorAll('ul, ol'), function (list) { list.remove(); });
return inlineChildren(clone).trim();
}
function directNestedLists(li) {
return Array.prototype.filter.call(li.querySelectorAll('ul, ol'), function (list) {
return list.closest('li') === li;
});
}
function listMarkdown(el, depth) {
var tag = el.tagName.toLowerCase();
var isTask = el.getAttribute('data-type') === 'taskList';
var orderedStart = tag === 'ol' ? Number.parseInt(el.getAttribute('start') || '1', 10) : 1;
if (!Number.isFinite(orderedStart)) orderedStart = 1;
var indent = ' '.repeat(depth);
return Array.prototype.map.call(el.children, function (li, index) {
if (li.tagName.toLowerCase() !== 'li') return '';
var marker;
if (isTask) {
var input = li.querySelector('input[type="checkbox"]');
marker = '- [' + (input && input.checked ? 'x' : ' ') + '] ';
} else {
var listNumber =
li.getAttribute &&
(li.getAttribute('data-list-number') || li.getAttribute('value'));
marker = tag === 'ol' ? String(listNumber || orderedStart + index) + '. ' : '- ';
}
var line = indent + marker + listItemText(li);
var nested = directNestedLists(li).map(function (list) {
return listMarkdown(list, depth + 1);
}).filter(Boolean).join('\\n');
return nested ? line + '\\n' + nested : line;
}).filter(Boolean).join('\\n');
}
function blockMarkdown(node) {
if (node.nodeType === Node.TEXT_NODE) return textContent(node).trim();
if (node.nodeType !== Node.ELEMENT_NODE) return '';
var el = node;
var tag = el.tagName.toLowerCase();
if (tag.match(/^h[1-6]$/)) return '#'.repeat(Number(tag.slice(1))) + ' ' + inlineChildren(el).trim();
if (tag === 'p' || tag === 'div') return inlineChildren(el).trim();
if (tag === 'blockquote') {
return inlineChildren(el).trim().split('\\n').map(function (line) { return '> ' + line; }).join('\\n');
}
if (tag === 'pre') {
var lang = el.getAttribute('data-language') || '';
var code = textContent(el.querySelector('code') || el).replace(/\\n+$/g, '');
return '\`\`\`' + lang + '\\n' + code + '\\n\`\`\`';
}
if (tag === 'ul' || tag === 'ol') {
return listMarkdown(el, 0);
}
if (tag === 'table') {
var rows = Array.prototype.slice.call(el.querySelectorAll('tr'));
if (rows.length === 0) return '';
var cellsFor = function (row) {
return Array.prototype.map.call(row.children, function (cell) { return inlineChildren(cell).trim(); });
};
var headers = cellsFor(rows[0]);
var bodyRows = rows.slice(1).map(cellsFor);
return '| ' + headers.join(' | ') + ' |\\n| ' + headers.map(function () { return '---'; }).join(' | ') + ' |' + (bodyRows.length ? '\\n' + bodyRows.map(function (row) { return '| ' + row.join(' | ') + ' |'; }).join('\\n') : '');
}
if (tag === 'hr') return '---';
if (tag === 'img') return inlineMarkdown(el);
return inlineChildren(el).trim();
}
function currentMarkdown() {
return Array.prototype.map.call(editor.childNodes, blockMarkdown).filter(function (block) {
return block.trim().length > 0;
}).join('\\n\\n').trimEnd();
}
function syncTaskCheckboxesDisabled() {
Array.prototype.forEach.call(editor.querySelectorAll('input[type="checkbox"]'), function (input) {
input.disabled = !editable;
});
}
function emitChange() {
if (suppressInput || !editable) return;
window.clearTimeout(inputTimer);
var pendingGeneration = documentGeneration;
lastMarkdown = currentMarkdown();
post({ type: 'change', markdown: lastMarkdown, generation: pendingGeneration });
}
function setMarkdown(markdown, generation) {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
// Why: replacing innerHTML detaches the remembered caret's nodes.
savedSelectionRange = null;
selectionDroppedOnBlur = false;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
suppressInput = false;
}
function setEditable(nextEditable) {
editable = Boolean(nextEditable);
editor.setAttribute('contenteditable', editable ? 'true' : 'false');
syncTaskCheckboxesDisabled();
}
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
if (command === 'paragraph') document.execCommand('formatBlock', false, 'p');
else if (command === 'heading1') document.execCommand('formatBlock', false, 'h1');
else if (command === 'heading2') document.execCommand('formatBlock', false, 'h2');
else if (command === 'heading3') document.execCommand('formatBlock', false, 'h3');
else if (command === 'bold') document.execCommand('bold');
else if (command === 'italic') document.execCommand('italic');
else if (command === 'strike') document.execCommand('strikeThrough');
else if (command === 'bulletList') document.execCommand('insertUnorderedList');
else if (command === 'orderedList') document.execCommand('insertOrderedList');
else if (command === 'quote') document.execCommand('formatBlock', false, 'blockquote');
else if (command === 'inlineCode') wrapSelection('code');
else if (command === 'codeBlock') document.execCommand('insertHTML', false, '<pre data-language=""><code>code</code></pre><p><br></p>');
else if (command === 'taskList') document.execCommand('insertHTML', false, '<ul data-type="taskList"><li data-checked="false"><label contenteditable="false"><input type="checkbox" /></label><div><p>Task</p></div></li></ul>');
else if (command === 'link') {
var href = window.prompt('Link URL');
if (href && isSafeUrl(href)) document.execCommand('createLink', false, href);
} else if (command === 'image') {
var src = window.prompt('Image URL');
if (src && isSafeUrl(src)) document.execCommand('insertImage', false, src);
}
syncTaskCheckboxesDisabled();
emitChange();
}
editor.addEventListener('input', function () {
selectionDroppedOnBlur = false;
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (input) {
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
}
if (editable) emitChange();
});
editor.addEventListener('click', function (event) {
var link = event.target && event.target.closest && event.target.closest('a[href]');
if (link) {
event.preventDefault();
post({ type: 'openLink', url: link.getAttribute('href') || '' });
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) {
if (!editable) return;
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
if (uneditable && uneditable !== editor) return;
selectionDroppedOnBlur = false;
if (document.activeElement === editor) return;
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
var caret = caretRangeAtPoint(event.clientX, event.clientY);
focusEditor();
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
return;
}
if (!editable) {
event.preventDefault();
return;
}
var li = input.closest('li');
if (li) li.setAttribute('data-checked', input.checked ? 'true' : 'false');
emitChange();
});
editor.addEventListener('keydown', function (event) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'b') {
event.preventDefault();
runCommand('bold');
}
});
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
</script>
</body>
</html>`
--accent-link: ${colors.accentBlue}${MOBILE_RICH_MARKDOWN_EDITOR_BODY_PRIMARY}
${MOBILE_RICH_MARKDOWN_EDITOR_BODY_SECONDARY}${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_AFTER_KEYBOARD_DISMISS}${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}${MOBILE_RICH_MARKDOWN_EDITOR_DOCUMENT_END}`
}
@@ -16,6 +16,7 @@ import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import type { WorkspaceCreateSetupDecision } from '../tasks/workspace-create-params'
import type { WorkspaceSshGate } from '../tasks/workspace-ssh-gate'
import type { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { WorktreeCreateIdempotencySupport } from '../tasks/worktree-create-idempotency-policy'
import {
pickPreferredNewWorktreeAgent,
type NewWorktreeAgentOption,
@@ -54,7 +55,7 @@ export function useNewWorkspaceCreateSubmit(args: {
runSetup: boolean
trustedOrcaHooks: PersistedTrustedOrcaHooks
setTrustedOrcaHooks: (trust: PersistedTrustedOrcaHooks) => void
getWorktreeCreateCutoverSupport: () => Promise<boolean>
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
transitionDrawer: (view: Exclude<NewWorktreeDrawerView, 'transition'>) => void
setError: Dispatch<SetStateAction<string>>
onCreated: (worktreeId: string, name: string) => void
@@ -163,7 +164,7 @@ export function useNewWorkspaceCreateSubmit(args: {
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: args.composer.isNameAutoManaged,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
: await createBlankWorkspace({
client,
@@ -173,7 +174,7 @@ export function useNewWorkspaceCreateSubmit(args: {
createdWithAgentId,
comment: trimmedNote,
setupDecision,
supportsIdempotentCutoverRetry: args.getWorktreeCreateCutoverSupport()
worktreeCreateIdempotency: args.getWorktreeCreateCutoverSupport()
})
if ('error' in result) {
args.setError(result.error)
@@ -0,0 +1,240 @@
import { describe, expect, it } from 'vitest'
import {
diagnoseConnection,
getReportableConnectionIncidentId
} from './connection-diagnostics-analysis'
import type { ConnectionLogEntry } from '../transport/types'
function event(message: string, detail?: string): ConnectionLogEntry {
return { id: message, ts: 1, level: 'error', message, detail }
}
describe('diagnoseConnection', () => {
it('reports the current healthy path instead of a historical failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'connected',
activePath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'Connection is healthy via Relay.',
nextStep: 'No action needed.',
reportability: 'none'
})
})
it('distinguishes an invalid Relay credential from a transient outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (401)')]
})
).toEqual({
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
})
})
it('identifies the direct Tailscale timeout while Relay recovery is pending', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [event('WebSocket connect timeout')]
})
).toEqual({
likelyCause: 'The saved Tailscale endpoint did not answer before the connection timeout.',
nextStep: 'Relay recovery is in progress; keep Orca open while it retries.',
reportability: 'none'
})
})
it('identifies an authenticated Relay liveness failure', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'Relay stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'orca-relay'
})
})
it('separates an active Relay close from a failed Relay dial', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay: active relay session failed', 'RelayOuterError: close code 4408'),
code: 'relay-session-failed',
path: 'relay'
}
]
})
).toEqual({
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability: 'orca-relay'
})
})
it('marks authenticated Relay liveness failures as safe to send', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
}
]
}).reportability
).toBe('orca-relay')
})
it.each([
['direct timeout', 'connect-timeout', 'tailscale'],
['handshake timeout', 'handshake-timeout', 'relay'],
['invalid credential', 'relay director resolve failed (401)', 'relay'],
['ambiguous recovery', 'retry scheduled', 'relay']
] as const)('does not offer submission for %s', (_name, message, path) => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [{ ...event(message), path }]
}).reportability
).toBe('none')
})
it('does not offer submission for a bounded Relay director outage', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [event('Relay: relay dial failed', 'relay director resolve failed (503)')]
}).reportability
).toBe('none')
})
it('classifies the newest failure instead of an older persisted 401', () => {
expect(
diagnoseConnection({
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
pendingPath: 'relay',
entries: [
event('Relay: relay dial failed', 'relay director resolve failed (401)'),
{ ...event('WebSocket connect timeout'), id: 'newer', ts: 2, code: 'connect-timeout' }
]
}).likelyCause
).toBe('The saved Tailscale endpoint did not answer before the connection timeout.')
})
it('requires structured Relay evidence before offering submission', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [event('active relay session failed')]
}).reportability
).toBe('none')
})
it('ignores failures from before the current app resume boundary', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'connecting',
activePath: 'lan',
entries: [
{
...event('Relay health check failed'),
code: 'liveness-timeout',
path: 'relay'
},
{
...event('App returned to foreground'),
id: 'resume',
ts: 2,
code: 'app-resumed'
},
{ ...event('WebSocket closed'), id: 'closed', ts: 3, code: 'socket-closed' }
]
}).reportability
).toBe('none')
})
it('uses a structured direct liveness path ahead of the current active path', () => {
expect(
diagnoseConnection({
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
activePath: 'relay',
entries: [
{
...event('Connection health check failed'),
code: 'liveness-timeout',
path: 'lan'
}
]
})
).toEqual({
likelyCause: 'The connected host stopped answering authenticated health checks.',
nextStep: 'Orca closed the stale session and started recovery.',
reportability: 'none'
})
})
it('keys reportability to the current structured incident', () => {
const args = {
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting' as const,
activePath: 'relay' as const,
entries: [
{ ...event('Authenticated'), code: 'direct-connected' as const, path: 'lan' as const },
{
...event('Relay health check failed'),
id: 'current-relay-failure',
ts: 2,
code: 'liveness-timeout' as const,
path: 'relay' as const
}
]
}
expect(getReportableConnectionIncidentId(args)).toBe('current-relay-failure')
expect(
getReportableConnectionIncidentId({
...args,
entries: [...args.entries, { ...event('Network changed'), ts: 3, code: 'network-changed' }]
})
).toBeNull()
})
})
@@ -0,0 +1,168 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
export type ConnectionDiagnosis = {
likelyCause: string
nextStep: string
reportability: 'none' | 'orca-relay'
}
type DiagnoseConnectionArgs = {
endpoint: string
state: ConnectionState
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
entries: readonly ConnectionLogEntry[]
}
export function diagnoseConnection(args: DiagnoseConnectionArgs): ConnectionDiagnosis {
if (args.state === 'connected') {
return {
likelyCause: `Connection is healthy${args.activePath ? ` via ${formatPath(args.activePath)}` : ''}.`,
nextStep: 'No action needed.',
reportability: 'none'
}
}
const failure = findCurrentDiagnosticFailure(args.entries)
const evidence = failure ? `${failure.code ?? ''} ${failure.message} ${failure.detail ?? ''}` : ''
if (/relay director resolve failed \(401\)/i.test(evidence)) {
return {
likelyCause: 'Relay rejected the saved resume credential.',
nextStep: 'Try a direct connection; if Relay keeps returning 401, pair this device again.',
reportability: 'none'
}
}
if (/relay director resolve failed \(503\)/i.test(evidence)) {
const retryMs = parseRetryDelayMs(evidence)
return {
likelyCause: `Relay service was temporarily unavailable${retryMs == null ? '.' : ` and asked Orca to retry in ${formatDelay(retryMs)}.`}`,
nextStep: 'Keep Orca open; recovery should retry automatically.',
reportability: 'none'
}
}
if (/liveness-timeout|liveness timeout|connection health check failed/i.test(evidence)) {
const relayLiveness = failure?.code === 'liveness-timeout' && failure.path === 'relay'
const structuredDirectLiveness =
failure?.code === 'liveness-timeout' &&
(failure.path === 'lan' || failure.path === 'tailscale')
const path =
relayLiveness || (!structuredDirectLiveness && args.activePath === 'relay')
? 'Relay'
: 'The connected host'
return {
likelyCause: `${path} stopped answering authenticated health checks.`,
nextStep: 'Orca closed the stale session and started recovery.',
reportability: relayLiveness ? 'orca-relay' : 'none'
}
}
if (/relay-session-failed|active relay session failed/i.test(evidence)) {
return {
likelyCause: 'The active Relay session closed unexpectedly.',
nextStep: 'Orca started Relay recovery; the event history includes the cell close reason.',
reportability:
failure?.code === 'relay-session-failed' && failure.path === 'relay' ? 'orca-relay' : 'none'
}
}
if (/authentication-rejected|unauthorized|pairing may be revoked/i.test(evidence)) {
return {
likelyCause: 'The desktop rejected this device during authentication.',
nextStep: 'Confirm the device is still paired; pair it again if the rejection repeats.',
reportability: 'none'
}
}
if (/connect-timeout|websocket connect timeout/i.test(evidence)) {
return {
likelyCause: isTailscaleEndpoint(args.endpoint)
? 'The saved Tailscale endpoint did not answer before the connection timeout.'
: 'The saved direct endpoint did not answer before the connection timeout.',
nextStep:
args.pendingPath === 'relay'
? 'Relay recovery is in progress; keep Orca open while it retries.'
: 'Check the local/VPN network and confirm the desktop is awake.',
reportability: 'none'
}
}
if (/handshake-timeout|handshake timeout/i.test(evidence)) {
return {
likelyCause: 'The endpoint opened, but the encrypted Orca handshake did not finish.',
nextStep: 'Confirm the desktop is running a compatible Orca version and retry.',
reportability: 'none'
}
}
if (args.pendingPath === 'relay') {
return {
likelyCause: 'Relay recovery is selected, but no more specific failure is recorded yet.',
nextStep: 'Keep this page open while the next recovery event is recorded.',
reportability: 'none'
}
}
return {
likelyCause: 'No single failure cause can be determined from the recorded events.',
nextStep: 'Run diagnostics and copy the report again after the next connection attempt.',
reportability: 'none'
}
}
export function getReportableConnectionIncidentId(args: DiagnoseConnectionArgs): string | null {
if (diagnoseConnection(args).reportability !== 'orca-relay') {
return null
}
return findCurrentDiagnosticFailure(args.entries)?.id ?? null
}
function findCurrentDiagnosticFailure(
entries: readonly ConnectionLogEntry[]
): ConnectionLogEntry | undefined {
const boundaryIndex = entries.findLastIndex(isDiagnosticBoundary)
return entries
.slice(boundaryIndex + 1)
.toReversed()
.find(isDiagnosticFailure)
}
function isDiagnosticBoundary(entry: ConnectionLogEntry): boolean {
return (
entry.code === 'client-session-started' ||
entry.code === 'app-resumed' ||
entry.code === 'network-changed' ||
entry.code === 'relay-connected' ||
entry.code === 'direct-connected' ||
entry.message === 'Authenticated'
)
}
function isDiagnosticFailure(entry: ConnectionLogEntry): boolean {
const evidence = `${entry.code ?? ''} ${entry.message} ${entry.detail ?? ''}`
return /relay director resolve failed \((?:401|503)\)|liveness-timeout|liveness timeout|connection health check failed|relay-session-failed|active relay session failed|authentication-rejected|unauthorized|pairing may be revoked|connect-timeout|websocket connect timeout|handshake-timeout|handshake timeout/i.test(
evidence
)
}
function parseRetryDelayMs(evidence: string): number | null {
const match = /retry(?:-|\s)?after(?:=|\s)(\d+)ms/i.exec(evidence)
return match ? Number(match[1]) : null
}
function formatDelay(ms: number): string {
return ms < 60_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / 60_000)}m`
}
function formatPath(path: MobileConnectionDiagnosticPath): string {
if (path === 'relay') {
return 'Relay'
}
return path === 'tailscale' ? 'Tailscale/direct' : 'LAN/direct'
}
@@ -13,6 +13,7 @@ describe('buildConnectionDiagnosticsReport', () => {
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
desktopAppVersion: '1.4.191',
entries: [
{
id: 'log-1',
@@ -26,6 +27,7 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
expect(report).toContain('Host Orca version: 1.4.191')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
@@ -48,8 +50,96 @@ describe('buildConnectionDiagnosticsReport', () => {
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
expect(report).toContain('Host Orca version: unknown')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
expect(report).toContain('No connection events recorded this session.')
expect(report).toContain('No connection events recorded.')
})
it('explains the most likely cause and redacts credentials before copying', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 3',
endpoint: 'ws://100.88.90.25:6768',
state: 'reconnecting',
reconnectAttempts: 4,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.46',
activePath: 'tailscale',
pendingPath: 'relay',
entries: [
{
id: 'relay-failure',
ts: NOW - 5_000,
level: 'error',
message: 'Relay: relay dial failed',
detail:
'RelayDirectorHttpError: relay director resolve failed (503); retry after 30000ms; resumeToken=secret-resume-token'
}
],
nowMs: NOW
})
expect(report).toContain(
'Likely cause: Relay service was temporarily unavailable and asked Orca to retry in 30s.'
)
expect(report).toContain('Path: active=tailscale; recovery=relay')
expect(report).toContain('Next step: Keep Orca open; recovery should retry automatically.')
expect(report).toContain('resumeToken=[redacted]')
expect(report).not.toContain('secret-resume-token')
})
it('redacts quoted JSON credentials and never echoes an invalid endpoint', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 4',
endpoint: 'not-a-url?token=endpoint-secret',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'json-secret',
ts: NOW,
level: 'error',
message: 'Relay failed',
detail: '{"resumeToken":"json-secret","authorization":"Bearer bearer-secret"}'
}
],
nowMs: NOW
})
expect(report).toContain('Endpoint: invalid endpoint')
expect(report).not.toContain('endpoint-secret')
expect(report).not.toContain('json-secret')
expect(report).not.toContain('bearer-secret')
})
it('bounds a single event line before submission while preserving its identity', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 5',
endpoint: 'ws://192.168.1.2:6768',
state: 'reconnecting',
reconnectAttempts: 1,
lastConnectedAt: null,
platform: 'android 36',
appVersion: '0.0.47',
entries: [
{
id: 'oversized',
ts: NOW,
level: 'error',
message: `newest oversized ${'😀'.repeat(2_000)}`
}
],
nowMs: NOW
})
expect(report).toContain('newest oversized')
expect(report).toContain('[truncated]')
expect(new TextEncoder().encode(report.split('\n').at(-1)!).byteLength).toBeLessThanOrEqual(
2048
)
})
})
@@ -1,6 +1,16 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
import type {
ConnectionLogEntry,
ConnectionState,
MobileConnectionDiagnosticPath
} from '../transport/types'
import { normalizeHostAppVersion } from '../transport/host-app-version-store'
import { formatEndpoint } from './host-reachability'
import { diagnoseConnection } from './connection-diagnostics-analysis'
import { redactConnectionLogEntry, redactConnectionLogText } from './connection-log-redaction'
const MAX_EVENT_LINE_BYTES = 2 * 1024
const EVENT_TRUNCATION_MARKER = ' … [truncated]'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
@@ -13,37 +23,83 @@ export function buildConnectionDiagnosticsReport(args: {
lastConnectedAt: number | null
platform: string
appVersion: string
desktopAppVersion?: string | null
entries: readonly ConnectionLogEntry[]
activePath?: MobileConnectionDiagnosticPath
pendingPath?: MobileConnectionDiagnosticPath | null
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
const entries = args.entries.map(redactConnectionLogEntry)
const diagnosis = diagnoseConnection({
endpoint: args.endpoint,
state: args.state,
activePath: args.activePath,
pendingPath: args.pendingPath,
entries
})
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
lines.push(`Host: ${args.hostName}`)
const desktopAppVersion = normalizeHostAppVersion(args.desktopAppVersion)
lines.push(`Host Orca version: ${desktopAppVersion ?? 'unknown'}`)
lines.push(`Host: ${redactConnectionLogText(args.hostName)}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
if (args.activePath) {
lines.push(
`Path: active=${args.activePath}${args.pendingPath ? `; recovery=${args.pendingPath}` : ''}`
)
}
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
if (args.entries.length === 0) {
lines.push('No connection events recorded this session.')
lines.push(`Likely cause: ${diagnosis.likelyCause}`)
lines.push(`Next step: ${diagnosis.nextStep}`)
lines.push('')
if (entries.length === 0) {
lines.push('No connection events recorded.')
} else {
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
for (const entry of args.entries) {
lines.push(`Recent connection history (${entries.length} events, oldest first):`)
for (const entry of entries) {
const detail = entry.detail ? ` — ${entry.detail}` : ''
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
const evidence = [entry.code, entry.path].filter(Boolean).join(' · ')
lines.push(
truncateUtf8WithMarker(
`${new Date(entry.ts).toISOString()} [${entry.level}]${evidence ? ` [${evidence}]` : ''} ${entry.message}${detail}`,
MAX_EVENT_LINE_BYTES,
EVENT_TRUNCATION_MARKER
)
)
}
}
return lines.join('\n')
}
function truncateUtf8WithMarker(value: string, maxBytes: number, marker: string): string {
if (new TextEncoder().encode(value).byteLength <= maxBytes) {
return value
}
const markerBytes = new TextEncoder().encode(marker).byteLength
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = new TextEncoder().encode(character).byteLength
if (bytes + characterBytes + markerBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return `${characters.join('')}${marker}`
}
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from 'vitest'
import {
getDiagnosticsSubmissionState,
readConnectionDiagnosticsSnapshot,
readHydratedConnectionLog,
resolveDiagnosticsHostId,
selectDiagnosticsHostId,
updateDiagnosticsSubmissionState
} from './connection-diagnostics-screen-data'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
const host = (id: string): HostProfile => ({
id,
name: id,
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
})
describe('connection diagnostics screen data', () => {
it('prefers a valid route host when navigation changes', () => {
expect(selectDiagnosticsHostId([host('a'), host('b')], 'b', 'a')).toBe('b')
expect(selectDiagnosticsHostId([host('a')], 'missing', 'missing')).toBe('a')
})
it('applies route changes synchronously without discarding a chip choice for the same route', () => {
const hosts = [host('a'), host('b')]
expect(resolveDiagnosticsHostId(hosts, undefined, null)).toBe('a')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'a' })).toBe('b')
expect(resolveDiagnosticsHostId(hosts, 'b', { hostId: 'a', requestedHostId: 'b' })).toBe('a')
})
it('does not revive a stale manual choice when routing away and back', () => {
const hosts = [host('a'), host('b'), host('c')]
const firstRouteA = {}
const routeC = {}
const secondRouteA = {}
const manualSelection = { hostId: 'b', requestedHostId: 'a', routeKey: firstRouteA }
expect(resolveDiagnosticsHostId(hosts, 'c', manualSelection, routeC)).toBe('c')
expect(resolveDiagnosticsHostId(hosts, 'a', manualSelection, secondRouteA)).toBe('a')
})
it('keeps async submission completion scoped to its host incident', () => {
let states = updateDiagnosticsSubmissionState({}, 'host-a:incident-1', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-b:incident-2', 'sending')
states = updateDiagnosticsSubmissionState(states, 'host-a:incident-1', 'sent')
expect(getDiagnosticsSubmissionState(states, 'host-a:incident-1')).toBe('sent')
expect(getDiagnosticsSubmissionState(states, 'host-b:incident-2')).toBe('sending')
})
it('waits for hydration and then reads the refreshed log', async () => {
const entries: ConnectionLogEntry[] = []
const store = {
hydrate: vi.fn(async () => {
entries.push({ id: 'stored', ts: 1, level: 'info', message: 'stored event' })
}),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.get).toHaveBeenCalledAfter(store.hydrate)
})
it('retries one transient hydration failure before reading', async () => {
const entries = [{ id: 'stored', ts: 1, level: 'info' as const, message: 'stored event' }]
const store = {
hydrate: vi
.fn()
.mockRejectedValueOnce(new Error('storage unavailable'))
.mockResolvedValueOnce(undefined),
get: vi.fn(() => entries)
}
await expect(readHydratedConnectionLog(store, 'host-a')).resolves.toEqual(entries)
expect(store.hydrate).toHaveBeenCalledTimes(2)
})
it('reads connection metadata after hydration completes', async () => {
let state: 'connecting' | 'reconnecting' = 'connecting'
const store = {
hydrate: vi.fn(async () => {
state = 'reconnecting'
}),
get: vi.fn(() => [{ id: 'new', ts: 2, level: 'warn' as const, message: 'new event' }])
}
const context = {
getState: vi.fn(() => state),
getReconnectAttempt: vi.fn(() => 2),
getLastConnectedAt: vi.fn(() => 1),
getActivePath: vi.fn(() => 'relay' as const),
getPendingPath: vi.fn(() => null)
}
await expect(readConnectionDiagnosticsSnapshot(context, store, 'host-a')).resolves.toEqual({
state: 'reconnecting',
reconnectAttempts: 2,
lastConnectedAt: 1,
activePath: 'relay',
pendingPath: null,
entries: [{ id: 'new', ts: 2, level: 'warn', message: 'new event' }]
})
expect(context.getState).toHaveBeenCalledAfter(store.hydrate)
})
})
@@ -0,0 +1,94 @@
import type { ConnectionLogStore } from '../transport/connection-log-buffer'
import type { ConnectionLogEntry, HostProfile } from '../transport/types'
import type { RpcClientContextValue } from '../transport/rpc-client-context-contract'
export type DiagnosticsHostSelection = {
hostId: string
requestedHostId: string | undefined
routeKey?: object
}
export type DiagnosticsSubmissionState = 'sending' | 'sent' | 'failed'
export type DiagnosticsSubmissionStates = Readonly<Record<string, DiagnosticsSubmissionState>>
export function selectDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
previousHostId: string | null
): string | null {
if (requestedHostId && hosts.some((host) => host.id === requestedHostId)) {
return requestedHostId
}
if (previousHostId && hosts.some((host) => host.id === previousHostId)) {
return previousHostId
}
return hosts[0]?.id ?? null
}
export function resolveDiagnosticsHostId(
hosts: readonly HostProfile[],
requestedHostId: string | undefined,
manualSelection: DiagnosticsHostSelection | null,
routeKey?: object
): string | null {
const selected = manualSelection
if (selected && selected.requestedHostId === requestedHostId && selected.routeKey === routeKey) {
const manualHostId = selected.hostId
if (hosts.some((host) => host.id === manualHostId)) {
return manualHostId
}
}
return selectDiagnosticsHostId(hosts, requestedHostId, null)
}
export function getDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string | null
): DiagnosticsSubmissionState | 'idle' {
return key ? (states[key] ?? 'idle') : 'idle'
}
export function updateDiagnosticsSubmissionState(
states: DiagnosticsSubmissionStates,
key: string,
state: DiagnosticsSubmissionState | null
): DiagnosticsSubmissionStates {
const next = { ...states }
if (state) {
next[key] = state
} else {
delete next[key]
}
return next
}
export async function readHydratedConnectionLog(
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
): Promise<readonly ConnectionLogEntry[]> {
try {
await store.hydrate(hostId)
} catch {
await store.hydrate(hostId).catch(() => {})
}
return store.get(hostId)
}
export async function readConnectionDiagnosticsSnapshot(
context: Pick<
RpcClientContextValue,
'getState' | 'getReconnectAttempt' | 'getLastConnectedAt' | 'getActivePath' | 'getPendingPath'
>,
store: Pick<ConnectionLogStore, 'hydrate' | 'get'>,
hostId: string
) {
const entries = await readHydratedConnectionLog(store, hostId)
return {
state: context.getState(hostId),
reconnectAttempts: context.getReconnectAttempt(hostId),
lastConnectedAt: context.getLastConnectedAt(hostId),
activePath: context.getActivePath(hostId),
pendingPath: context.getPendingPath(hostId),
entries
}
}
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from 'vitest'
import {
boundConnectionDiagnosticsReport,
submitConnectionDiagnostics
} from './connection-diagnostics-submission'
describe('submitConnectionDiagnostics', () => {
it('sends a bounded report through the diagnostics lane', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 204 }))
const result = await submitConnectionDiagnostics(
{ report: 'x'.repeat(100_000), appVersion: '0.0.47', platform: 'android 36' },
fetchImpl
)
expect(result).toEqual({ ok: true })
expect(fetchImpl).toHaveBeenCalledWith(
'https://www.onorca.dev/v1/feedback',
expect.objectContaining({ method: 'POST' })
)
const request = fetchImpl.mock.calls[0]?.[1]
const body = JSON.parse(String(request?.body)) as { feedback: string; submissionType: string }
expect(body.submissionType).toBe('connection_diagnostics')
expect(new TextEncoder().encode(body.feedback).byteLength).toBeLessThanOrEqual(64 * 1024)
})
it('returns a safe failure for a rejected response', async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 }))
await expect(
submitConnectionDiagnostics(
{ report: 'report', appVersion: '0.0.47', platform: 'android' },
fetchImpl
)
).resolves.toEqual({ ok: false, error: 'status 503' })
})
it('preserves the newest complete events when bounding a UTF-8 report', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (3 events, oldest first):',
`oldest ${'😀'.repeat(20)}`,
`middle ${'😀'.repeat(20)}`,
`newest ${'😀'.repeat(20)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 180)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(180)
expect(bounded).toContain('newest')
expect(bounded).not.toContain('oldest')
expect(bounded).toMatch(/older omitted/)
})
it('retains a bounded form of the newest event when that event exceeds the budget', () => {
const report = [
'Orca Mobile connection diagnostics',
'State: reconnecting',
'',
'Recent connection history (2 events, oldest first):',
'old event',
`newest oversized ${'😀'.repeat(1_000)}`
].join('\n')
const bounded = boundConnectionDiagnosticsReport(report, 220)
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(220)
expect(bounded).toContain('newest oversized')
expect(bounded).toContain('[truncated]')
})
})
@@ -0,0 +1,125 @@
const CONNECTION_DIAGNOSTICS_ENDPOINT = 'https://www.onorca.dev/v1/feedback'
const SUBMISSION_TIMEOUT_MS = 10_000
const MAX_SUBMISSION_BYTES = 64 * 1024
export type ConnectionDiagnosticsSubmission = {
report: string
appVersion: string
platform: string
}
export type ConnectionDiagnosticsSubmissionResult = { ok: true } | { ok: false; error: string }
/** Sends only the already-redacted, bounded report after an explicit user tap. */
export async function submitConnectionDiagnostics(
submission: ConnectionDiagnosticsSubmission,
fetchImpl: typeof fetch = fetch
): Promise<ConnectionDiagnosticsSubmissionResult> {
const report = boundConnectionDiagnosticsReport(submission.report)
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SUBMISSION_TIMEOUT_MS)
try {
const response = await fetchImpl(CONNECTION_DIAGNOSTICS_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
feedback: report,
submissionType: 'connection_diagnostics',
githubLogin: null,
githubEmail: null,
appVersion: submission.appVersion,
platform: `mobile-${submission.platform}`,
osRelease: 'unknown',
arch: 'unknown'
}),
signal: controller.signal
})
return response.ok ? { ok: true } : { ok: false, error: `status ${response.status}` }
} catch (error) {
return {
ok: false,
error: controller.signal.aborted
? 'request timed out'
: error instanceof Error
? error.message
: 'request failed'
}
} finally {
clearTimeout(timeout)
}
}
export function boundConnectionDiagnosticsReport(
report: string,
maxBytes: number = MAX_SUBMISSION_BYTES
): string {
if (utf8Bytes(report) <= maxBytes) {
return report
}
const lines = report.split('\n')
const historyIndex = lines.findIndex((line) => line.startsWith('Recent connection history ('))
if (historyIndex === -1) {
return truncateUtf8(report, maxBytes)
}
const header = lines.slice(0, historyIndex)
const events = lines.slice(historyIndex + 1)
let kept: string[] = []
for (let index = events.length - 1; index >= 0; index--) {
const candidateEvents = [events[index]!, ...kept]
const candidate = formatBoundedReport(header, candidateEvents, events.length)
if (utf8Bytes(candidate) > maxBytes) {
break
}
kept = candidateEvents
}
if (kept.length === 0 && events.length > 0) {
return formatReportWithTruncatedNewestEvent(header, events, maxBytes)
}
return truncateUtf8(formatBoundedReport(header, kept, events.length), maxBytes)
}
function formatReportWithTruncatedNewestEvent(
header: string[],
events: string[],
maxBytes: number
): string {
const history = `Recent connection history (1 newest event; ${events.length - 1} older omitted):`
const prefix = [...header, history].join('\n') + '\n'
const availableBytes = maxBytes - utf8Bytes(prefix)
if (availableBytes <= 0) {
return truncateUtf8(prefix, maxBytes)
}
const marker = ' … [truncated]'
const markerBytes = utf8Bytes(marker)
if (availableBytes <= markerBytes) {
return truncateUtf8(prefix, maxBytes)
}
return `${prefix}${truncateUtf8(events.at(-1)!, availableBytes - markerBytes)}${marker}`
}
function formatBoundedReport(header: string[], events: string[], totalEvents: number): string {
const omitted = totalEvents - events.length
return [
...header,
`Recent connection history (${events.length} newest events; ${omitted} older omitted):`,
...events
].join('\n')
}
function truncateUtf8(value: string, maxBytes: number): string {
const characters: string[] = []
let bytes = 0
for (const character of value) {
const characterBytes = utf8Bytes(character)
if (bytes + characterBytes > maxBytes) {
break
}
characters.push(character)
bytes += characterBytes
}
return characters.join('')
}
function utf8Bytes(value: string): number {
return new TextEncoder().encode(value).byteLength
}
@@ -0,0 +1,51 @@
import type { ConnectionLogEntry } from '../transport/types'
const SECRET_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*\4/gi
const SECRET_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*\3/gi
const SECRET_UNTERMINATED_JSON_ASSIGNMENT =
/(["'])(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)\1(\s*:\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\4)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(["'])(?:Bearer\s+)?(?:\\.|(?!\3)[^\\\r\n])*(?=\r?\n|$)/gi
const SECRET_ASSIGNMENT =
/\b(resumeToken|deviceToken|authorization|credential|token|publicKeyB64)(\s*[:=]\s*)(?:Bearer\s+)?([^\s;,}"']+)/gi
const SECRET_QUERY = /([?&](?:token|credential|resumeToken|deviceToken)=)[^&#\s]+/gi
const URL_USERINFO = /((?:wss?|https?):\/\/)[^\s/?#]*@/gi
export function redactConnectionLogText(value: string): string {
return value
.replace(
SECRET_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]${valueQuote}`
)
.replace(
SECRET_UNTERMINATED_JSON_ASSIGNMENT,
(_match, keyQuote: string, label: string, separator: string, valueQuote: string) =>
`${keyQuote}${label}${keyQuote}${separator}${valueQuote}[redacted]`
)
.replace(
SECRET_UNTERMINATED_QUOTED_VALUE_ASSIGNMENT,
(_match, label: string, separator: string, valueQuote: string) =>
`${label}${separator}${valueQuote}[redacted]`
)
.replace(SECRET_ASSIGNMENT, (_match, label: string, separator: string) => {
return `${label}${separator}[redacted]`
})
.replace(SECRET_QUERY, '$1[redacted]')
.replace(URL_USERINFO, '$1[redacted]@')
}
export function redactConnectionLogEntry(entry: ConnectionLogEntry): ConnectionLogEntry {
return {
...entry,
message: redactConnectionLogText(entry.message),
...(entry.detail ? { detail: redactConnectionLogText(entry.detail) } : {})
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability, unreachableHostDetail } from './host-reachability'
import { formatEndpoint, testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
it('points at Tailscale for tailnet CGNAT endpoints', () => {
@@ -21,6 +21,10 @@ describe('unreachableHostDetail', () => {
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
})
it('does not echo malformed endpoints that may contain credentials', () => {
expect(formatEndpoint('not-a-url?token=secret')).toBe('invalid endpoint')
})
})
describe('testHostReachability', () => {
+1 -1
View File
@@ -55,7 +55,7 @@ export function formatEndpoint(endpoint: string): string {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
return 'invalid endpoint'
}
}
+2
View File
@@ -177,6 +177,8 @@ export function MobileHomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: disconnectHostClient,
onDiagnostics: (hostId) =>
data.router.push({ pathname: '/connection-log', params: { hostId } }),
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
@@ -3,6 +3,7 @@ import { getHostListActionSheetActions } from './host-list-action-sheet-actions'
import type { ConnectionState, HostProfile } from './transport/types'
vi.mock('lucide-react-native', () => ({
Activity: vi.fn(),
Edit3: vi.fn(),
PowerOff: vi.fn(),
RefreshCw: vi.fn()
@@ -22,6 +23,7 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
}
@@ -35,14 +37,17 @@ function build(overrides: { state?: ConnectionState; hasEverConnected?: boolean
}
describe('getHostListActionSheetActions', () => {
// Why: both open a second drawer. Presenting one while this sheet's native
// Why: these navigate or open a second drawer. Presenting while this sheet's native
// Modal is still up freezes the whole screen on iOS — issue #8791.
it.each(['Edit host', 'Remove'])('defers %s until the action sheet has closed', (label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
})
it.each(['Network diagnostics', 'Edit host', 'Remove'])(
'defers %s until the action sheet has closed',
(label) => {
const { actions } = build()
expect(actions.find((action) => action.label === label)).toMatchObject({
closeBeforePress: true
})
}
)
it('leaves the in-place actions undeferred so they fire on tap', () => {
const { actions, spies } = build()
@@ -65,15 +70,23 @@ describe('getHostListActionSheetActions', () => {
expect(spies.onEdit).toHaveBeenCalledWith(HOST.id)
})
it('routes Network diagnostics with the selected host', () => {
const { actions, spies } = build()
actions.find((action) => action.label === 'Network diagnostics')?.onPress()
expect(spies.onDiagnostics).toHaveBeenCalledWith(HOST.id)
})
it('offers Disconnect only while the socket is live', () => {
expect(build({ state: 'reconnecting' }).actions.map((action) => action.label)).toEqual([
'Reconnect',
'Disconnect',
'Network diagnostics',
'Edit host',
'Remove'
])
expect(build({ state: 'disconnected' }).actions.map((action) => action.label)).toEqual([
'Connect',
'Network diagnostics',
'Edit host',
'Remove'
])
@@ -93,6 +106,7 @@ describe('getHostListActionSheetActions', () => {
onDismiss: vi.fn(),
onReconnect: vi.fn(),
onDisconnect: vi.fn(),
onDiagnostics: vi.fn(),
onEdit: vi.fn(),
onRemove: vi.fn()
})
+12 -3
View File
@@ -1,9 +1,9 @@
import { Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import { Activity, Edit3, PowerOff, RefreshCw } from 'lucide-react-native'
import type { ActionSheetAction } from './components/ActionSheetModal'
import type { ConnectionState, HostProfile } from './transport/types'
/** Builds the home-screen host long-press menu. Edit and Remove open a second
* drawer, so both must defer until this sheet's native Modal has unmounted —
/** Builds the home-screen host long-press menu. Navigation and second drawers
* defer until this sheet's native Modal has unmounted —
* presenting into a live one freezes the whole screen on iOS (issue #8791). */
export function getHostListActionSheetActions(args: {
host: HostProfile | null
@@ -13,6 +13,7 @@ export function getHostListActionSheetActions(args: {
onDismiss: () => void
onReconnect: (hostId: string) => void
onDisconnect: (hostId: string) => void
onDiagnostics: (hostId: string) => void
onEdit: (hostId: string) => void
onRemove: (host: HostProfile) => void
}): ActionSheetAction[] {
@@ -47,6 +48,14 @@ export function getHostListActionSheetActions(args: {
}
]
: []),
{
label: 'Network diagnostics',
icon: Activity,
closeBeforePress: true,
onPress: () => {
args.onDiagnostics(host.id)
}
},
{
label: 'Edit host',
icon: Edit3,
@@ -0,0 +1,175 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const hostScreenPrimaryStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 34,
paddingTop: spacing.xs,
paddingHorizontal: spacing.lg
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
sidebarCollapseButton: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginLeft: spacing.xs
},
hostIdentity: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
minWidth: 0,
marginRight: spacing.md
},
hostNameText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
gap: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbar: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbarRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
embeddedFilterChip: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
embeddedModeButton: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle
},
filterChipActive: {
borderColor: colors.textSecondary,
backgroundColor: colors.bgRaised
},
filterChipText: {
fontSize: 12,
color: colors.textSecondary
},
filterChipTextActive: {
color: colors.textPrimary
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
toolbarSpacer: {
flex: 1
},
floatingWorkspaceHeaderButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
toolbarIconDisabled: {
opacity: 0.6
},
searchToggle: {
padding: spacing.xs
},
searchBar: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
}
})
@@ -0,0 +1,139 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const hostScreenSecondaryStyles = StyleSheet.create({
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize
},
list: {
paddingBottom: spacing.lg
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs
},
sectionIcon: {
marginRight: spacing.xs
},
sectionRepoIcon: {
marginRight: spacing.xs
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
sectionCount: {
fontSize: 11,
color: colors.textMuted,
marginLeft: spacing.xs
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
marginLeft: spacing.lg + 24,
marginRight: spacing.lg
},
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
},
filterSectionLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
filterGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.md
},
filterRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2,
gap: spacing.sm
},
filterRowText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
filterSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
borderRadius: 4
},
confirmContent: {
paddingBottom: spacing.lg
},
confirmTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
confirmMessage: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
confirmButtons: {
flexDirection: 'row',
gap: spacing.sm
},
confirmBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
confirmBtnCancel: {
backgroundColor: colors.bgPanel
},
confirmBtnDestructive: {
backgroundColor: colors.statusRed
},
confirmBtnPressed: {
opacity: 0.7
},
confirmBtnCancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
confirmBtnDestructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
@@ -0,0 +1,7 @@
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
export const hostScreenStyles = {
...hostScreenPrimaryStyles,
...hostScreenSecondaryStyles
}
+185
View File
@@ -0,0 +1,185 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
export const pairScanStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm
},
steps: {
gap: spacing.sm,
marginBottom: spacing.lg,
marginLeft: 7
},
step: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stepBadge: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
stepNumber: {
fontSize: 12,
fontWeight: '700',
color: colors.textSecondary
},
stepText: {
fontSize: typography.bodySize,
color: colors.textSecondary
},
cameraWrap: {
flex: 1,
borderRadius: radii.camera,
overflow: 'hidden'
},
// Why: holds the layout slot while the camera is unmounted during
// paste, so the bottom action button doesn't snap up to fill the
// empty space.
cameraPlaceholder: {
flex: 1,
backgroundColor: colors.bgPanel,
borderRadius: radii.camera
},
camera: {
...StyleSheet.absoluteFillObject
},
reticle: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
reticleFrame: {
position: 'relative'
},
corner: {
position: 'absolute',
width: 28,
height: 28,
borderColor: 'rgba(255,255,255,0.7)'
},
cornerTL: {
top: 0,
left: 0,
borderTopWidth: 2.5,
borderLeftWidth: 2.5,
borderTopLeftRadius: 6
},
cornerTR: {
top: 0,
right: 0,
borderTopWidth: 2.5,
borderRightWidth: 2.5,
borderTopRightRadius: 6
},
cornerBL: {
bottom: 0,
left: 0,
borderBottomWidth: 2.5,
borderLeftWidth: 2.5,
borderBottomLeftRadius: 6
},
cornerBR: {
bottom: 0,
right: 0,
borderBottomWidth: 2.5,
borderRightWidth: 2.5,
borderBottomRightRadius: 6
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
title: {
fontSize: typography.titleSize,
fontWeight: '600',
color: colors.textPrimary,
marginBottom: spacing.sm
},
subtitle: {
maxWidth: 310,
fontSize: typography.bodySize,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
connectingText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
marginTop: spacing.lg
},
logSlot: {
width: '100%',
marginTop: spacing.lg,
paddingHorizontal: spacing.sm
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center',
marginBottom: spacing.xl,
lineHeight: 20
},
primaryButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm + 2,
borderRadius: radii.button
},
primaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
pasteButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
marginTop: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
pasteButtonPressed: {
opacity: 0.6
},
pasteButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
},
errorActions: {
alignItems: 'center',
gap: spacing.sm
},
secondaryButton: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
secondaryButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from './blank-workspace-create'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: unknown }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
@@ -35,7 +40,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
@@ -70,7 +75,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: true,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(calls[0]?.params).toMatchObject({ nameWasGenerated: true })
@@ -90,7 +95,7 @@ describe('createBlankWorkspace', () => {
comment: 'spike',
setupDecision: 'run',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
const params = calls[0]?.params as Record<string, unknown>
@@ -122,7 +127,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
@@ -148,7 +153,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'inherit',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
@@ -167,7 +172,7 @@ describe('createBlankWorkspace', () => {
comment: undefined,
setupDecision: 'skip',
nameWasGenerated: false,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
})
expect(result).toEqual({ error: 'SSH connection is not available' })
+3 -2
View File
@@ -1,6 +1,7 @@
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { RpcClient } from '../transport/rpc-client'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
import {
agentLaunchCreateFields,
type WorkspaceCreateSetupDecision
@@ -19,13 +20,13 @@ export async function createBlankWorkspace(args: {
/** True when `baseName` is a generated creature name rather than one the user typed; only then
* may the host retire it. */
nameWasGenerated: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
nameWasGenerated: args.nameWasGenerated,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => {
const params: Record<string, unknown> = {
repo: `id:${args.repoId}`,
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
import { WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS } from './worktree-create-idempotency-policy'
type Call = { method: string; params: Record<string, unknown> }
@@ -24,6 +25,9 @@ function fakeClient(handle: (method: string, call: number) => unknown, calls: Ca
}
const agent = { choice: 'blank' as const }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
const baseArgs = {
targetRepoId: 'repo-1',
@@ -31,7 +35,7 @@ const baseArgs = {
agent,
workspaceName: undefined,
note: undefined,
supportsIdempotentCutoverRetry: true
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT
}
describe('createWorkspaceFromComposerSource', () => {
+9 -8
View File
@@ -13,6 +13,7 @@ import {
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorktreeCreateIdempotencyProbe } from './worktree-create-idempotency-policy'
// The agent bundle the modal resolved: `choice` drives launch resolution — the
// host applies the agent's launch args (permission flags) and shell quoting.
@@ -29,7 +30,7 @@ export type CreateWorkspaceFromComposerArgs = {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}
export async function createWorkspaceFromComposerSource(
@@ -92,7 +93,7 @@ async function createWorkItemWorkspace(args: {
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
@@ -137,7 +138,7 @@ async function createWorkItemWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (name) => ({ ...params, name })
})
}
@@ -150,7 +151,7 @@ async function createBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -174,7 +175,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
@@ -196,7 +197,7 @@ async function createBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
@@ -220,7 +221,7 @@ async function createNewBranchWorkspace(args: {
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
@@ -232,7 +233,7 @@ async function createNewBranchWorkspace(args: {
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
supportsIdempotentCutoverRetry: args.supportsIdempotentCutoverRetry,
worktreeCreateIdempotency: args.worktreeCreateIdempotency,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
@@ -2,8 +2,21 @@ import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import { readNewWorktreeRuntimeCapabilities } from './worktree-create-capability'
import {
WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS,
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
} from './worktree-create-idempotency-policy'
function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClient {
type StatusOutcome =
| 'cutover'
| 'error'
| string[]
| {
capabilities?: string[]
worktreeCreateIdempotency?: unknown
}
function statusClient(outcomes: StatusOutcome[]): RpcClient {
let call = 0
return {
sendRequest: async () => {
@@ -15,10 +28,13 @@ function statusClient(outcomes: Array<'cutover' | 'error' | string[]>): RpcClien
if (outcome === 'error') {
throw new Error('offline')
}
const result = Array.isArray(outcome)
? { capabilities: outcome, hostPlatform: 'darwin' }
: { ...outcome, hostPlatform: 'darwin' }
return {
id: '1',
ok: true,
result: { capabilities: outcome, hostPlatform: 'darwin' },
result,
_meta: { runtimeId: 'r' }
}
}
@@ -29,23 +45,123 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('reads task and idempotent-create support from status.get', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([['mobile.tasks.v1', 'worktree.create-idempotency.v1']])
statusClient([
{
capabilities: ['mobile.tasks.v1', 'worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 }
}
])
)
).resolves.toEqual({
tasksSupported: true,
idempotentWorktreeCreateSupported: true,
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
hostPlatform: 'darwin'
})
})
it('retries the safe status probe after a connection cutover', async () => {
it('uses the bounded fallback for an old idempotent host without an advertisement', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(statusClient([['worktree.create-idempotency.v1']]))
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS },
hostPlatform: 'darwin'
})
})
it('fails closed when a valid idempotency container omits dedupeTtlMs', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient(['cutover', ['worktree.create-idempotency.v1']])
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: {}
}
])
)
).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: true,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
})
it.each([
{ label: 'null', value: null },
{ label: 'string', value: 'nonsense' },
{ label: 'number', value: 20_000 },
{ label: 'empty array', value: [] }
])(
'fails closed for a malformed idempotency container ($label)',
async ({ value: worktreeCreateIdempotency }) => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
}
)
it('clamps an over-large host advertisement to the client fallback ceiling', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 600_000 }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS },
hostPlatform: 'darwin'
})
})
it.each(['60000', -1, Number.NaN, 60_000.5, {}])(
'fails closed for malformed host advertisement %j',
async (advertisedDedupeTtlMs) => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: advertisedDedupeTtlMs }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
hostPlatform: 'darwin'
})
}
)
it('retries the safe status probe after a connection cutover', async () => {
await expect(
readNewWorktreeRuntimeCapabilities(
statusClient([
'cutover',
{
capabilities: ['worktree.create-idempotency.v1'],
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 }
}
])
)
).resolves.toEqual({
tasksSupported: false,
worktreeCreateIdempotency: { dedupeTtlMs: 30_000 },
hostPlatform: 'darwin'
})
})
@@ -53,7 +169,7 @@ describe('readNewWorktreeRuntimeCapabilities', () => {
it('fails closed when capability detection is unavailable', async () => {
await expect(readNewWorktreeRuntimeCapabilities(statusClient(['error']))).resolves.toEqual({
tasksSupported: false,
idempotentWorktreeCreateSupported: false,
worktreeCreateIdempotency: false,
hostPlatform: null
})
})
+28 -8
View File
@@ -4,6 +4,11 @@ import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-cli
import type { RpcSuccess } from '../transport/types'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { MOBILE_TASKS_CAPABILITY } from './mobile-tasks-capability'
import {
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
resolveWorktreeCreateIdempotencySupport,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Why: older hosts strip worktree.create's clientMutationId, so mobile must not
// replay an ambiguous create unless the host advertises idempotency support.
@@ -14,13 +19,13 @@ const STATUS_CUTOVER_MAX_RETRIES = 5
export type NewWorktreeRuntimeCapabilities = {
tasksSupported: boolean
idempotentWorktreeCreateSupported: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
hostPlatform: NodeJS.Platform | null
}
const UNSUPPORTED_CAPABILITIES: NewWorktreeRuntimeCapabilities = {
tasksSupported: false,
idempotentWorktreeCreateSupported: false,
worktreeCreateIdempotency: false,
hostPlatform: null
}
@@ -35,13 +40,28 @@ export async function readNewWorktreeRuntimeCapabilities(
if (!response.ok) {
return UNSUPPORTED_CAPABILITIES
}
const result = (response as RpcSuccess).result as { capabilities?: string[] }
const result = (response as RpcSuccess).result as {
capabilities?: string[]
worktreeCreateIdempotency?: unknown
}
const capabilities = result.capabilities ?? []
const supportsIdempotency = capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
)
const advertisedIdempotency = result.worktreeCreateIdempotency
return {
tasksSupported: capabilities.includes(MOBILE_TASKS_CAPABILITY),
idempotentWorktreeCreateSupported: capabilities.includes(
MOBILE_WORKTREE_CREATE_IDEMPOTENCY_CAPABILITY
),
worktreeCreateIdempotency: supportsIdempotency
? advertisedIdempotency === undefined
? { dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS }
: advertisedIdempotency !== null &&
typeof advertisedIdempotency === 'object' &&
!Array.isArray(advertisedIdempotency)
? resolveWorktreeCreateIdempotencySupport(
(advertisedIdempotency as { dedupeTtlMs?: unknown }).dedupeTtlMs
)
: { dedupeTtlMs: 0 }
: false,
hostPlatform: readMobileRuntimeHostPlatform(result)
}
} catch (error) {
@@ -58,7 +78,7 @@ export function useNewWorktreeRuntimeCapabilities(
): {
tasksSupported: boolean
hostPlatform: NodeJS.Platform | null
getWorktreeCreateCutoverSupport: () => Promise<boolean>
getWorktreeCreateCutoverSupport: () => Promise<WorktreeCreateIdempotencySupport | false>
} {
const [tasksSupported, setTasksSupported] = useState(false)
const [hostPlatform, setHostPlatform] = useState<NodeJS.Platform | null>(null)
@@ -97,7 +117,7 @@ export function useNewWorktreeRuntimeCapabilities(
}, [client, enabled, getCapabilities, setTasksSupported])
const getWorktreeCreateCutoverSupport = useCallback(
() => getCapabilities().then((capabilities) => capabilities.idempotentWorktreeCreateSupported),
() => getCapabilities().then((capabilities) => capabilities.worktreeCreateIdempotency),
[getCapabilities]
)
return { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport }
@@ -0,0 +1,40 @@
// Why: old idempotent hosts omit policy, so preserve today's effective replay window.
export const WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS = 60_000
// Why: a client must not trust a host advertisement beyond its tested ceiling.
export const WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS = 60_000
// Why: the replay still has to reach the host before its dedupe record expires.
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
export type WorktreeCreateIdempotencySupport = {
dedupeTtlMs: number
}
export type WorktreeCreateIdempotencyProbe =
| WorktreeCreateIdempotencySupport
| false
| Promise<WorktreeCreateIdempotencySupport | false>
export function resolveWorktreeCreateIdempotencySupport(
advertisedDedupeTtlMs: unknown
): WorktreeCreateIdempotencySupport {
if (advertisedDedupeTtlMs === undefined) {
return { dedupeTtlMs: 0 }
}
if (
typeof advertisedDedupeTtlMs !== 'number' ||
!Number.isSafeInteger(advertisedDedupeTtlMs) ||
advertisedDedupeTtlMs < 0
) {
// Truthy support preserves immediate cutover retries while disabling ambiguous replay.
return { dedupeTtlMs: 0 }
}
return {
dedupeTtlMs: Math.min(advertisedDedupeTtlMs, WORKTREE_CREATE_DEDUPE_TTL_CLIENT_CEILING_MS)
}
}
export function getWorktreeCreateReplayWindowMs(support: WorktreeCreateIdempotencySupport): number {
return Math.max(0, support.dedupeTtlMs - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS)
}
+102 -34
View File
@@ -3,16 +3,24 @@ import type { RpcClient } from '../transport/rpc-client'
import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
import type { ConnectionState } from '../transport/types'
import { WORKTREE_CREATE_DEDUPE_TTL_MS } from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import {
getWorktreeCreateReplayWindowMs,
WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
import {
createWorktreeWithNameRetry,
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS,
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS
} from './worktree-create-retry'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
type Attempt = { method: string; params: Record<string, unknown> }
const IDEMPOTENT_CREATE_SUPPORT = {
dedupeTtlMs: WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS
}
const LEGACY_HOST_REPLAY_WINDOW_MS = getWorktreeCreateReplayWindowMs(IDEMPOTENT_CREATE_SUPPORT)
// Drives the transport state a replay has to wait on. Production couples the two:
// a socket close rejects the pending frame AND drops the client off 'connected'.
function connectionController(): {
@@ -106,21 +114,21 @@ describe('createWorktreeWithNameRetry', () => {
it('waits for capability detection before sending a create', async () => {
const attempts: Attempt[] = []
const client = scriptedClient([{ id: 'wt-ready' }], attempts)
let resolveSupport!: (supported: boolean) => void
const support = new Promise<boolean>((resolve) => {
let resolveSupport!: (supported: WorktreeCreateIdempotencySupport) => void
const support = new Promise<WorktreeCreateIdempotencySupport>((resolve) => {
resolveSupport = resolve
})
const pending = createWorktreeWithNameRetry({
client,
baseName: 'puffin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: support,
worktreeCreateIdempotency: support,
mintMutationId: () => 'key-ready'
})
await Promise.resolve()
expect(attempts).toHaveLength(0)
resolveSupport(true)
resolveSupport(IDEMPOTENT_CREATE_SUPPORT)
await expect(pending).resolves.toEqual({ worktreeId: 'wt-ready', name: 'puffin' })
expect(attempts).toHaveLength(1)
@@ -134,7 +142,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'otter',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-1'
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'otter' })
@@ -152,7 +160,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'seal',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-mig'
})
expect(result).toEqual({ worktreeId: 'wt-2', name: 'seal' })
@@ -172,7 +180,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'crab',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-x'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -190,7 +198,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'eel',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-t'
})
).rejects.toThrow('Request timed out')
@@ -208,7 +216,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'topic',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => `key-${(n += 1)}`
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'topic-2' })
@@ -231,7 +239,7 @@ describe('createWorktreeWithNameRetry', () => {
baseName: 'nautilus-2',
nameWasGenerated: true,
buildParams: (name) => ({ repo: 'id:r', name, nameWasGenerated: true }),
supportsIdempotentCutoverRetry: false
worktreeCreateIdempotency: false
})
expect(result).toEqual({ worktreeId: 'wt-generated', name: 'nautilus-3' })
@@ -246,7 +254,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'ray',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: false,
worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toBeInstanceOf(LogicalClientCutoverError)
@@ -273,7 +281,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-drop'
})
@@ -301,7 +309,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: false,
worktreeCreateIdempotency: false,
mintMutationId: () => 'must-not-be-used'
})
).rejects.toThrow('Connection interrupted')
@@ -309,6 +317,32 @@ describe('createWorktreeWithNameRetry', () => {
expect(attempts[0]!.params.clientMutationId).toBeUndefined()
})
it('disables ambiguous replay for a malformed advertisement', async () => {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true
}
],
attempts,
connection
)
await expect(
createWorktreeWithNameRetry({
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 0 },
mintMutationId: () => 'key-malformed'
})
).rejects.toThrow('Connection interrupted')
expect(attempts).toHaveLength(1)
expect(attempts[0]!.params.clientMutationId).toBe('key-malformed')
})
it('gives up after the delivery-ambiguity replay budget, still inside the dedupe TTL', async () => {
vi.useFakeTimers()
try {
@@ -331,21 +365,21 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'barnacle',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-budget'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_MS)
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
await settled
// Initial attempt + 2 replays.
expect(attempts).toHaveLength(3)
// The budget is a count, but what keeps a replay reconciling instead of building
// a second worktree is wall clock: every attempt has to land while the host still
// holds the record, with the watchdog's detection latency already deducted.
expect(settledAt - startedAt).toBeLessThanOrEqual(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
expect(settledAt - startedAt).toBeLessThanOrEqual(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -373,7 +407,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'anemone',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stuck'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -388,6 +422,41 @@ describe('createWorktreeWithNameRetry', () => {
// so vitest's default 5s real-time budget is reachable on a loaded runner.
}, 30_000)
it('stops replaying when the host-advertised dedupe record has expired', async () => {
vi.useFakeTimers()
try {
const attempts: Attempt[] = []
const connection = connectionController()
const client = scriptedClient(
[
{
throws: markRpcDeliveryUnknown(new Error('Connection interrupted')),
dropsConnection: true,
takesMs: 12_000,
reconnectsAfterMs: 1_000
},
{ id: 'wt-duplicate' }
],
attempts,
connection
)
const pending = createWorktreeWithNameRetry({
client,
baseName: 'mussel',
buildParams: (name) => ({ repo: 'id:r', name }),
worktreeCreateIdempotency: { dedupeTtlMs: 20_000 },
mintMutationId: () => 'key-short-ttl'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(30_000)
await settled
expect(attempts).toHaveLength(1)
} finally {
vi.useRealTimers()
}
})
it('does not replay an ambiguity that surfaced while the transport stayed connected', async () => {
vi.useFakeTimers()
try {
@@ -415,7 +484,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'cuttlefish',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-expired'
})
const settled = expect(pending).rejects.toThrow('Request timed out')
@@ -464,19 +533,19 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'nautilus',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-clamped'
}).catch((error: unknown) => {
settledAt = Date.now()
throw error
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
await vi.advanceTimersByTimeAsync(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
await vi.advanceTimersByTimeAsync(LEGACY_HOST_REPLAY_WINDOW_MS)
await settled
expect(attempts).toHaveLength(2)
// The window is anchored at the FIRST ambiguity, so the second wait gets only the
// remainder — it must not restart a full wait and carry the replay past the record.
expect(settledAt - startedAt).toBe(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS)
expect(settledAt - startedAt).toBe(LEGACY_HOST_REPLAY_WINDOW_MS)
} finally {
vi.useRealTimers()
}
@@ -509,7 +578,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'limpet',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-stale'
})
const settled = expect(pending).rejects.toThrow('Connection interrupted')
@@ -551,7 +620,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'kelp',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-long'
})
await vi.advanceTimersByTimeAsync(250_000)
@@ -606,7 +675,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-anchored'
})
const settled = expect(pending).rejects.toThrow('Connection lost')
@@ -638,7 +707,7 @@ describe('createWorktreeWithNameRetry', () => {
client,
baseName: 'urchin',
buildParams: (name) => ({ repo: 'id:r', name }),
supportsIdempotentCutoverRetry: true,
worktreeCreateIdempotency: IDEMPOTENT_CREATE_SUPPORT,
mintMutationId: () => 'key-unmarked'
})
).rejects.toThrow('Socket closed before send')
@@ -649,11 +718,10 @@ describe('createWorktreeWithNameRetry', () => {
// The window is measured from a lower bound on when the host could have resolved, so
// it has to leave the record room for the replay to still be in flight. Widening it
// to the whole TTL would let the last replay land on a record that just expired.
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeGreaterThan(0)
expect(WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_MS)
// A single wait must not be able to consume the window on its own.
expect(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS).toBeLessThan(
WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
)
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeGreaterThan(0)
expect(LEGACY_HOST_REPLAY_WINDOW_MS).toBeLessThan(WORKTREE_CREATE_DEDUPE_TTL_LEGACY_HOST_MS)
// A reconnect wait is clamped when a short advertisement leaves less time.
const shortReplayWindowMs = getWorktreeCreateReplayWindowMs({ dedupeTtlMs: 20_000 })
expect(Math.min(WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS, shortReplayWindowMs)).toBe(10_000)
})
})
+19 -26
View File
@@ -7,10 +7,14 @@ import {
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
getClientWorktreeCreateCandidate,
getGeneratedWorktreeCreateRetryCandidate,
isRetryableWorktreeCreateConflict,
WORKTREE_CREATE_DEDUPE_TTL_MS
isRetryableWorktreeCreateConflict
} from '../../../src/shared/new-workspace/worktree-create-retry-policy'
import { WORKTREE_CREATE_TIMEOUT_MS } from './workspace-create-timeout'
import {
getWorktreeCreateReplayWindowMs,
type WorktreeCreateIdempotencyProbe,
type WorktreeCreateIdempotencySupport
} from './worktree-create-idempotency-policy'
// Why: server-side collision checks (branch already exists locally / on a remote
// / already has PR #N) can fire even after a pre-flight basename dedupe —
@@ -36,17 +40,6 @@ const WORKTREE_CREATE_CUTOVER_MAX_RETRIES = 5
// finished the worktree. Replay on the same clientMutationId instead.
const WORKTREE_CREATE_AMBIGUOUS_MAX_RETRIES = 2
// Why: the host keeps a settled create's dedupe record for WORKTREE_CREATE_DEDUPE_TTL_MS
// after it resolves; a replay that lands later misses it and the host's suffix loop
// builds a SECOND worktree — and for folder workspaces a second one with the SAME name
// and no collision check at all. So a replay is bounded by how stale our knowledge of the
// host is, and that has to be measured in WALL CLOCK: iOS/Android suspend JS timers while
// the app is backgrounded, so any ceiling derived from timer intervals — a watchdog probe
// budget, a request timeout — can be arbitrarily wrong across a background cycle, which is
// exactly when a create sits ambiguous for minutes.
const WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS = 10_000
export const WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS =
WORKTREE_CREATE_DEDUPE_TTL_MS - WORKTREE_CREATE_REPLAY_FLIGHT_MARGIN_MS
// Bounded so the Create spinner doesn't sit for the whole window; the deadline still caps it.
export const WORKTREE_CREATE_AMBIGUOUS_RECONNECT_WAIT_MS = 20_000
@@ -55,7 +48,7 @@ export type CreateWorktreeWithNameRetryArgs = {
baseName: string
nameWasGenerated?: boolean
buildParams: (name: string) => Record<string, unknown>
supportsIdempotentCutoverRetry: boolean | Promise<boolean>
worktreeCreateIdempotency: WorktreeCreateIdempotencyProbe
maxAttempts?: number
// Injected in tests; production mints a fresh idempotency key per candidate.
mintMutationId?: () => string
@@ -72,7 +65,7 @@ export async function createWorktreeWithNameRetry(
const { client, baseName, buildParams } = args
// Why: creating before status.get settles would silently disable safe replay
// during the exact slow-network window this path is meant to recover from.
const supportsIdempotentCutoverRetry = await args.supportsIdempotentCutoverRetry
const worktreeCreateIdempotency = await args.worktreeCreateIdempotency
const maxAttempts = args.maxAttempts ?? CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS
const mintMutationId = args.mintMutationId ?? defaultWorktreeCreateMutationId
let lastError: string | null = null
@@ -84,14 +77,10 @@ export async function createWorktreeWithNameRetry(
// Why: older hosts strip unknown fields, so only stamp and replay when the
// host advertises idempotency. One key per candidate makes cutover retries
// safe while a name-collision bump remains a genuinely new create.
const params = supportsIdempotentCutoverRetry
const params = worktreeCreateIdempotency
? { ...candidateParams, clientMutationId: mintMutationId() }
: candidateParams
const response = await sendWorktreeCreateResilient(
client,
params,
supportsIdempotentCutoverRetry
)
const response = await sendWorktreeCreateResilient(client, params, worktreeCreateIdempotency)
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
return { worktreeId: result.worktree.id, name: candidateName }
@@ -112,7 +101,7 @@ export async function createWorktreeWithNameRetry(
async function sendWorktreeCreateResilient(
client: RpcClient,
params: Record<string, unknown>,
supportsIdempotentCutoverRetry: boolean
worktreeCreateIdempotency: WorktreeCreateIdempotencySupport | false
): Promise<RpcResponse> {
let migrationRetry = 0
let ambiguousRetry = 0
@@ -124,7 +113,7 @@ async function sendWorktreeCreateResilient(
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
} catch (error) {
if (!supportsIdempotentCutoverRetry) {
if (!worktreeCreateIdempotency) {
throw error
}
if (isLogicalClientCutoverError(error)) {
@@ -152,7 +141,7 @@ async function sendWorktreeCreateResilient(
}
// Computed once: a later ambiguity reads a fresher lastInboundAt from the
// replacement session, which would push the deadline past the record it respects.
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt)
replayDeadlineAt ??= resolveReplayDeadline(client, firstSentAt, worktreeCreateIdempotency)
const remainingWindowMs = replayDeadlineAt - Date.now()
if (remainingWindowMs <= 0) {
throw error
@@ -179,10 +168,14 @@ async function sendWorktreeCreateResilient(
// so it stays honest across a suspension in a way a timer budget cannot; the send is the
// fallback, and a sound floor either way, because the host cannot resolve a create it has
// not yet received.
function resolveReplayDeadline(client: RpcClient, firstSentAt: number): number {
function resolveReplayDeadline(
client: RpcClient,
firstSentAt: number,
support: WorktreeCreateIdempotencySupport
): number {
const lastInboundAt = client.getLastInboundAt?.() ?? null
const anchor = lastInboundAt !== null && lastInboundAt > firstSentAt ? lastInboundAt : firstSentAt
return anchor + WORKTREE_CREATE_AMBIGUOUS_REPLAY_WINDOW_MS
return anchor + getWorktreeCreateReplayWindowMs(support)
}
function defaultWorktreeCreateMutationId(): string {
+8 -140
View File
@@ -1,3 +1,9 @@
import {
SPECIAL_KEY_LABELS,
TERMINAL_ACCESSORY_KEY_DEFINITIONS,
TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
} from './terminal-key-definitions'
export type TerminalAccessoryKey = {
id: string
label: string
@@ -75,66 +81,6 @@ const CTRL_PRINTABLE_BYTES: Record<string, string> = {
'?': '\x7f'
}
const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const CSI_FINAL_SPECIAL_KEYS: Record<string, string> = {
arrowUp: 'A',
arrowDown: 'B',
@@ -165,87 +111,9 @@ const CSI_TILDE_SPECIAL_KEYS: Record<string, number> = {
f12: 24
}
export const TERMINAL_SHORTCUT_SPECIAL_KEYS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_SHORTCUT_SPECIAL_KEYS = TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS
export const TERMINAL_ACCESSORY_KEYS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
export const TERMINAL_ACCESSORY_KEYS = TERMINAL_ACCESSORY_KEY_DEFINITIONS
export function buildTerminalShortcutKey(
binding: TerminalShortcutBinding
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender,
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
} from './terminal-accessory-repeat'
function deferred() {
let resolve: () => void = () => {}
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('terminal accessory repeat', () => {
afterEach(() => vi.useRealTimers())
it('keeps one send in flight and preserves repeat order under host latency', async () => {
vi.useFakeTimers()
const first = deferred()
const second = deferred()
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
expect(sent).toEqual(['down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
expect(sent).toEqual(['down'])
first.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(sent).toEqual(['down', 'down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
expect(sent).toEqual(['down', 'down'])
second.resolve()
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
expect(sent).toEqual(['down', 'down', 'down'])
})
it('does not schedule another repeat after release while a send is pending', async () => {
vi.useFakeTimers()
const pending = deferred()
const send = vi.fn(() => pending.promise.then(() => true))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
repeat.stop()
pending.resolve()
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
vi.useFakeTimers()
const pending = Array.from({ length: 5 }, () => deferred())
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return pending[sent.length - 1]!.promise.then(() => true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
for (let index = 0; index < pending.length; index += 1) {
repeat.start(`tap-${index}`, send)
repeat.stop()
}
expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
pending.forEach(({ resolve }) => resolve())
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(5)
})
it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
vi.useFakeTimers()
const first = deferred()
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
repeat.stop()
repeat.start('up', send)
repeat.cancel()
first.resolve()
await vi.runAllTimersAsync()
expect(sent).toEqual(['down', 'up'])
expect(send).toHaveBeenCalledTimes(2)
})
it('stops repeating when the transport rejects a send', async () => {
vi.useFakeTimers()
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops repeating when the send is dropped before reaching the transport', async () => {
vi.useFakeTimers()
const send = vi.fn(async () => false)
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops sending when the terminal active at press time is no longer active', async () => {
let activeTerminal = 'terminal-a'
const sendToTerminal = vi.fn(async () => true)
const send = createTerminalAccessoryRepeatSender(
activeTerminal,
(targetHandle) => activeTerminal === targetHandle,
sendToTerminal
)
await expect(send('down')).resolves.toBe(true)
activeTerminal = 'terminal-b'
await expect(send('down')).resolves.toBe(false)
expect(sendToTerminal).toHaveBeenCalledTimes(1)
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
})
it('provides a live target guard for async send preflight', async () => {
let targetCurrent = true
const preflight = deferred()
const send = createTerminalAccessoryRepeatSender(
'terminal-a',
() => targetCurrent,
async (_input, _targetHandle, isDeliveryTargetCurrent) => {
await preflight.promise
return isDeliveryTargetCurrent()
}
)
const result = send('down')
targetCurrent = false
preflight.resolve()
await expect(result).resolves.toBe(false)
})
it('stops held repeats when the press-time connection is no longer current', async () => {
vi.useFakeTimers()
let connectionGeneration = 1
const first = deferred()
const sent: string[] = []
const sendToTerminal = vi.fn((input: string) => {
sent.push(input)
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
})
const createSender = () => {
const pressedConnectionGeneration = connectionGeneration
return createTerminalAccessoryRepeatSender(
'terminal-a',
() => connectionGeneration === pressedConnectionGeneration,
sendToTerminal
)
}
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', createSender())
connectionGeneration = 2
first.resolve()
await vi.runAllTimersAsync()
expect(sent).toEqual(['down'])
})
})
@@ -0,0 +1,76 @@
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
type TerminalAccessoryRepeatSendToTerminal<TInput> = (
input: TInput,
targetHandle: string,
isDeliveryTargetCurrent: () => boolean
) => Promise<boolean>
export function createTerminalAccessoryRepeatSender<TInput>(
targetHandle: string | null,
isDeliveryTargetCurrent: (targetHandle: string) => boolean,
sendToTerminal: TerminalAccessoryRepeatSendToTerminal<TInput>
): TerminalAccessoryRepeatSender<TInput> {
return (input) => {
if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
return Promise.resolve(false)
}
return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
}
}
export function createTerminalAccessoryRepeatController<TInput>() {
let generation = 0
let timer: ReturnType<typeof setTimeout> | null = null
const stop = () => {
generation += 1
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
const cancel = stop
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
stop()
const activeGeneration = generation
const pressedAt = Date.now()
const schedule = (delayMs: number) => {
timer = setTimeout(() => {
timer = null
if (generation !== activeGeneration) {
return
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
}
},
() => undefined
)
}, delayMs)
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(
Math.min(
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
)
)
}
},
() => undefined
)
}
return { cancel, start, stop }
}
@@ -0,0 +1,143 @@
import type { TerminalAccessoryKey, TerminalShortcutSpecialKey } from './terminal-accessory-keys'
export const SPECIAL_KEY_LABELS: Record<string, string> = {
escape: 'Esc',
tab: 'Tab',
enter: 'Enter',
backspace: '⌫',
delete: 'Del',
insert: 'Ins',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
home: 'Home',
end: 'End',
pageUp: 'PgUp',
pageDown: 'PgDn',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
const SPECIAL_KEY_ACCESSIBILITY_LABELS: Record<string, string> = {
escape: 'Escape',
tab: 'Tab',
enter: 'Enter',
backspace: 'Backspace',
delete: 'Forward delete',
insert: 'Insert',
arrowUp: 'Arrow up',
arrowDown: 'Arrow down',
arrowLeft: 'Arrow left',
arrowRight: 'Arrow right',
home: 'Home',
end: 'End',
pageUp: 'Page up',
pageDown: 'Page down',
space: 'Space',
f1: 'F1',
f2: 'F2',
f3: 'F3',
f4: 'F4',
f5: 'F5',
f6: 'F6',
f7: 'F7',
f8: 'F8',
f9: 'F9',
f10: 'F10',
f11: 'F11',
f12: 'F12'
}
export const TERMINAL_SHORTCUT_SPECIAL_KEY_DEFINITIONS: TerminalShortcutSpecialKey[] = [
'escape',
'tab',
'enter',
'backspace',
'delete',
'insert',
'arrowUp',
'arrowDown',
'arrowLeft',
'arrowRight',
'home',
'end',
'pageUp',
'pageDown',
'space',
'f1',
'f2',
'f3',
'f4',
'f5',
'f6',
'f7',
'f8',
'f9',
'f10',
'f11',
'f12'
].map((id) => ({
id,
label: SPECIAL_KEY_LABELS[id]!,
accessibilityLabel: SPECIAL_KEY_ACCESSIBILITY_LABELS[id]!
}))
export const TERMINAL_ACCESSORY_KEY_DEFINITIONS: TerminalAccessoryKey[] = [
{ id: 'escape', label: 'Esc', bytes: '\x1b', accessibilityLabel: 'Escape' },
{ id: 'tab', label: 'Tab', bytes: '\t', accessibilityLabel: 'Tab' },
{ id: 'enter', label: 'Enter', bytes: '\r', accessibilityLabel: 'Enter' },
// Why: terminal apps recognize ESC [ Z as the reverse-tab sequence.
{ id: 'shiftTab', label: 'Shift+Tab', bytes: '\x1b[Z', accessibilityLabel: 'Shift Tab' },
{ id: 'space', label: 'Space', bytes: ' ', accessibilityLabel: 'Space' },
{ id: 'backspace', label: '⌫', bytes: '\x7f', accessibilityLabel: 'Backspace', repeatable: true },
{
id: 'delete',
label: 'Del',
bytes: '\x1b[3~',
accessibilityLabel: 'Forward delete',
repeatable: true
},
{ id: 'arrowUp', label: '↑', bytes: '\x1b[A', accessibilityLabel: 'Arrow Up', repeatable: true },
{
id: 'arrowDown',
label: '↓',
bytes: '\x1b[B',
accessibilityLabel: 'Arrow Down',
repeatable: true
},
{
id: 'arrowLeft',
label: '←',
bytes: '\x1b[D',
accessibilityLabel: 'Arrow Left',
repeatable: true
},
{
id: 'arrowRight',
label: '→',
bytes: '\x1b[C',
accessibilityLabel: 'Arrow Right',
repeatable: true
},
{ id: 'ctrlC', label: 'Ctrl+C', bytes: '\x03', accessibilityLabel: 'Interrupt terminal' },
{ id: 'ctrlD', label: 'Ctrl+D', bytes: '\x04', accessibilityLabel: 'Send EOF' },
{ id: 'ctrlL', label: 'Ctrl+L', bytes: '\x0c', accessibilityLabel: 'Clear screen' },
{ id: 'ctrlZ', label: 'Ctrl+Z', bytes: '\x1a', accessibilityLabel: 'Suspend process' },
{ id: 'ctrlR', label: 'Ctrl+R', bytes: '\x12', accessibilityLabel: 'Reverse search' },
{ id: 'ctrlA', label: 'Ctrl+A', bytes: '\x01', accessibilityLabel: 'Start of line' },
{ id: 'ctrlE', label: 'Ctrl+E', bytes: '\x05', accessibilityLabel: 'End of line' },
{ id: 'ctrlW', label: 'Ctrl+W', bytes: '\x17', accessibilityLabel: 'Delete word backward' },
{ id: 'ctrlU', label: 'Ctrl+U', bytes: '\x15', accessibilityLabel: 'Clear line before cursor' }
]
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
function captureClient(
result: Promise<unknown> = Promise.resolve({
id: 'send',
ok: true,
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
_meta: { runtimeId: 'test-runtime' }
})
) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
}
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes instead of sending while disconnected', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
it('reports a rejected send without surfacing transport errors', async () => {
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
).resolves.toBeUndefined()
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
it('reports a fulfilled RPC failure as a failed send', async () => {
const response: RpcResponse = {
id: 'send',
ok: false,
error: { code: 'terminal_error', message: 'failed' },
_meta: { runtimeId: 'test-runtime' }
}
const { client } = captureClient(Promise.resolve(response))
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
})
@@ -1,4 +1,5 @@
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
export async function sendTerminalLiveAccessoryRawBytes(
args: TerminalLiveAccessoryRawSendArgs
): Promise<void> {
): Promise<boolean> {
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle: args.targetHandle,
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
activeSessionTabType: args.activeSessionTabType
})
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
return
return false
}
await args.client
return args.client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(
() => undefined,
() => undefined
)
.then(isTerminalSendRpcAccepted, () => false)
}
@@ -26,6 +26,7 @@ function createDeferredBoolean(): DeferredBoolean {
}
type AccessoryInputCommitHarnessOptions = {
readonly composing?: boolean
readonly heldText?: string
readonly sentText?: string
readonly pendingHandle?: string | null
@@ -46,6 +47,7 @@ type AccessoryInputCommitHarness = {
}
function createAccessoryInputCommitHarness({
composing,
heldText = '',
sentText = '',
pendingHandle = null,
@@ -55,6 +57,7 @@ function createAccessoryInputCommitHarness({
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
const activeHandle = 'terminal-a'
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
const liveInputComposingRef: RefObject<boolean | undefined> = { current: composing }
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
const liveInputRef: RefObject<TextInput | null> = { current: null }
@@ -66,7 +69,9 @@ function createAccessoryInputCommitHarness({
return sendResult
}
}
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
const applyLiveInputMirror = vi.fn(
async (_handle: string, _fieldText: string, _composing?: boolean) => true
)
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
@@ -82,6 +87,7 @@ function createAccessoryInputCommitHarness({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -165,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
expect(result).toEqual({ kind: 'handled' })
})
it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
const harness = createAccessoryInputCommitHarness({
heldText: '한',
pendingHandle: 'terminal-a',
sendResult: false
})
const result = await harness.commit({ bytes: '\x1b' })
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'suppress-raw' })
})
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
// Given
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
@@ -178,10 +198,11 @@ describe('terminal live accessory input commit hook', () => {
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
it('Given accessory backspace with reported composition When committed Then keeps the edited preedit held', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한',
composing: true,
heldText: 'ni hao',
sentText: '',
pendingHandle: 'terminal-a'
})
@@ -190,14 +211,31 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'ni ha', true)
expect(result).toEqual({ kind: 'handled' })
expect(harness.sent).toEqual([])
})
it('Given accessory backspace with an unreported hold When committed Then preserves the Android fallback', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
heldText: '한글',
sentText: '',
pendingHandle: 'terminal-a'
})
// When
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '한', undefined)
expect(result).toEqual({ kind: 'handled' })
})
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
// Given
const harness = createAccessoryInputCommitHarness({
composing: false,
heldText: '',
sentText: 'ab',
pendingHandle: 'terminal-a'
@@ -207,7 +245,7 @@ describe('terminal live accessory input commit hook', () => {
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
// Then
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a', false)
expect(result).toEqual({ kind: 'handled' })
})
})
@@ -21,10 +21,15 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
readonly applyLiveInputMirror: (
handle: string,
fieldText: string,
composing?: boolean
) => Promise<boolean>
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly liveInputRef: RefObject<TextInput | null>
readonly liveInputTerminalHandles: ReadonlySet<string>
readonly pendingLiveInputHandleRef: RefObject<string | null>
@@ -40,6 +45,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -81,15 +87,21 @@ export function useTerminalLiveAccessoryInputCommit({
// field is edited here and the mirror diff syncs the PTY echo.
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
applyLiveInputMirror(activeHandle, editedText)
return { kind: 'handled' }
// Preserve undefined so Android's heuristic hold still settles on its timer.
const sent = await applyLiveInputMirror(
activeHandle,
editedText,
liveInputComposingRef.current
)
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
case 'commit-held-then-send': {
const sent = await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
default:
decision satisfies never
return { kind: 'handled' }
@@ -101,6 +113,7 @@ export function useTerminalLiveAccessoryInputCommit({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -161,6 +161,49 @@ describe('terminal live input commit hook', () => {
expect(sent).toEqual([])
})
it('Given an iOS pinyin preedit When accessory Backspace edits it Then only the candidate reaches the terminal', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, 'ni hao', true)
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 2)
changeLiveInput(handlers, '你好', false)
// Then
await vi.waitFor(() => expect(sent).toEqual(['你好']))
})
it('Given an Android heuristic hold When accessory Backspace edits it Then the remaining text still settles', async () => {
// Given
vi.useFakeTimers()
const { handlers, sent } = createTerminalLiveInputCommitHarness()
changeLiveInput(handlers, '한글')
// When
await handlers.handleLiveInputAccessoryBytes({ bytes: '\x7f', localEdit: 'backspace' })
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
// Then
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
changeLiveInput(handlers, 'a', false)
await vi.waitFor(() => expect(sent).toEqual(['a']))
const result = await handlers.handleLiveInputAccessoryBytes({
bytes: '\x7f',
localEdit: 'backspace'
})
expect(sent).toEqual(['a', '\x7f'])
expect(result).toEqual({ kind: 'suppress-raw' })
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
@@ -68,6 +68,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush
@@ -132,7 +133,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(nativeEvent.text)
applyLiveInputMirror(
void applyLiveInputMirror(
activeHandle,
normalizeTerminalTextInput(nativeEvent.text),
nativeEvent.isComposing
@@ -196,6 +197,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
liveInputRef,
liveInputTerminalHandles,
pendingLiveInputHandleRef,
@@ -30,10 +30,15 @@ type RunTerminalLiveMirrorStep = (
) => Promise<boolean>
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly applyLiveInputMirror: (
handle: string,
fieldText: string,
composing?: boolean
) => Promise<boolean>
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
readonly liveInputComposingRef: RefObject<boolean | undefined>
readonly pendingLiveInputHandleRef: RefObject<string | null>
readonly sentLiveInputTextRef: RefObject<string>
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
@@ -50,6 +55,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const liveInputComposingRef = useRef<boolean | undefined>(undefined)
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
@@ -65,6 +71,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
}, [clearHeldCommitTimer])
@@ -106,6 +113,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
})
sentLiveInputTextRef.current = step.nextSentText
heldLiveInputTextRef.current = step.heldText
liveInputComposingRef.current = composing
pendingLiveInputHandleRef.current =
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
@@ -149,9 +157,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [runMirrorStep])
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string, composing?: boolean): void => {
void runMirrorStep(handle, fieldText, false, composing)
},
(handle: string, fieldText: string, composing?: boolean): Promise<boolean> =>
runMirrorStep(handle, fieldText, false, composing),
[runMirrorStep]
)
@@ -187,6 +194,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldCommitTimerRef.current = null
}
heldLiveInputTextRef.current = ''
liveInputComposingRef.current = undefined
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
@@ -198,6 +206,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearPendingLiveInputCommit,
flushPendingLiveInputText,
heldLiveInputTextRef,
liveInputComposingRef,
pendingLiveInputHandleRef,
sentLiveInputTextRef,
waitForPendingLiveInputFlush

Some files were not shown because too many files have changed in this diff Show More