Merge branch 'main' into brennanb2025/relay-completion-unverifiable-r1

This commit is contained in:
Merge Sim
2026-08-29 21:34:39 -07:00
729 changed files with 70351 additions and 50347 deletions
@@ -28,10 +28,9 @@ runs:
steps:
# setup-node needs pnpm on PATH to locate and restore its store.
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
id: default-node
@@ -88,7 +87,7 @@ runs:
pnpm install --frozen-lockfile --ignore-scripts
# Job containers can run composite steps from a source mirror without .git.
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
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
# Why cached: `--ignore-scripts` leaves node-pty without build/Release, so
+2 -3
View File
@@ -171,10 +171,9 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -43,10 +43,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -3
View File
@@ -145,10 +145,9 @@ jobs:
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
+2 -3
View File
@@ -191,10 +191,9 @@ jobs:
# pnpm must be on PATH before setup-node so setup-node can locate the store.
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
+30 -7
View File
@@ -55,9 +55,13 @@ jobs:
env:
VITE_EXPOSE_STORE: 'true'
run: |
npx electron-vite build --mode e2e
pnpm run build:web-from-renderer
pnpm run build:relay
status=0
pnpm run build:relay &
relay_pid=$!
npx electron-vite build --mode e2e || status=1
pnpm run build:web-from-renderer || status=1
wait "$relay_pid" || status=1
exit "$status"
- name: Upload E2E build output
uses: actions/upload-artifact@v7
@@ -71,9 +75,29 @@ jobs:
retention-days: 1
if-no-files-found: error
# Build Electron-native dependencies once per workflow. Consumer shards restore
# this immutable cache instead of compiling the same ABI concurrently.
prepare-native-cache:
name: prepare Electron native cache
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
e2e:
name: e2e ${{ matrix.shard_name }}
needs: build
needs: [build, prepare-native-cache]
if: inputs.test_files == ''
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -159,7 +183,7 @@ jobs:
changed-e2e:
name: changed e2e specs
needs: build
needs: [build, prepare-native-cache]
if: inputs.test_files != ''
runs-on: ubuntu-latest
# Why 45: pr.yml now maps SSH source edits onto Docker-backed specs, so this lane can
@@ -227,8 +251,7 @@ 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
needs: [build, prepare-native-cache]
# 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
+2 -3
View File
@@ -43,10 +43,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: Linux golden E2E uses the same native install path as PR/release CI,
# which needs pnpm to bypass its non-executable bundled gyp_main.py.
+2 -3
View File
@@ -135,10 +135,9 @@ jobs:
- name: Setup pnpm
if: steps.freshness.outputs.should_build == 'true'
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
if: steps.freshness.outputs.should_build == 'true'
@@ -24,10 +24,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: mirrors pr.yml so native module rebuilds do not use pnpm's
# non-executable bundled gyp_main.py on Linux runners.
@@ -44,12 +43,9 @@ jobs:
fi
- name: Install dependencies
# Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu
# runners while creating the root node_modules. Use the normal resolver
# path, then verify package metadata stayed unchanged.
run: |
pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false
git diff --exit-code package.json pnpm-lock.yaml
pnpm install --frozen-lockfile
git diff --exit-code package.json pnpm-lock.yaml pnpm-workspace.yaml
- name: Start Weston
run: |
+2 -3
View File
@@ -44,10 +44,9 @@ jobs:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -3
View File
@@ -64,10 +64,9 @@ jobs:
node-version: 24
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
+2 -3
View File
@@ -51,10 +51,9 @@ jobs:
working-directory: mobile
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: the mobile typecheck imports shared types from ../src/shared, and
# some of those files import runtime deps (tweetnacl, ws) resolved from
+6 -9
View File
@@ -864,10 +864,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: Linux terminal golden E2E uses the same native install path as
# release CI, which needs pnpm to bypass its non-executable gyp_main.py.
@@ -1080,10 +1079,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: keep the non-blocking evidence lane on the same Linux native
# install path as the blocking golden and release build jobs.
@@ -1195,10 +1193,9 @@ jobs:
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
+2 -3
View File
@@ -38,10 +38,9 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
+2 -3
View File
@@ -41,10 +41,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Use external node-gyp to avoid pnpm bundled copy
run: |
+2 -3
View File
@@ -73,10 +73,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: this scheduled/manual workflow uses the same native install path as
# PR and E2E CI, which needs pnpm to bypass its bundled gyp_main.py.
+2 -3
View File
@@ -46,10 +46,9 @@ jobs:
# Why: setup-node can restore pnpm's content-addressed store only after
# the pnpm binary exists, avoiding repeat dependency downloads per run.
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
+2 -3
View File
@@ -85,10 +85,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
# Why: the harness only needs its runtime deps (the Playwright Electron
# driver); it drives already-built installer artifacts, so no app build.
@@ -58,10 +58,9 @@ jobs:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -46,10 +46,9 @@ jobs:
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -32,10 +32,9 @@ jobs:
# Why: pnpm must exist before setup-node resolves its dependency cache.
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/setup@v2
with:
version: 10.24.0
run_install: false
install: false
- name: Setup Node.js
uses: actions/setup-node@v6
-2
View File
@@ -1,2 +0,0 @@
shamefully-hoist=true
minimum-release-age=4320
+14
View File
@@ -40,6 +40,18 @@
"react/no-string-refs": "error",
"react/no-unescaped-entities": "error",
"react/require-render-return": "error",
"react/error-boundaries": "error",
"react/globals": "error",
"react/immutability": "off",
"react/incompatible-library": "off",
"react/preserve-manual-memoization": "off",
"react/purity": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/set-state-in-render": "error",
"react/static-components": "off",
"react/use-memo": "error",
"react/void-use-memo": "error",
"react/jsx-curly-brace-presence": [
"error",
{ "props": "never", "children": "never", "propElementValues": "always" }
@@ -62,6 +74,7 @@
"typescript/consistent-type-definitions": ["error", "type"],
"typescript/consistent-type-imports": "error",
"typescript/no-explicit-any": ["error", { "ignoreRestArgs": true }],
"typescript/no-confusing-non-null-assertion": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-unnecessary-boolean-literal-compare": "error",
"typescript/no-unnecessary-template-expression": "error",
@@ -131,6 +144,7 @@
{
"files": ["**/*.test.*", "**/*.spec.*", "**/*-benchmark.*"],
"rules": {
"react/globals": "off",
"quadratic-buffer-concat/no-loop-carried-concat": "off",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
}
@@ -26,6 +26,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable \
&& corepack prepare pnpm@10.24.0 --activate
&& corepack prepare pnpm@12.0.0 --activate \
&& pnpm --version
WORKDIR /workspace
-37
View File
@@ -3,72 +3,40 @@
# 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/main/agent-hooks/server.ts
inline src/main/automations/external-manager.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/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/daemon-init.ts
inline src/main/git/worktree.ts
inline src/main/github/project-view.ts
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/providers/local-pty-provider.ts
inline src/main/rate-limits/service.ts
inline src/main/runtime/orca-runtime-browser.ts
inline src/main/runtime/orca-runtime-files.ts
inline src/main/runtime/orca-runtime.test.ts
inline src/main/runtime/orca-runtime.ts
inline src/main/runtime/rpc/methods/orchestration.ts
inline src/main/runtime/runtime-rpc.ts
inline src/main/source-control/hosted-review-creation.ts
inline src/main/speech/stt-service.ts
inline src/main/ssh/ssh-channel-multiplexer.ts
inline src/main/ssh/ssh-connection.ts
inline src/main/ssh/ssh-relay-deploy.ts
inline src/main/ssh/ssh-relay-session.ts
inline src/main/updater.ts
inline src/main/window/attach-main-window-services.ts
inline src/preload/index.ts
inline src/relay/dispatcher.ts
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/LinearItemDrawer.tsx
inline src/renderer/src/components/TaskPage.tsx
inline src/renderer/src/components/Terminal.tsx
inline src/renderer/src/components/WorktreeJumpPalette.tsx
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/floating-terminal/FloatingTerminalPanel.tsx
inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx
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/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
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/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
@@ -76,16 +44,11 @@ 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/keybindings.ts
inline src/shared/telemetry-events.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/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config src/browser/MobileBrowserPane.tsx
mobile-config src/components/NewWorktreeModal.tsx
mobile-config src/terminal/terminal-webview-html.ts
mobile-config src/transport/rpc-client.ts
@@ -10,7 +10,7 @@ index 855bd4b86f0a3c18c7594212c0e42b6e35bc4001..33774e7ae296f0de39dd94156673c9e7
],
"conditions": [
['OS=="win"', {
@@ -15,14 +14,13 @@
@@ -15,12 +14,11 @@
"src/process_commandline.cc"
],
- "include_dirs": [],
File diff suppressed because one or more lines are too long
+8 -4
View File
@@ -6345,8 +6345,8 @@
"https://github.com/stablyai/orca/issues/16457",
"https://github.com/stablyai/orca/pull/17128"
],
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics. A long-lived POSIX shell whose cwd inode was deleted and replaced may run an extension-enabled OMP launch from the live path named by its logical PWD only in a subshell; the parent shell cwd, OMP argv, status extension, and exit status remain unchanged, while a genuinely unavailable path fails visibly without invoking OMP. A usable current directory remains authoritative when PWD is unset instead of being remapped to a workspace fallback.",
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path. In real interactive Bash and Zsh PTYs, unset PWD in a usable nested directory and require OMP to stay there rather than remap to ORCA_WORKTREE_PATH; then delete and recreate the active project path, require OMP to observe the replacement inode with byte-exact extension argv and its nonzero status preserved, require the parent shell to remain on the stale inode, and finally delete the replacement and require an actionable failure before the fake OMP binary runs again.",
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics. A long-lived POSIX shell whose cwd inode was deleted and replaced may run a wrapped OMP command from the live path named by its logical PWD only in a subshell; the parent shell cwd, OMP argv, status extension, and exit status remain unchanged, while a genuinely unavailable path fails visibly without invoking OMP. A usable physical cwd remains authoritative when PWD is unset or names a removed logical symlink instead of being remapped to a workspace fallback.",
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path. In real interactive Bash and Zsh PTYs, unset PWD and remove a logical symlink while the physical cwd stays usable, requiring OMP to remain there; then delete and recreate the active project path, require extension-enabled and extension-skipping invocations to observe the replacement inode with byte-exact argv and nonzero status preserved, require the parent shell to remain on the stale inode, unset PWD and require recovery through ORCA_WORKTREE_PATH, clear every logical fallback and require an actionable failure without invoking OMP, and finally delete the replacement and require the same for the missing PWD path.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-spawn-cwd-fallback.test.ts src/main/ipc/pty-wsl-cwd-validation.test.ts",
@@ -6410,6 +6410,10 @@
"assertions": [
"real Bash and Zsh PTYs rebind OMP to a recreated cwd inode without changing the parent shell",
"an unset PWD with a usable cwd stays unset and does not remap OMP to ORCA_WORKTREE_PATH",
"a removed logical symlink does not invalidate its still-usable physical cwd",
"extension-skipping commands such as omp --version still recover the cwd without gaining extension argv",
"an unset PWD on a stale inode recovers through ORCA_WORKTREE_PATH",
"an unset PWD on a stale inode without a logical fallback fails visibly before invoking OMP",
"extension argv and a nonzero OMP exit status survive the recovery subshell",
"a subsequently missing logical cwd prints an actionable error and does not invoke OMP again"
]
@@ -6465,7 +6469,7 @@
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/pty/omp-shell-wrapper.node-pty.test.ts src/main/pty/omp-shell-wrapper-alias-safety.test.ts src/main/shell-wrapper-generated-file-snapshot.test.ts",
"result": "passed",
"durationSeconds": 3,
"summary": "3 test files passed; real Bash and Zsh PTYs covered unset-PWD authority, stale-inode recovery, parent-shell isolation, extension argv, exit status, missing-path visibility, alias safety, and generated local/daemon/relay parity."
"summary": "3 test files passed; real Bash and Zsh PTYs covered usable physical cwd authority, stale-inode recovery with set and unset PWD, extension-enabled and extension-skipping argv, parent-shell isolation, exit status, missing-path visibility, alias safety, and generated local/daemon/relay parity."
}
],
"runtimeBudget": {
@@ -6482,7 +6486,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "The spawn recovery adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs. Each extension-enabled OMP launch adds a constant number of shell filesystem predicates; only a detected stale/unusable cwd creates one short recovery subshell. No polling, retry, provider listing, hidden-pane work, startup awaits, extra OMP subprocesses, or render-loop work was added."
"evidence": "The spawn recovery adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs. Each wrapped OMP launch adds a constant number of shell filesystem predicates; only a detected stale/unusable cwd creates one short recovery subshell. No polling, retry, provider listing, hidden-pane work, startup awaits, extra OMP subprocesses, or render-loop work was added."
},
"promotionCriteria": [
"Attach CI evidence for all declared test files.",
+3 -8
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
if (process.platform === 'win32') {
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
@@ -18,14 +19,8 @@ runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
const command = npmExecPath
? process.execPath
: process.platform === 'win32'
? 'pnpm.cmd'
: 'pnpm'
const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName]
const result = spawnSync(command, args, { stdio: 'inherit' })
const { command, prefixArgs, shell } = resolvePnpmCliInvocation()
const result = spawnSync(command, [...prefixArgs, 'run', scriptName], { stdio: 'inherit', shell })
if (result.signal) {
process.kill(process.pid, result.signal)
@@ -29,11 +29,11 @@ import {
readSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { join, resolve } from 'node:path'
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
import {
nodeGypRebuildInvocation,
stageWindowsProcessTreeNodeAddonApiHeaders,
WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR
} from './windows-process-tree-gyp-rebuild.mjs'
@@ -96,10 +96,6 @@ function assertPatchApplied() {
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
@@ -134,10 +130,7 @@ function applyWindowsProcessTreeBuildFixes() {
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))
}
stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)
if (bindingGyp !== originalBinding || processCc !== originalProcess) {
console.warn('[windows-process-tree] Repaired un-applied pnpm patch hunks before build.')
}
@@ -171,7 +171,7 @@ describe('computer-use e2e workflow', () => {
expect(checkout.with['persist-credentials'], jobName).toBe(false)
expect(install.with['native-runtime'], jobName).toBe('electron')
expect(
job.steps.some((step) => step.uses === 'pnpm/action-setup@v6'),
job.steps.some((step) => step.uses === 'pnpm/setup@v2'),
jobName
).toBe(false)
expect(
+2 -1
View File
@@ -377,7 +377,8 @@ function rebuildNodeRuntimeModules(moduleNames) {
}
function runPnpm(args, { cwd = projectDir } = {}) {
const command = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
// cmd.exe resolves both Corepack's pnpm.cmd and pnpm 12's native pnpm.exe.
const command = 'pnpm'
const env =
process.platform === 'linux' && args.includes('node-gyp')
? { ...process.env, CXXFLAGS: `${process.env.CXXFLAGS ?? ''} -std=gnu++2a`.trim() }
@@ -24,10 +24,15 @@ function createFixture() {
for (const directory of [workspace, detachedCwd]) {
writeFileSync(join(directory, 'package.json'), '{"name":"fixture"}\n')
writeFileSync(join(directory, 'pnpm-lock.yaml'), 'lockfileVersion: 9\n')
writeFileSync(join(directory, 'pnpm-workspace.yaml'), 'packages: []\n')
}
expect(run('git', ['init', '-q'], { cwd: workspace }).status).toBe(0)
expect(run('git', ['add', 'package.json', 'pnpm-lock.yaml'], { cwd: workspace }).status).toBe(0)
expect(
run('git', ['add', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'], {
cwd: workspace
}).status
).toBe(0)
const pnpm = join(bin, 'pnpm')
writeFileSync(pnpm, '#!/bin/sh\nexit 0\n')
@@ -76,7 +81,8 @@ describe('install-node-dependencies action', () => {
it.each([
['package.json', '{"name":"changed"}\n'],
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n']
['pnpm-lock.yaml', 'lockfileVersion: 9\nchanged: true\n'],
['pnpm-workspace.yaml', 'packages: []\nchanged: true\n']
])('rejects a changed %s when the composite step cwd is detached', (file, contents) => {
const fixture = createFixture()
try {
@@ -8,14 +8,13 @@ import { relayArtifactFilenames } from '../../src/shared/relay-artifacts.ts'
const projectDir = resolve(import.meta.dirname, '../..')
const require = createRequire(import.meta.url)
const { createPackagedRuntimeNodeModuleResources } = require('../packaged-runtime-node-modules.cjs')
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
const readProject = (file) => readFileSync(join(projectDir, file), 'utf8')
const packageJson = JSON.parse(readProject('package.json'))
const pnpmWorkspace = parse(readProject('pnpm-workspace.yaml'))
describe('Electron runtime package contract', () => {
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
const patch = readFileSync(
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
'utf8'
)
const patch = readProject('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
expect(patch).toContain('readonly clearModelGeneration: number')
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
@@ -26,7 +25,7 @@ describe('Electron runtime package contract', () => {
it('keeps root postinstall as the single Electron binary install owner', () => {
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
expect(pnpmWorkspace.allowBuilds).not.toHaveProperty('electron')
})
it('keeps the native Windows registry addon optional and platform-gated', () => {
@@ -41,7 +40,7 @@ describe('Electron runtime package contract', () => {
expect(packageJson.optionalDependencies['windows-native-registry']).toBe('3.2.2')
// Why: pnpm installs optional target architectures on every host; the root
// Windows-only rebuild owns this addon so macOS/Linux never run node-gyp for it.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('windows-native-registry')
expect(pnpmWorkspace.allowBuilds['windows-native-registry']).toBe(false)
// Why assert the guard and the member separately: the list now carries more
// than one addon, so pinning the whole literal only tested its formatting.
expect(rebuildScript).toContain("rebuildPlatform === 'win32'")
@@ -80,14 +79,14 @@ describe('Electron runtime package contract', () => {
expect(packageJson.optionalDependencies['@vscode/windows-process-tree']).toBe('0.8.0')
// Why: same rule as the registry addon -- pnpm installs optional deps on
// every host, so macOS/Linux must never run node-gyp for a Windows addon.
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('@vscode/windows-process-tree')
expect(pnpmWorkspace.allowBuilds['@vscode/windows-process-tree']).toBe(false)
expect(rebuildScript).toContain("'@vscode/windows-process-tree'")
expect(ensureScript).toContain("'@vscode/windows-process-tree'")
// Why pin the patch: the upstream binding.gyp requires Spectre-mitigated
// libraries our build agents do not carry, and the enumeration stops after
// 1024 processes -- on a busy host that silently hides the very descendants
// teardown is looking for.
expect(packageJson.pnpm.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(
expect(pnpmWorkspace.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
const packageTargets = {
+26
View File
@@ -0,0 +1,26 @@
// pnpm 12's npm_execpath is a native binary; feeding it to node broke hourly/adhoc macOS builds.
const JS_CLI_EXTENSION = /\.[cm]?js$/i
export function resolvePnpmCliInvocation({
npmExecPath = process.env.npm_execpath,
nodeExecPath = process.execPath,
platform = process.platform
} = {}) {
if (typeof npmExecPath === 'string' && npmExecPath.length > 0) {
if (JS_CLI_EXTENSION.test(npmExecPath)) {
return { command: nodeExecPath, prefixArgs: [npmExecPath], shell: false }
}
return {
command: npmExecPath,
prefixArgs: [],
shell: platform === 'win32' && /\.(cmd|bat)$/i.test(npmExecPath)
}
}
return {
command: platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
prefixArgs: [],
shell: platform === 'win32'
}
}
+100
View File
@@ -0,0 +1,100 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
const nodeExecPath = '/usr/local/bin/node'
describe('resolvePnpmCliInvocation', () => {
it('runs a JS CLI through node so older pnpm.cjs still works', () => {
expect(
resolvePnpmCliInvocation({
npmExecPath: '/Users/runner/setup-pnpm/node_modules/pnpm/bin/pnpm.cjs',
nodeExecPath,
platform: 'darwin'
})
).toEqual({
command: nodeExecPath,
prefixArgs: ['/Users/runner/setup-pnpm/node_modules/pnpm/bin/pnpm.cjs'],
shell: false
})
})
it('executes pnpm 12 native binaries directly instead of through node', () => {
expect(
resolvePnpmCliInvocation({
npmExecPath: '/Users/runner/setup-pnpm/pnpm',
nodeExecPath,
platform: 'darwin'
})
).toEqual({
command: '/Users/runner/setup-pnpm/pnpm',
prefixArgs: [],
shell: false
})
})
it('does not wrap a Windows native pnpm.exe in node', () => {
expect(
resolvePnpmCliInvocation({
npmExecPath: 'C:\\hostedtoolcache\\pnpm.exe',
nodeExecPath: 'C:\\Program Files\\nodejs\\node.exe',
platform: 'win32'
})
).toEqual({
command: 'C:\\hostedtoolcache\\pnpm.exe',
prefixArgs: [],
shell: false
})
})
it('shells out for a Windows .cmd wrapper', () => {
expect(
resolvePnpmCliInvocation({
npmExecPath: 'C:\\Users\\runner\\pnpm.cmd',
nodeExecPath: 'C:\\Program Files\\nodejs\\node.exe',
platform: 'win32'
})
).toEqual({
command: 'C:\\Users\\runner\\pnpm.cmd',
prefixArgs: [],
shell: true
})
})
it('treats .js and .mjs CLIs the same as .cjs', () => {
for (const npmExecPath of ['/opt/pnpm.js', '/opt/pnpm.mjs']) {
expect(resolvePnpmCliInvocation({ npmExecPath, nodeExecPath, platform: 'linux' })).toEqual({
command: nodeExecPath,
prefixArgs: [npmExecPath],
shell: false
})
}
})
it('falls back to PATH pnpm when npm_execpath is unset', () => {
expect(
resolvePnpmCliInvocation({ npmExecPath: undefined, nodeExecPath, platform: 'darwin' })
).toEqual({ command: 'pnpm', prefixArgs: [], shell: false })
expect(resolvePnpmCliInvocation({ npmExecPath: '', nodeExecPath, platform: 'linux' })).toEqual({
command: 'pnpm',
prefixArgs: [],
shell: false
})
expect(
resolvePnpmCliInvocation({ npmExecPath: undefined, nodeExecPath, platform: 'win32' })
).toEqual({ command: 'pnpm.cmd', prefixArgs: [], shell: true })
})
})
describe('pnpm 12 native-cli callers', () => {
it('reinvokes pnpm through the helper rather than `node $npm_execpath`', () => {
for (const file of [
'./build-native-for-platform.mjs',
'./run-ssh-docker-bulk-open-freeze-e2e.mjs'
]) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8')
expect(source).toContain("from './pnpm-cli-invocation.mjs'")
expect(source).not.toMatch(/process\.execPath,\s*\[\s*(?:pnpmEntry|npmExecPath)/)
}
})
})
@@ -9,6 +9,7 @@ const dependencyAction = parse(
readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const pnpmWorkspace = parse(readFileSync('pnpm-workspace.yaml', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
@@ -224,7 +225,10 @@ 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(packageManagerVersion).toBe('12.0.0')
expect(steps[pnpmIndex].uses).toBe('pnpm/setup@v2')
expect(steps[pnpmIndex].with.version).toBeUndefined()
expect(steps[pnpmIndex].with.install).toBe(false)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -232,20 +236,20 @@ describe('PR workflow parallelism', () => {
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
})
it('pins every direct pnpm setup to the repository package-manager version', () => {
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
it('uses the repository package-manager version for every direct pnpm setup', () => {
const directSetups = globSync('.github/workflows/*.yml').flatMap((workflowPath) => {
const parsed = parse(readFileSync(workflowPath, 'utf8'))
return Object.values(parsed.jobs ?? {}).flatMap((job) =>
(job.steps ?? [])
.filter((step) => step.uses === 'pnpm/action-setup@v6')
.filter((step) => step.uses === 'pnpm/setup@v2')
.map((step) => ({ workflowPath, step }))
)
})
expect(directSetups.length).toBeGreaterThan(0)
for (const { workflowPath, step } of directSetups) {
expect(step.with?.version, workflowPath).toBe(packageManagerVersion)
expect(step.with?.version, workflowPath).toBeUndefined()
expect(step.with?.install, workflowPath).toBe(false)
}
})
@@ -293,15 +297,15 @@ describe('PR workflow parallelism', () => {
expect(dependencyInstall.run).toContain('--frozen-lockfile')
expect(dependencyInstall.run).not.toContain('--no-frozen-lockfile')
expect(dependencyInstall.run).toContain(
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml'
'git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml'
)
expect(dependencyInstall.run).toContain('--ignore-scripts')
expect(dependencyInstall.run).not.toContain('--os=')
expect(dependencyInstall.run).not.toContain('--cpu=')
expect(packageJson.pnpm.supportedArchitectures.os).toEqual(
expect(pnpmWorkspace.supportedArchitectures.os).toEqual(
expect.arrayContaining(['current', 'win32'])
)
expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current')
expect(pnpmWorkspace.supportedArchitectures.cpu).toContain('current')
const prepareRuntime = dependencyAction.runs.steps.find(
(step) => step.name === 'Prepare native runtime'
)
+9
View File
@@ -20,6 +20,7 @@
import { rebuild } from '@electron/rebuild'
import { execFileSync, spawnSync } from 'node:child_process'
import { stageWindowsProcessTreeNodeAddonApiHeaders } from './windows-process-tree-gyp-rebuild.mjs'
import {
copyFileSync,
existsSync,
@@ -132,6 +133,14 @@ if (!ignoreModules.includes('cpu-features')) {
}
}
if (
rebuildPlatform === 'win32' &&
modulesToRebuild.includes('@vscode/windows-process-tree') &&
existsSync(join(projectDir, 'node_modules', '@vscode', 'windows-process-tree', 'package.json'))
) {
stageWindowsProcessTreeNodeAddonApiHeaders()
}
try {
await rebuild({
buildPath: projectDir,
@@ -21,6 +21,9 @@ const sourceInstallScriptPath = fileURLToPath(
const sourceNodePtyJobOwnershipPath = fileURLToPath(
new URL('./node-pty-job-ownership.cjs', import.meta.url)
)
const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath(
new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url)
)
describe('rebuild-native-deps Electron install fallback', () => {
it('continues non-strict postinstall when Electron retry download fails', () => {
@@ -159,6 +162,41 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
}
)
it('stages windows-process-tree node-addon-api headers before a Windows rebuild', () => {
const projectDir = mkTempProject()
try {
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
writeFakeElectronRebuild(projectDir)
writeFakeNodePtyConptyPayload(projectDir, 'x64')
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir)
const result = runRebuildScript(
projectDir,
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
['--platform=win32', '--arch=x64', '--force']
)
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(
join(
projectDir,
'node_modules',
'@vscode',
'windows-process-tree',
'deps',
'node-addon-api',
'napi.h'
),
'utf8'
)
).toBe('// napi.h\n')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => {
const projectDir = mkTempProject()
@@ -344,6 +382,10 @@ function mkTempProject() {
sourceNodePtyJobOwnershipPath,
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
)
copyFileSync(
sourceWindowsProcessTreeGypRebuildPath,
join(projectDir, 'config', 'scripts', 'windows-process-tree-gyp-rebuild.mjs')
)
return projectDir
}
@@ -578,6 +620,18 @@ function writeFakeWindowsProcessTree(projectDir) {
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
}
function writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) {
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api')
mkdirSync(nodeAddonApiDir, { recursive: true })
writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n')
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n')
writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n')
writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n')
writeFileSync(join(nodeAddonApiDir, 'napi-inl.deprecated.h'), '// napi-inl.deprecated.h\n')
}
function writeNodePtyPatchFile(projectDir) {
mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true })
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
+9 -8
View File
@@ -22,13 +22,15 @@ const DEFAULT_REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const MANIFEST_RELATIVE_PATH = path.join('config', 'patches', 'xterm-upstream.json')
/**
* Flags pnpm@10 passes to `git diff` in its own `diffFolders()`. A patch built
* Flags pnpm@12 passes to `git diff` in its own `diff_folders()`. A patch built
* with anything else is a patch pnpm may re-diff differently on the next
* `pnpm patch-commit`, so the byte-comparison gate would never settle.
*/
export const PNPM_DIFF_FLAGS = [
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
@@ -38,7 +40,8 @@ export const PNPM_DIFF_FLAGS = [
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
'--no-color',
'--'
]
/**
@@ -47,14 +50,12 @@ export const PNPM_DIFF_FLAGS = [
*/
export const CHECKOUT_DIFF_FLAGS = PNPM_DIFF_FLAGS.filter((flag) => flag !== '--no-index')
/** Blanks the vars pnpm blanks so user and system git config cannot reach the diff. */
/** Applies pnpm's git config isolation so local machine settings cannot change the patch. */
export function pnpmDiffEnvironment(baseEnvironment = process.env) {
return {
...baseEnvironment,
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
GIT_CONFIG_GLOBAL: '/dev/null'
}
}
@@ -216,7 +217,7 @@ export function patchHash(patchText) {
function lockfilePatchHashPattern(packageKey) {
// Unscoped keys such as `node-pty@1.1.0` are emitted unquoted.
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?:\\n hash: )([0-9a-f]{64})$`, 'm')
return new RegExp(`(^ '?${escapeRegExp(packageKey)}'?: )([0-9a-f]{64})$`, 'm')
}
export function readLockfilePatchHash(lockfileText, packageKey) {
@@ -507,7 +508,7 @@ function diffFolders(folderA, folderB) {
/** The source of truth for the hand-written half: what the checkout itself holds. */
function diffCheckoutSource(packageRoot) {
return run('git', [...CHECKOUT_DIFF_FLAGS, '--', 'src/'], {
return run('git', [...CHECKOUT_DIFF_FLAGS, 'src/'], {
cwd: packageRoot,
env: pnpmDiffEnvironment(),
maxBuffer: 64 * 1024 * 1024
@@ -98,6 +98,8 @@ describe('pnpm diff format', () => {
expect(PNPM_DIFF_FLAGS).toEqual([
'-c',
'core.safecrlf=false',
'-c',
'core.quotePath=false',
'diff',
'--src-prefix=a/',
'--dst-prefix=b/',
@@ -107,18 +109,18 @@ describe('pnpm diff format', () => {
'--no-index',
'--text',
'--no-ext-diff',
'--no-color'
'--no-color',
'--'
])
})
it('blanks the config-bearing environment variables', () => {
it('matches pnpm git config isolation', () => {
const environment = pnpmDiffEnvironment({ PATH: '/usr/bin', HOME: '/Users/someone' })
expect(environment).toMatchObject({
PATH: '/usr/bin',
GIT_CONFIG_NOSYSTEM: '1',
HOME: '',
XDG_CONFIG_HOME: '',
USERPROFILE: ''
GIT_CONFIG_GLOBAL: '/dev/null',
HOME: '/Users/someone'
})
})
@@ -350,12 +352,8 @@ describe('manifest guards', () => {
describe('lockfile coupling', () => {
const lockfile = [
'patchedDependencies:',
" '@xterm/xterm@6.1.0-beta.287':",
` hash: ${'0'.repeat(64)}`,
' path: config/patches/@xterm__xterm@6.1.0-beta.287.patch',
' node-pty@1.1.0:',
` hash: ${'1'.repeat(64)}`,
' path: config/patches/node-pty@1.1.0.patch',
` '@xterm/xterm@6.1.0-beta.287': ${'0'.repeat(64)}`,
` node-pty@1.1.0: ${'1'.repeat(64)}`,
'snapshots:',
` '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=${'0'.repeat(64)}))':`,
` '@xterm/xterm': 6.1.0-beta.287(patch_hash=${'0'.repeat(64)})`,
@@ -396,7 +394,10 @@ describe('lockfile coupling', () => {
it('reports a lockfile stale in its resolution keys alone', () => {
const key = '@xterm/xterm@6.1.0-beta.287'
const halfUpdated = lockfile.replace(`hash: ${'0'.repeat(64)}`, `hash: ${'a'.repeat(64)}`)
const halfUpdated = lockfile.replace(
`'@xterm/xterm@6.1.0-beta.287': ${'0'.repeat(64)}`,
`'@xterm/xterm@6.1.0-beta.287': ${'a'.repeat(64)}`
)
expect(readLockfilePatchHash(halfUpdated, key)).toBe('a'.repeat(64))
expect(lockfilePatchHashIsStale(halfUpdated, key, 'a'.repeat(64))).toBe(true)
@@ -8,6 +8,7 @@ const EXPECTED_MATRIX = {
'.github/workflows/e2e.yml#build': { contents: 'read' },
'.github/workflows/e2e.yml#changed-e2e': { contents: 'read' },
'.github/workflows/e2e.yml#e2e': { contents: 'read' },
'.github/workflows/e2e.yml#prepare-native-cache': { contents: 'read' },
'.github/workflows/e2e.yml#ssh-docker-watcher-isolation': { contents: 'read' },
'.github/workflows/homebrew-bump.yml#bump-cask': { contents: 'read' },
'.github/workflows/release-mac-build.yml#build-mac': { contents: 'write' },
@@ -46,6 +46,28 @@ describe('release E2E dispatch contract', () => {
expect(refInput.required).toBe(false)
})
it('overlaps the relay bundle with the Electron build', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
expect(buildStep.run).toContain('pnpm run build:relay &')
expect(buildStep.run).toContain('relay_pid=$!')
expect(buildStep.run).toContain('wait "$relay_pid"')
expect(buildStep.run).toContain('pnpm run build:web-from-renderer')
})
it('primes the Electron native cache before every E2E consumer', () => {
const primer = e2eWorkflow.jobs['prepare-native-cache']
expect(primer.steps).toBeDefined()
expect(
primer.steps.find((step) => step.uses === './.github/actions/install-node-dependencies').with
).toEqual({
'native-runtime': 'electron'
})
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
expect(e2eWorkflow.jobs[jobName].needs, jobName).toEqual(['build', 'prepare-native-cache'])
}
})
it('includes the paired-runtime web client in the shared E2E build artifact', () => {
const buildStep = e2eWorkflow.jobs.build.steps.find((step) => step.name === 'Build E2E outputs')
@@ -71,7 +93,7 @@ describe('release E2E dispatch contract', () => {
const downloadStep = job.steps.find((step) => step.name === 'Download E2E build output')
const runStep = job.steps.find((step) => step.name === runStepName)
expect(job.needs).toBe('build')
expect(job.needs).toEqual(['build', 'prepare-native-cache'])
expect(downloadStep.with.name).toBe('e2e-build-out')
expect(downloadStep.with.path).toBe('out/')
expect(runStep.run).toContain('ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay"')
@@ -1,18 +1,17 @@
import { spawnSync } from 'node:child_process'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
const extraArgs = process.argv.slice(2)
const pnpmEntry = process.env.npm_execpath
if (!pnpmEntry) {
throw new Error('npm_execpath is required; run this harness through pnpm')
}
const { command: pnpm, prefixArgs: pnpmPrefix, shell } = resolvePnpmCliInvocation()
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
const runtime = spawnSync(process.execPath, [pnpmEntry, 'run', 'ensure:electron-runtime'], {
const runtime = spawnSync(pnpm, [...pnpmPrefix, 'run', 'ensure:electron-runtime'], {
stdio: 'inherit',
env
env,
shell
})
if (runtime.status !== 0) {
@@ -20,9 +19,9 @@ if (runtime.status !== 0) {
}
const result = spawnSync(
process.execPath,
pnpm,
[
pnpmEntry,
...pnpmPrefix,
'exec',
'playwright',
'test',
@@ -36,7 +35,8 @@ const result = spawnSync(
],
{
stdio: 'inherit',
env
env,
shell
}
)
@@ -34,7 +34,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { join, resolve } from 'node:path'
// Why @electron/asar: canonical replacement for the deprecated `asar` package.
// It's transitively available via electron-builder (and pnpm's
// `shamefully-hoist=true` in `.npmrc` flattens it into the root
// `shamefullyHoist: true` in `pnpm-workspace.yaml` flattens it into the root
// `node_modules`). If electron-builder ever drops it, promote this to a
// direct devDependency in package.json.
import { extractFile, listPackage } from '@electron/asar'
@@ -20,15 +20,22 @@ describe('windows-process-tree node-addon-api gyp path', () => {
join(projectDir, 'config/scripts/build-windows-process-tree-relay-addon.mjs'),
'utf8'
)
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('stageWindowsProcessTreeNodeAddonApiHeaders(PACKAGE_DIR)')
expect(buildScript).toContain('Repaired un-applied pnpm patch hunks before build.')
const rebuildHelper = readFileSync(
join(projectDir, 'config/scripts/windows-process-tree-gyp-rebuild.mjs'),
'utf8'
)
expect(rebuildHelper).toContain("createRequire(join(packageDir, 'package.json'))")
expect(rebuildHelper).toContain("resolve('node-addon-api/package.json')")
expect(rebuildHelper).toContain("'napi.h'")
expect(rebuildHelper).toContain("'napi-inl.h'")
expect(rebuildHelper).toContain("'napi-inl.deprecated.h'")
const rebuildScript = readFileSync(
join(projectDir, 'config/scripts/rebuild-native-deps.mjs'),
'utf8'
)
expect(rebuildScript).toContain('stageWindowsProcessTreeNodeAddonApiHeaders()')
})
it('resolves node_addon_api.gyp to a real file from the package directory', () => {
@@ -9,8 +9,9 @@
* hop escapes the store and configure fails with "node_addon_api.gyp not
* found" (run 32999886072).
*/
import { realpathSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { copyFileSync, mkdirSync, realpathSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
const ROOT = resolve(import.meta.dirname, '..', '..')
@@ -21,6 +22,12 @@ export const WINDOWS_PROCESS_TREE_PACKAGE_DIR = join(
'windows-process-tree'
)
export const WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS = [
'napi.h',
'napi-inl.h',
'napi-inl.deprecated.h'
]
export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR) {
return {
args: [
@@ -31,3 +38,18 @@ export function nodeGypRebuildInvocation(arch, packageDir = WINDOWS_PROCESS_TREE
cwd: realpathSync(packageDir)
}
}
// Patched binding.gyp includes deps/node-addon-api; the tarball does not ship those headers.
export function stageWindowsProcessTreeNodeAddonApiHeaders(
packageDir = WINDOWS_PROCESS_TREE_PACKAGE_DIR
) {
const nodeAddonApiDir = dirname(
createRequire(join(packageDir, 'package.json')).resolve('node-addon-api/package.json')
)
const stagedHeaderDir = join(packageDir, 'deps', 'node-addon-api')
mkdirSync(stagedHeaderDir, { recursive: true })
for (const header of WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS) {
copyFileSync(join(nodeAddonApiDir, header), join(stagedHeaderDir, header))
}
return stagedHeaderDir
}
@@ -1,9 +1,20 @@
import { execFileSync } from 'node:child_process'
import { existsSync, realpathSync } from 'node:fs'
import { resolve } from 'node:path'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
nodeGypRebuildInvocation,
stageWindowsProcessTreeNodeAddonApiHeaders,
WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS,
WINDOWS_PROCESS_TREE_PACKAGE_DIR
} from './windows-process-tree-gyp-rebuild.mjs'
@@ -26,4 +37,25 @@ describe('windows-process-tree node-gyp rebuild', () => {
expect(args).toContain('rebuild')
expect(args).toContain('--arch=arm64')
})
it('copies node-addon-api headers into the patched include dir', () => {
const packageDir = mkdtempSync(join(tmpdir(), 'orca-windows-process-tree-headers-'))
try {
const nodeAddonApiDir = join(packageDir, 'node_modules', 'node-addon-api')
mkdirSync(nodeAddonApiDir, { recursive: true })
writeFileSync(join(packageDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n')
writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n')
for (const header of WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS) {
writeFileSync(join(nodeAddonApiDir, header), `// ${header}\n`)
}
const stagedDir = stageWindowsProcessTreeNodeAddonApiHeaders(packageDir)
expect(stagedDir).toBe(join(packageDir, 'deps', 'node-addon-api'))
for (const header of WINDOWS_PROCESS_TREE_NODE_ADDON_API_HEADERS) {
expect(readFileSync(join(stagedDir, header), 'utf8')).toBe(`// ${header}\n`)
}
} finally {
rmSync(packageDir, { recursive: true, force: true })
}
})
})
@@ -1,6 +1,7 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { parseAllDocuments } from 'yaml'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
@@ -16,7 +17,9 @@ describe('Windows process-tree patch contract', () => {
const patchHash = createHash('sha256')
.update(patchBytes.toString('utf8').replaceAll('\r\n', '\n'))
.digest('hex')
const lockfile = readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8')
expect(lockfile).toContain(`hash: ${patchHash}`)
const lockfile = parseAllDocuments(readFileSync(join(projectDir, 'pnpm-lock.yaml'), 'utf8'))
.map((document) => document.toJS())
.find((document) => document.patchedDependencies)
expect(lockfile.patchedDependencies['@vscode/windows-process-tree@0.8.0']).toBe(patchHash)
})
})
+13
View File
@@ -38,11 +38,24 @@
"../src/main/codex/codex-config-settings-removal.ts",
"../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-definition.ts",
"../src/main/codex/codex-managed-home-resource-copy-marker.ts",
"../src/main/codex/codex-managed-trust-grant-plan.ts",
"../src/main/codex/codex-path-observation.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-legacy-cleanup.ts",
"../src/main/codex/codex-hook-local-install.ts",
"../src/main/codex/codex-hook-local-maintenance.ts",
"../src/main/codex/codex-hook-remote-install.ts",
"../src/main/codex/codex-hook-script.ts",
"../src/main/codex/codex-hook-service-implementation.ts",
"../src/main/codex/codex-hook-status.ts",
"../src/main/codex/codex-hook-system-trust.ts",
"../src/main/codex/codex-hook-trust-cleanup.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-hook-trust-queue.ts",
"../src/main/codex/codex-hook-user-mirroring.ts",
"../src/main/codex/codex-hook-wsl-runtime.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-state-db.ts",
+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: 32m">
<title>downloads: 32m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 33m">
<title>downloads: 33m</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">32m</text>
<text x="90" y="14">32m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">33m</text>
<text x="90" y="14">33m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+39 -4
View File
@@ -95,16 +95,51 @@ negotiated capabilities differ from the contract. Adding an optional field keeps
green (Rule 1); making a client depend on that field turns the new-client/old-host
pairing red.
### Never write down what the old side has
The baseline is whichever release tag is newest, so it moves on every cut. An
expectation of the form "the old side does not have X" — a `not.toHaveProperty`, a
`not.toContain`, a hard-coded field list — stops being true the first time a release
ships X. The suite then reddens on whatever pull request is in flight, with no code
change anywhere, and the job trains people to ignore it. That is worse than no test,
because a rolling baseline eventually contains every additive field the wire has, and
adding one is the sanctioned way to evolve it.
Derive the expectation from the baseline that was actually checked out:
- for a published frame, pair each build against a client of its own version and
compare the skewed pairing against that same-version reference, so the expectation
is whatever that build publishes today (`publishedFieldNames` in
`tests/e2e/cross-version-wire/published-field-shape.ts`);
- for a negotiated surface, read the old build's advertised capabilities and
registered method names from its checkout, and assert they agree with each other
rather than asserting the old build lacks them;
- for a "client too old to know X", derive that client's advertised list by removing
X from the baseline's own list, so the gate stays exercised after X ships.
Name the direction in the assertion. `new client against old server` and `old client
against new server` fail for different reasons, and the host is the only side that
authors a published frame — the terminal `terminalOwner` false positive on 2026-08-29
was misread as a new client sending an unknown field when the old server was
publishing it. Two things are still safe to state literally: the current build's own
contract, and an invariant that holds for every version.
Pinning a legacy ref is the fallback when a contract genuinely needs a release from
before a feature shipped, as `cross-version-browser-placement.unit.test.ts` does with
`LEGACY_BROWSER_PLACEMENT_RELEASE_REF`. It does not rot on a cut, but it is
hand-maintained, so prefer deriving.
`tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts` pairs the
same two builds over the structured `agentSession.*` surface. Because a released build
cannot name a capability string its own source never contains, the old side's advertised
list and registered method names are read from the extracted checkout rather than
hand-written. It covers the three skews that surface can fail on:
- an old client — advertising only what the baseline build defines — is told the whole
surface does not exist and reaches no host method;
- a new client against the old dispatcher gets `method_not_found` on every method, and
can see the absence during negotiation instead of by calling;
- an old client — advertising the baseline's list minus this capability — is told the
whole surface does not exist and reaches no host method;
- a new client against the old dispatcher always gets an answer rather than silence,
and `method_not_found` for every method that release does not register, so the
absence is visible during negotiation instead of by calling;
- a cursor survives a host restart: the client's fence is refused as stale with the live
one attached, and resuming from the held cursor replays only what it missed.
@@ -206,7 +206,7 @@ The addon is Windows-only, so it follows the same contract as
`config/scripts/package-electron-runtime-contract.test.mjs`):
- an `optionalDependency`, so a macOS/Linux install tolerates its absence;
- **not** in `pnpm.onlyBuiltDependencies` — pnpm installs optional dependencies
- **not** enabled in `allowBuilds` in `pnpm-workspace.yaml` — pnpm installs optional dependencies
on every host, and macOS/Linux must never run `node-gyp` for it;
- listed in the win32 branch of `rebuild-native-deps.mjs` and
`ensure-native-runtime.mjs`;
-1
View File
@@ -1 +0,0 @@
minimum-release-age=4320
-24
View File
@@ -45,30 +45,12 @@
"max-lines": ["error", { "max": 2152, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1603, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/browser/MobileBrowserPane.tsx"],
"rules": {
"max-lines": ["error", { "max": 1594, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/index.tsx"],
"rules": {
"max-lines": ["error", { "max": 1422, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/components/NewWorktreeModal.tsx"],
"rules": {
"max-lines": ["error", { "max": 1263, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["src/transport/rpc-client.ts"],
"rules": {
@@ -81,12 +63,6 @@
"max-lines": ["error", { "max": 407, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/troubleshoot.tsx"],
"rules": {
"max-lines": ["error", { "max": 436, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/h/*/files/*.tsx"],
"rules": {
File diff suppressed because it is too large Load Diff
+125 -82
View File
@@ -182,6 +182,7 @@ import {
} from '../../../../src/session/mobile-file-syntax'
import {
getTerminalRecordsFromSessionTabs,
hasConnectedTerminalAbsentFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
@@ -245,6 +246,11 @@ import {
} from '../../../../src/session/mobile-terminal-prune-decision'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { MobileTerminalInventoryRequest } from '../../../../src/session/mobile-terminal-inventory-request'
import {
useMobileTerminalInventoryRecoveryBridge,
type MobileTerminalInventoryRefreshOptions
} from '../../../../src/session/use-mobile-terminal-inventory-recovery'
import {
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
@@ -1277,6 +1283,9 @@ export default function SessionScreen() {
)
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
unsubscribeTerminalRef.current = unsubscribeTerminal
const terminalInventoryRecoveryScope = JSON.stringify([hostId, worktreeId])
const { registerTerminalInventoryRecoveryAction, signalTerminalInventoryRecovery } =
useMobileTerminalInventoryRecoveryBridge(terminalInventoryRecoveryScope)
const clearTerminalCache = useCallback(() => {
terminalUnsubsRef.current.forEach((unsub) => unsub())
@@ -1379,6 +1388,7 @@ export default function SessionScreen() {
diagnostics.firstStreamEvent(handle, seq, data.type)
if (data.type === 'end' || data.type === 'error') {
unsubscribeTerminalRef.current(handle)
signalTerminalInventoryRecovery()
return
}
if (data.type === 'subscribed') {
@@ -1522,7 +1532,10 @@ export default function SessionScreen() {
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
}
},
() => unsubscribeTerminalRef.current(handle)
() => {
unsubscribeTerminalRef.current(handle)
signalTerminalInventoryRecovery()
}
)
if (subscribeSeqRef.current.get(handle) === seq) {
@@ -1532,7 +1545,14 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
[
client,
getTerminalRef,
markNativeChatInputLeaseReady,
scheduleDelayedAction,
showToast,
signalTerminalInventoryRecovery
]
)
const nativeChatStream = useMobileNativeChatTerminalStream({
@@ -1586,93 +1606,105 @@ export default function SessionScreen() {
)
const lastKnownTerminalCountRef = useRef(0)
const fetchTerminalsInFlightRef = useRef(false)
const terminalInventoryRequest = useMemo(
() => new MobileTerminalInventoryRequest(),
[client, hostId, worktreeId]
)
useEffect(() => {
lastKnownTerminalCountRef.current = 0
return terminalInventoryRequest.activate()
}, [terminalInventoryRequest])
const fetchTerminals = useCallback(
async (opts: { allowEmptyLoaded?: boolean } = {}) => {
(opts: MobileTerminalInventoryRefreshOptions = {}): Promise<boolean> => {
if (!client) {
return
return Promise.resolve(false)
}
if (fetchTerminalsInFlightRef.current) {
return
}
fetchTerminalsInFlightRef.current = true
const allowEmptyLoaded = opts.allowEmptyLoaded ?? true
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`,
includeVisualLayouts: false
})
if (response.ok) {
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
if (result.terminals.length === 0 && !allowEmptyLoaded) {
return
}
// Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty.
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
lastKnownTerminalCountRef.current = 0
return
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
const pruneContext = {
liveHandles,
showNativeChat: showNativeChatRef.current,
activeHandle: activeHandleRef.current
}
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!shouldPrune(handle)) {
continue
}
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) {
return terminalInventoryRequest.run(
allowEmptyLoaded,
async (allowsEmpty, isCurrent) => {
try {
const response = await client.sendRequest('terminal.list', {
worktree: `id:${worktreeId}`,
includeVisualLayouts: false
})
if (!isCurrent()) {
return false
}
seen.add(t.handle)
if (!response.ok) {
return false
}
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
if (result.terminals.length === 0 && !allowsEmpty()) {
return true
}
// Why: require two consecutive empties before trusting 0, so transient empty responses don't flash the UI empty.
if (result.terminals.length === 0 && lastKnownTerminalCountRef.current > 0) {
lastKnownTerminalCountRef.current = 0
return true
}
const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))
const pruneContext = {
liveHandles,
showNativeChat: showNativeChatRef.current,
activeHandle: activeHandleRef.current
}
// Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out.
// Sweep against the retained set, not the raw list: a chat-covered handle
// keeps its subscription across a graph reload, so erasing its live-input
// preference on the same refresh is the erasure this guard exists to stop.
pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext))
defaultTerminalHandlesToLiveInput([...liveHandles])
const shouldPrune = createTerminalPrunePredicate(pruneContext)
for (const handle of Array.from(terminalUnsubsRef.current.keys())) {
if (!shouldPrune(handle)) {
continue
}
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
const deduped = result.terminals.filter((t) => {
if (seen.has(t.handle)) {
return false
}
seen.add(t.handle)
return true
})
const mergedTerminals = mergeTerminalListWithKnownRecords(
deduped,
terminalsRef.current,
sessionTabsRef.current
)
setTerminals((prev) =>
terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals
)
terminalsRef.current = mergedTerminals
// Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces.
return true
})
const mergedTerminals = mergeTerminalListWithKnownRecords(
deduped,
terminalsRef.current,
sessionTabsRef.current
)
setTerminals((prev) =>
terminalRecordsEqual(prev, mergedTerminals) ? prev : mergedTerminals
)
terminalsRef.current = mergedTerminals
// Session tabs are the UI authority; terminal.list only refreshes per-handle metadata for existing terminal surfaces.
}
} catch {
// Failed to list terminals
} finally {
fetchTerminalsInFlightRef.current = false
}
} catch {
// Failed to list terminals
return false
}
},
opts.onPhysicalRequestStarted
)
},
[
client,
@@ -1682,6 +1714,7 @@ export default function SessionScreen() {
nativeChatStream,
pruneTerminalHandlesFromLiveInput,
subscribeToTerminal,
terminalInventoryRequest,
unsubscribeTerminal
]
)
@@ -2302,6 +2335,9 @@ export default function SessionScreen() {
() =>
closedTabTombstonesRef.current.size > 0 ||
pendingBrowserFocusPageIdRef.current !== null ||
// Why: tabs dropped a connected handle we still hold. `terminal.list` is the only
// remover, so keep the fast cadence until that sweep confirms or restores it.
hasConnectedTerminalAbsentFromSessionTabs(terminalsRef.current, sessionTabsRef.current) ||
// Why: a chat-covered handle that ran out of rearms and left `terminal.list`
// was reminted by a desktop graph reload. Only a fresh tab snapshot carries
// the replacement handle, so force one instead of holding the composer locked.
@@ -2333,7 +2369,8 @@ export default function SessionScreen() {
fetchSessionTabs,
ensureSessionTabs,
fetchPendingBrowserSessionTabs,
retryPendingTerminalRecovery
retryPendingTerminalRecovery,
requestTerminalInventoryRecovery
} = useMobileSessionTabsReconciliation<SessionTabsResult, MobileSessionTab>({
client,
connState,
@@ -2341,6 +2378,7 @@ export default function SessionScreen() {
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
terminalInventoryRecoveryScopeKey: terminalInventoryRecoveryScope,
hasRecoveryNeed: hasSessionTabsRecoveryNeed,
pendingTerminalRecoveryContextKey,
getPendingTerminalRecoveryContextKey,
@@ -2349,6 +2387,11 @@ export default function SessionScreen() {
...sessionTabsFetchReporting
})
useEffect(
() => registerTerminalInventoryRecoveryAction(requestTerminalInventoryRecovery),
[registerTerminalInventoryRecoveryAction, requestTerminalInventoryRecovery]
)
useEffect(() => {
if (connState === 'connected') {
return
+3 -142
View File
@@ -1,13 +1,5 @@
import { useState, useCallback, useRef } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
ScrollView,
ActivityIndicator,
Platform
} from 'react-native'
import { View, Text, Pressable, ScrollView, ActivityIndicator, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import {
@@ -20,7 +12,7 @@ import {
XCircle,
AlertTriangle
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import {
startDiagnosticFetchTimeout,
@@ -32,6 +24,7 @@ import {
unreachableHostDetail
} from '../src/diagnostics/host-reachability'
import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues'
import { troubleshootScreenStyles as styles } from '../src/diagnostics/troubleshoot-screen-styles'
type DiagnosticStatus = 'idle' | 'running' | 'done'
@@ -281,135 +274,3 @@ export default function TroubleshootScreen() {
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scroll: {
flex: 1
},
scrollContent: {
paddingBottom: spacing.xl
},
diagnosticButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: 10,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg
},
diagnosticButtonPressed: {
opacity: 0.7
},
diagnosticButtonDisabled: {
opacity: 0.5
},
diagnosticButtonLabel: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textPrimary
},
checkRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.md + 2
},
checkLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
checkDetail: {
flex: 1,
textAlign: 'right',
fontSize: typography.metaSize,
color: colors.textMuted
},
checkDetailFail: {
color: colors.statusRed
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.sm,
marginTop: spacing.sm,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.lg
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
rowPressed: {
backgroundColor: colors.bgRaised
},
accordionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
accordionTitle: {
flex: 1,
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
accordionBody: {
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.md,
gap: spacing.xs + 2
},
stepRow: {
flexDirection: 'row',
gap: spacing.sm
},
bullet: {
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
},
stepText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+30 -38
View File
@@ -25,43 +25,43 @@
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"buffer": "^6.0.3",
"expo": "^55.0.27",
"expo-build-properties": "^55.0.13",
"expo-camera": "^55.0.18",
"expo-clipboard": "^55.0.13",
"expo-constants": "^55.0.16",
"expo-crypto": "^55.0.14",
"expo-dev-client": "~55.0.35",
"expo-document-picker": "^55.0.13",
"expo-file-system": "55.0.19",
"expo-haptics": "^55.0.14",
"expo-image-manipulator": "^55.0.17",
"expo-image-picker": "^55.0.20",
"expo": "^55.0.30",
"expo-build-properties": "^55.0.18",
"expo-camera": "^55.0.23",
"expo-clipboard": "^55.0.17",
"expo-constants": "^55.0.17",
"expo-crypto": "^55.0.19",
"expo-dev-client": "~55.0.39",
"expo-document-picker": "^55.0.17",
"expo-file-system": "55.0.26",
"expo-haptics": "^55.0.18",
"expo-image-manipulator": "^55.0.21",
"expo-image-picker": "^55.0.24",
"expo-keep-awake": "~55.0.8",
"expo-linking": "^55.0.15",
"expo-linking": "^55.0.17",
"expo-modules-core": "~55.0.25",
"expo-network": "~55.0.14",
"expo-notifications": "^55.0.22",
"expo-router": "^55.0.14",
"expo-secure-store": "^55.0.13",
"expo-splash-screen": "^55.0.20",
"expo-network": "~55.0.18",
"expo-notifications": "^55.0.27",
"expo-router": "^55.0.18",
"expo-secure-store": "^55.0.18",
"expo-splash-screen": "^55.0.25",
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.16.1",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
"mermaid": "11.17.2",
"react": "^19.2.8",
"react-dom": "19.2.8",
"react-native": "^0.83.10",
"react-native-gesture-handler": "^2.31.2",
"react-native-reanimated": "^4.3.0",
"react-native-reanimated": "4.3.4",
"react-native-safe-area-context": "^5.7.0",
"react-native-screens": "^4.24.0",
"react-native-svg": "^15.15.4",
"react-native-web": "^0.21.2",
"react-native-webview": "^13.16.1",
"react-native-webview": "13.16.2",
"react-native-worklets": "^0.8.3",
"tweetnacl": "^1.0.3",
"ws": "^8.21.0",
"ws": "^8.21.3",
"zod": "~4.4.3",
"zustand": "^5.0.13"
},
@@ -73,21 +73,13 @@
"acorn": "8.15.0",
"esbuild": "0.25.4",
"expo-module-scripts": "^55.0.2",
"happy-dom": "^20.9.0",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
"react-test-renderer": "19.2.6",
"happy-dom": "^20.11.8",
"oxfmt": "^0.65.0",
"oxlint": "^1.80.0",
"react-test-renderer": "19.2.8",
"tsx": "^4.22.4",
"typescript": "6.0.3",
"vite": "^8.0.16",
"vitest": "^4.1.9"
},
"pnpm": {
"overrides": {
"xcode>uuid": "11.1.1"
},
"patchedDependencies": {
"react-native@0.83.9": "patches/react-native@0.83.9.patch"
}
"vitest": "^4.1.11"
}
}
@@ -0,0 +1,13 @@
diff --git a/index.d.ts b/index.d.ts
index 10b9c145b920a6a2ba1fb09bd72150328fafe211..626a67a3d6aca587ee9e74a52b799a2c50b4c52b 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -5,7 +5,7 @@ export { FileDownload, WebViewMessageEvent, WebViewNavigation } from './lib/WebV
export type WebViewProps = IOSWebViewProps & AndroidWebViewProps & WindowsWebViewProps;
-declare class WebView<P = undefined> extends Component<WebViewProps & P> {
+declare class WebView<P = {}> extends Component<WebViewProps & P> {
/**
* Go back one page in the webview's history.
*/
+1460 -1189
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1,5 +1,11 @@
allowBuilds:
esbuild: true
minimumReleaseAge: 4320
overrides:
xcode>uuid: 11.1.1
patchedDependencies:
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
react-native@0.83.10: patches/react-native@0.83.10.patch
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,321 @@
import type { Dispatch, RefObject, SetStateAction } from 'react'
import {
ActivityIndicator,
Image,
Pressable,
Text,
TextInput,
View,
type PanResponderInstance,
type StyleProp,
type ViewStyle
} from 'react-native'
import { ArrowUp, ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { MobileBrowserKeyRow } from './MobileBrowserKeyRow'
import {
MobileBrowserPointerModifiers,
type BrowserPointerModifier
} from './MobileBrowserPointerModifiers'
import { MobileBrowserToolbarIconButton } from './MobileBrowserToolbarIconButton'
import { MobileBrowserViewModeSwitch } from './MobileBrowserViewModeSwitch'
import { buttonColor, type FrameLayer } from './mobile-browser-frame-state'
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import type {
BrowserFrameGeometry,
BrowserTouchLayout,
BrowserZoomState
} from './browser-touch-geometry'
import type { MobileBrowserViewMode } from './browser-screencast-request'
import type { MobileBrowserTab } from './MobileBrowserPane'
type MobileBrowserPaneViewProps = {
addressFocused: boolean
addressValue: string
bottomInset: number
browserLayerRef: (layer: FrameLayer) => (view: View | null) => void
browserViewMode: MobileBrowserViewMode
busy: boolean
controlsDisabled: boolean
dialog: { dialogType: string; message: string } | null
error: string | null
frameGeometry: BrowserFrameGeometry | null
frameLayerErrorHandler: (layer: FrameLayer) => () => void
frameLayerLoadHandler: (layer: FrameLayer) => () => void
frameLayerRef: (layer: FrameLayer) => (image: Image | null) => void
frameLayerStyle: (layer: FrameLayer) => StyleProp<ViewStyle>
goBack: () => void
goForward: () => void
keyboardLift: number
keyboardValue: string
layoutRef: RefObject<BrowserTouchLayout | null>
navigateToAddress: () => Promise<void>
panResponder: PanResponderInstance
pointerModifiers: BrowserPointerModifier[]
reloadPage: () => void
renderedFrameSource: { uri: string } | null
selectBrowserViewMode: (mode: MobileBrowserViewMode) => void
sendDialogCommand: (method: 'browser.dialogDismiss' | 'browser.dialogAccept') => Promise<void>
sendKeyboardText: () => Promise<void>
sendKeypress: (key: string) => Promise<void>
setAddressFocused: Dispatch<SetStateAction<boolean>>
setAddressValue: Dispatch<SetStateAction<string>>
setKeyboardValue: Dispatch<SetStateAction<string>>
setLayout: Dispatch<SetStateAction<BrowserTouchLayout | null>>
setRootViewRef: (view: View | null) => void
tab: MobileBrowserTab
togglePointerModifier: (modifier: BrowserPointerModifier) => void
zoom: BrowserZoomState
}
export function MobileBrowserPaneView(props: MobileBrowserPaneViewProps) {
const {
addressFocused,
addressValue,
bottomInset,
browserLayerRef,
browserViewMode,
busy,
controlsDisabled,
dialog,
error,
frameGeometry,
frameLayerErrorHandler,
frameLayerLoadHandler,
frameLayerRef,
frameLayerStyle,
goBack,
goForward,
keyboardLift,
keyboardValue,
layoutRef,
navigateToAddress,
panResponder,
pointerModifiers,
reloadPage,
renderedFrameSource,
selectBrowserViewMode,
sendDialogCommand,
sendKeyboardText,
sendKeypress,
setAddressFocused,
setAddressValue,
setKeyboardValue,
setLayout,
setRootViewRef,
tab,
togglePointerModifier,
zoom
} = props
return (
<View ref={setRootViewRef} style={styles.root}>
<View style={styles.toolbar}>
<MobileBrowserToolbarIconButton
disabled={controlsDisabled || !tab.canGoBack}
label="Back"
onPress={goBack}
>
<ChevronLeft size={15} color={buttonColor(!controlsDisabled && tab.canGoBack)} />
</MobileBrowserToolbarIconButton>
<MobileBrowserToolbarIconButton
disabled={controlsDisabled || !tab.canGoForward}
label="Forward"
onPress={goForward}
>
<ChevronRight size={15} color={buttonColor(!controlsDisabled && tab.canGoForward)} />
</MobileBrowserToolbarIconButton>
<MobileBrowserToolbarIconButton
disabled={controlsDisabled}
label="Reload"
onPress={reloadPage}
>
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
</MobileBrowserToolbarIconButton>
<MobileBrowserAddressField
value={addressValue}
onChangeText={setAddressValue}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
onSubmit={() => void navigateToAddress()}
focused={addressFocused}
disabled={controlsDisabled}
/>
<MobileBrowserViewModeSwitch
disabled={controlsDisabled}
value={browserViewMode}
onChange={selectBrowserViewMode}
/>
</View>
<View
style={styles.viewport}
onLayout={(event) => {
const next = {
width: event.nativeEvent.layout.width,
height: event.nativeEvent.layout.height
}
const current = layoutRef.current
if (current && current.width === next.width && current.height === next.height) {
return
}
layoutRef.current = next
setLayout(next)
}}
{...panResponder.panHandlers}
>
{renderedFrameSource ? (
<View style={styles.browserImageHost}>
{frameGeometry ? (
<View
pointerEvents="none"
style={[
styles.browserZoomOffset,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight,
transform: [{ translateX: zoom.offsetX }, { translateY: zoom.offsetY }]
}
]}
>
<View
style={[
styles.browserFrameBox,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight,
transform: [{ scale: zoom.scale }]
}
]}
>
{([0, 1] as const).map((layer) => (
<View
key={layer}
ref={browserLayerRef(layer)}
pointerEvents="none"
style={frameLayerStyle(layer)}
>
<Image
ref={frameLayerRef(layer)}
source={renderedFrameSource}
resizeMode="stretch"
fadeDuration={0}
onLoad={frameLayerLoadHandler(layer)}
onError={frameLayerErrorHandler(layer)}
style={[
styles.browserImage,
{
width: frameGeometry.renderedWidth,
height: frameGeometry.renderedHeight
}
]}
/>
</View>
))}
</View>
</View>
) : (
([0, 1] as const).map((layer) => (
<View
key={layer}
ref={browserLayerRef(layer)}
pointerEvents="none"
style={frameLayerStyle(layer)}
>
<Image
ref={frameLayerRef(layer)}
source={renderedFrameSource}
resizeMode="contain"
fadeDuration={0}
onLoad={frameLayerLoadHandler(layer)}
onError={frameLayerErrorHandler(layer)}
style={styles.browserImageFill}
/>
</View>
))
)}
</View>
) : null}
{!renderedFrameSource || busy || error ? (
<View pointerEvents="none" style={styles.overlay}>
{/* Why: a stream can report ready and then deliver no frames, so key the
indicator off actually having pixels or it clears into a blank pane. */}
{busy || (!renderedFrameSource && !error) ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
{error ? <Text style={styles.errorText}>{error}</Text> : null}
</View>
) : null}
{dialog ? (
<View style={styles.dialogOverlay}>
<View style={styles.dialogCard}>
<Text style={styles.dialogTitle}>Browser Dialog</Text>
<Text style={styles.dialogMessage}>{dialog.message}</Text>
<View style={styles.dialogActions}>
{dialog.dialogType !== 'alert' ? (
<Pressable
style={({ pressed }) => [
styles.dialogButton,
pressed && styles.dialogButtonPressed
]}
onPress={() => void sendDialogCommand('browser.dialogDismiss')}
>
<Text style={styles.dialogButtonText}>Cancel</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.dialogButton,
styles.dialogButtonPrimary,
pressed && styles.dialogButtonPressed
]}
onPress={() => void sendDialogCommand('browser.dialogAccept')}
>
<Text style={[styles.dialogButtonText, styles.dialogButtonPrimaryText]}>OK</Text>
</Pressable>
</View>
</View>
</View>
) : null}
</View>
<View
style={[
styles.keyboardDock,
{ paddingBottom: bottomInset, transform: [{ translateY: -keyboardLift }] }
]}
>
<MobileBrowserPointerModifiers
disabled={controlsDisabled}
selectedModifiers={pointerModifiers}
onToggle={togglePointerModifier}
/>
<MobileBrowserKeyRow
disabled={controlsDisabled}
onKeypress={(key) => void sendKeypress(key)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.keyboardInput}
value={keyboardValue}
onChangeText={setKeyboardValue}
placeholder="Type on page…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!controlsDisabled}
onSubmitEditing={() => void sendKeyboardText()}
/>
<Pressable
style={[styles.sendButton, (controlsDisabled || !keyboardValue) && styles.disabled]}
disabled={controlsDisabled || !keyboardValue}
onPress={() => void sendKeyboardText()}
accessibilityLabel="Send text to browser"
>
<ArrowUp size={18} color={buttonColor(!controlsDisabled && !!keyboardValue)} />
</Pressable>
</View>
</View>
</View>
)
}
@@ -0,0 +1,77 @@
import type { Dispatch, SetStateAction } from 'react'
import { displayBrowserUrl } from './browser-url'
import { shouldSurfaceBrowserError } from './mobile-browser-frame-state'
export type BrowserDialogState = { dialogType: string; message: string }
export type ScreencastEvent = {
type?: string
message?: string
error?: { message?: string }
dialogType?: string
tab?: { url?: string; title?: string; canGoBack?: boolean; canGoForward?: boolean }
}
type HandleScreencastEventArgs = {
busyRef: { current: boolean }
clearStartupTimer: () => void
event: ScreencastEvent
lastZoomResetUrlRef: { current: string }
resetBrowserZoomState: () => void
setAddressValue: Dispatch<SetStateAction<string>>
setBusy: Dispatch<SetStateAction<boolean>>
setDialog: Dispatch<SetStateAction<BrowserDialogState | null>>
setError: Dispatch<SetStateAction<string | null>>
}
export function handleBrowserScreencastEvent(args: HandleScreencastEventArgs): void {
const {
busyRef,
clearStartupTimer,
event,
lastZoomResetUrlRef,
resetBrowserZoomState,
setAddressValue,
setBusy,
setDialog,
setError
} = args
if (event.type === 'ready') {
clearStartupTimer()
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
if (typeof event.tab?.url === 'string') {
setAddressValue(displayBrowserUrl(event.tab.url))
if (event.tab.url !== lastZoomResetUrlRef.current) {
lastZoomResetUrlRef.current = event.tab.url
resetBrowserZoomState()
}
}
} else if (event.type === 'end') {
clearStartupTimer()
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
} else if (event.type === 'dialog') {
setDialog({
dialogType: event.dialogType ?? 'alert',
message: event.message ?? 'Browser dialog'
})
} else if (event.type === 'dialogClosed') {
setDialog(null)
} else if (event.type === 'error') {
clearStartupTimer()
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
const message = event.message ?? event.error?.message ?? 'Browser stream failed.'
if (shouldSurfaceBrowserError(message)) {
setError(message)
}
}
}
@@ -0,0 +1,248 @@
import { useCallback, useRef, type Dispatch, type SetStateAction } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol'
import { assertRpcOk } from './mobile-browser-frame-state'
import {
computeBrowserFrameGeometry,
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import type { BrowserPointerModifier } from './MobileBrowserPointerModifiers'
const TOUCH_CLICK_RADIUS_DIP = 14
type BrowserPageParams = { worktree: string; page: string }
type PendingWheelCommand = {
base: BrowserPageParams
point: BrowserPoint
gestureId: number
dx: number
dy: number
}
type SendBrowserRequest = (
method: string,
params?: Record<string, unknown>,
options?: { showBusy?: boolean; suppressError?: boolean; timeoutMs?: number }
) => Promise<unknown | null>
type MobileBrowserCommandArgs = {
client: RpcClient | null
frameMetadataRef: { current: BrowserScreencastFrameMetadata | null }
keyboardValue: string
layoutRef: { current: BrowserTouchLayout | null }
onToast: (message: string, durationMs?: number) => void
pageParams: () => BrowserPageParams | null
pointerModifiers: BrowserPointerModifier[]
sendBrowserRequest: SendBrowserRequest
setDialog: Dispatch<SetStateAction<{ dialogType: string; message: string } | null>>
setError: Dispatch<SetStateAction<string | null>>
setKeyboardValue: Dispatch<SetStateAction<string>>
setPointerModifiers: Dispatch<SetStateAction<BrowserPointerModifier[]>>
zoomRef: { current: BrowserZoomState }
}
export function useMobileBrowserCommands(args: MobileBrowserCommandArgs) {
const {
client,
frameMetadataRef,
keyboardValue,
layoutRef,
onToast,
pageParams,
pointerModifiers,
sendBrowserRequest,
setDialog,
setError,
setKeyboardValue,
setPointerModifiers,
zoomRef
} = args
const pendingWheelCommandRef = useRef<PendingWheelCommand | null>(null)
const wheelCommandInFlightRef = useRef(false)
const flushPendingWheelCommand = useCallback(() => {
if (wheelCommandInFlightRef.current) {
return
}
const pending = pendingWheelCommandRef.current
if (!pending || !client) {
return
}
pendingWheelCommandRef.current = null
wheelCommandInFlightRef.current = true
void (async () => {
try {
assertRpcOk(
await client.sendRequest('browser.mouseMove', {
...pending.base,
x: pending.point.x,
y: pending.point.y
}),
'Browser pointer move failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseWheel', {
...pending.base,
dx: pending.dx,
dy: pending.dy
}),
'Browser scroll failed'
)
setError(null)
} catch {
// Scroll bursts commonly race page reload/navigation. Avoid replacing
// the live browser with transient command errors like selector_not_found.
} finally {
wheelCommandInFlightRef.current = false
flushPendingWheelCommand()
}
})()
}, [client])
const sendPointerClick = useCallback(
async (point: BrowserPoint, button: 'left' | 'right') => {
const base = pageParams()
if (!client || !base) {
return
}
const clickResult = await sendBrowserRequest(
'browser.mouseClick',
{
x: point.x,
y: point.y,
button,
modifiers: pointerModifiers,
...(button === 'left'
? {
radius: computeBrowserTouchClickRadiusCss(
layoutRef.current,
frameMetadataRef.current,
zoomRef.current,
TOUCH_CLICK_RADIUS_DIP
)
}
: {})
},
{ suppressError: true, timeoutMs: 5_000 }
)
if (clickResult !== null || pointerModifiers.length > 0) {
return
}
try {
assertRpcOk(
await client.sendRequest('browser.mouseMove', { ...base, x: point.x, y: point.y }),
'Browser pointer move failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseDown', { ...base, button }),
'Browser pointer down failed'
)
assertRpcOk(
await client.sendRequest('browser.mouseUp', { ...base, button }),
'Browser pointer up failed'
)
setError(null)
} catch {
// Pointer commands can race page navigation. Keep the stream visible;
// actionable failures still surface through navigation/stream errors.
}
},
[client, pageParams, pointerModifiers, sendBrowserRequest]
)
const togglePointerModifier = useCallback((modifier: BrowserPointerModifier) => {
setPointerModifiers((current) =>
current.includes(modifier)
? current.filter((candidate) => candidate !== modifier)
: [...current, modifier]
)
}, [])
const sendWheel = useCallback(
(point: BrowserPoint, screenDx: number, screenDy: number, gestureId: number) => {
const base = pageParams()
if (!client || !base) {
return
}
const currentLayout = layoutRef.current
const geometry = computeBrowserFrameGeometry(currentLayout, frameMetadataRef.current)
const localZoom = zoomRef.current.scale
const scale = (geometry?.scale ?? 1) * localZoom
const cssDx = screenDx / scale
const cssDy = screenDy / scale
const delta = { dx: Math.round(-cssDx), dy: Math.round(-cssDy) }
if (Math.abs(delta.dx) < 1 && Math.abs(delta.dy) < 1) {
return
}
const pending = pendingWheelCommandRef.current
pendingWheelCommandRef.current =
pending && pending.base.page === base.page && pending.gestureId === gestureId
? {
base,
point,
gestureId,
dx: pending.dx + delta.dx,
dy: pending.dy + delta.dy
}
: { base, point, gestureId, ...delta }
flushPendingWheelCommand()
},
[client, flushPendingWheelCommand, pageParams]
)
const mapTouchPoint = useCallback((locationX: number, locationY: number): BrowserPoint | null => {
return mapScreenToBrowserPoint(
locationX,
locationY,
layoutRef.current,
frameMetadataRef.current,
zoomRef.current
)
}, [])
const sendKeyboardText = useCallback(async () => {
const text = keyboardValue
if (!text) {
return
}
setKeyboardValue('')
const result = await sendBrowserRequest(
'browser.keyboardInsertText',
{ text },
{ suppressError: true }
)
if (result !== null) {
onToast('Sent')
} else {
setKeyboardValue(text)
}
}, [keyboardValue, onToast, sendBrowserRequest])
const sendKeypress = useCallback(
async (key: string) => {
await sendBrowserRequest('browser.keypress', { key }, { suppressError: true })
},
[sendBrowserRequest]
)
const sendDialogCommand = useCallback(
async (method: 'browser.dialogAccept' | 'browser.dialogDismiss') => {
setDialog(null)
await sendBrowserRequest(method, {}, { suppressError: true, timeoutMs: 5_000 })
},
[sendBrowserRequest]
)
return {
mapTouchPoint,
sendDialogCommand,
sendKeyboardText,
sendKeypress,
sendPointerClick,
sendWheel,
togglePointerModifier
}
}
@@ -0,0 +1,122 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import type { Image } from 'react-native'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS } from './browser-screencast-request'
import {
browserFrameMetadataEqual,
cacheBrowserFrame,
createBrowserFrameDataUri,
updateBrowserImageSource,
type FrameLayer
} from './mobile-browser-frame-state'
type PendingFrame = { frame: BrowserScreencastFrame; cacheKey: string }
type BrowserFrameApplyArgs = {
browserImageRefs: { current: [Image | null, Image | null] }
busyRef: { current: boolean }
frameMetadataRef: { current: BrowserScreencastFrameMetadata | null }
frameMountedRef: { current: boolean }
frameThrottleTimerRef: { current: ReturnType<typeof setTimeout> | null }
frameUriRef: { current: string | null }
lastAppliedFrameAtRef: { current: number }
pendingFrameLayerRef: { current: FrameLayer | null }
pendingThrottledFrameRef: { current: PendingFrame | null }
setBusy: Dispatch<SetStateAction<boolean>>
setFrameMetadata: Dispatch<SetStateAction<BrowserScreencastFrameMetadata | null>>
setFrameUri: Dispatch<SetStateAction<string | null>>
visibleFrameLayerRef: { current: FrameLayer }
}
export function useMobileBrowserFrameApply(args: BrowserFrameApplyArgs) {
const {
browserImageRefs,
busyRef,
frameMetadataRef,
frameMountedRef,
frameThrottleTimerRef,
frameUriRef,
lastAppliedFrameAtRef,
pendingFrameLayerRef,
pendingThrottledFrameRef,
setBusy,
setFrameMetadata,
setFrameUri,
visibleFrameLayerRef
} = args
const applyFrame = useCallback((frame: BrowserScreencastFrame, frameCacheKey: string): void => {
if (!browserFrameMetadataEqual(frameMetadataRef.current, frame.metadata)) {
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
const nextFrameUri = createBrowserFrameDataUri(frame)
cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
setFrameUri(nextFrameUri)
updateBrowserImageSource(browserImageRefs.current[0], nextFrameUri)
} else if (pendingFrameLayerRef.current === null) {
// Why: decode the next frame offscreen and keep the previous layer visible
// until onLoad; replacing the visible Image directly flashes black.
const nextLayer: FrameLayer = visibleFrameLayerRef.current === 0 ? 1 : 0
frameUriRef.current = nextFrameUri
pendingFrameLayerRef.current = nextLayer
updateBrowserImageSource(browserImageRefs.current[nextLayer], nextFrameUri)
} else {
// Why: popovers/menus can settle in one final frame while the previous
// offscreen frame is still decoding. Keep the hidden layer pointed at
// the newest frame instead of dropping the final static state.
frameUriRef.current = nextFrameUri
updateBrowserImageSource(browserImageRefs.current[pendingFrameLayerRef.current], nextFrameUri)
}
if (busyRef.current) {
busyRef.current = false
setBusy(false)
}
}, [])
const clearFrameThrottle = useCallback(() => {
pendingThrottledFrameRef.current = null
if (frameThrottleTimerRef.current) {
clearTimeout(frameThrottleTimerRef.current)
frameThrottleTimerRef.current = null
}
}, [])
const applyFrameThrottled = useCallback(
(frame: BrowserScreencastFrame, frameCacheKey: string): void => {
const now = Date.now()
const elapsed = now - lastAppliedFrameAtRef.current
if (lastAppliedFrameAtRef.current === 0 || elapsed >= MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS) {
clearFrameThrottle()
lastAppliedFrameAtRef.current = now
applyFrame(frame, frameCacheKey)
return
}
// Why: static UI changes can be the last frame Chromium emits. Coalesce
// throttled frames so the final visible state is applied after the delay.
pendingThrottledFrameRef.current = { frame, cacheKey: frameCacheKey }
if (frameThrottleTimerRef.current) {
return
}
frameThrottleTimerRef.current = setTimeout(
() => {
frameThrottleTimerRef.current = null
const pending = pendingThrottledFrameRef.current
pendingThrottledFrameRef.current = null
if (!pending) {
return
}
lastAppliedFrameAtRef.current = Date.now()
applyFrame(pending.frame, pending.cacheKey)
},
Math.max(0, MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS - elapsed)
)
},
[applyFrame, clearFrameThrottle]
)
return { applyFrameThrottled, clearFrameThrottle }
}
@@ -0,0 +1,290 @@
import { useCallback, useMemo, useRef, type Dispatch, type SetStateAction } from 'react'
import {
PanResponder,
type GestureResponderEvent,
type PanResponderGestureState
} from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import {
createPinchGesture,
MAX_ZOOM,
MIN_ZOOM,
updatePinchZoom,
type PinchGesture
} from './mobile-browser-frame-state'
import {
clampBrowserZoomState,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import type { BrowserPointerModifier } from './MobileBrowserPointerModifiers'
import type { BrowserScreencastFrameMetadata } from '../transport/browser-screencast-protocol'
import { useMobileBrowserCommands } from './use-mobile-browser-commands'
const TAP_SLOP = 16
const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
type BrowserPageParams = { worktree: string; page: string }
type PanGesture = { x: number; y: number; offsetX: number; offsetY: number }
type SendBrowserRequest = (
method: string,
params?: Record<string, unknown>,
options?: { showBusy?: boolean; suppressError?: boolean; timeoutMs?: number }
) => Promise<unknown | null>
type MobileBrowserInteractionArgs = {
clearLongPressTimer: () => void
client: RpcClient | null
dialogRef: { current: { dialogType: string; message: string } | null }
frameGeometry: BrowserFrameGeometry | null
frameMetadataRef: { current: BrowserScreencastFrameMetadata | null }
keyboardValue: string
layoutRef: { current: BrowserTouchLayout | null }
longPressTimerRef: { current: ReturnType<typeof setTimeout> | null }
onToast: (message: string, durationMs?: number) => void
pageParams: () => BrowserPageParams | null
panRef: { current: PanGesture | null }
pinchRef: { current: PinchGesture | null }
pointerModifiers: BrowserPointerModifier[]
sendBrowserRequest: SendBrowserRequest
setDialog: Dispatch<SetStateAction<{ dialogType: string; message: string } | null>>
setError: Dispatch<SetStateAction<string | null>>
setKeyboardValue: Dispatch<SetStateAction<string>>
scrollingRef: { current: boolean }
startPointRef: { current: { x: number; y: number; t: number } | null }
setPointerModifiers: Dispatch<SetStateAction<BrowserPointerModifier[]>>
setZoom: Dispatch<SetStateAction<BrowserZoomState>>
zoomRef: { current: BrowserZoomState }
}
export function useMobileBrowserInteractions(args: MobileBrowserInteractionArgs) {
const {
clearLongPressTimer,
client,
dialogRef,
frameGeometry,
frameMetadataRef,
keyboardValue,
layoutRef,
longPressTimerRef,
onToast,
pageParams,
panRef,
pinchRef,
pointerModifiers,
scrollingRef,
sendBrowserRequest,
setDialog,
setError,
setKeyboardValue,
setPointerModifiers,
setZoom,
startPointRef,
zoomRef
} = args
const rightClickSentRef = useRef(false)
const lastWheelRef = useRef({ dx: 0, dy: 0, at: 0 })
const wheelGestureIdRef = useRef(0)
const {
mapTouchPoint,
sendDialogCommand,
sendKeyboardText,
sendKeypress,
sendPointerClick,
sendWheel,
togglePointerModifier
} = useMobileBrowserCommands({
client,
frameMetadataRef,
keyboardValue,
layoutRef,
onToast,
pageParams,
pointerModifiers,
sendBrowserRequest,
setDialog,
setError,
setKeyboardValue,
setPointerModifiers,
zoomRef
})
const handleResponderGrant = useCallback(
(event: GestureResponderEvent) => {
const pinch = createPinchGesture(event, frameGeometry, zoomRef.current)
if (pinch) {
clearLongPressTimer()
pinchRef.current = pinch
panRef.current = null
startPointRef.current = null
return
}
const startPoint = readLocalTouchPoint(event.nativeEvent)
if (!startPoint) {
return
}
startPointRef.current = { x: startPoint.x, y: startPoint.y, t: Date.now() }
rightClickSentRef.current = false
scrollingRef.current = false
wheelGestureIdRef.current += 1
lastWheelRef.current = { dx: 0, dy: 0, at: 0 }
panRef.current =
zoomRef.current.scale > MIN_ZOOM
? {
x: startPoint.x,
y: startPoint.y,
offsetX: zoomRef.current.offsetX,
offsetY: zoomRef.current.offsetY
}
: null
clearLongPressTimer()
longPressTimerRef.current = setTimeout(() => {
const start = startPointRef.current
if (!start) {
return
}
const point = mapTouchPoint(start.x, start.y)
if (!point) {
return
}
rightClickSentRef.current = true
void sendPointerClick(point, 'right')
onToast('Right click')
}, LONG_PRESS_MS)
},
[clearLongPressTimer, frameGeometry, mapTouchPoint, onToast, sendPointerClick]
)
const handleResponderMove = useCallback(
(event: GestureResponderEvent, gesture: PanResponderGestureState) => {
const startedPinch = pinchRef.current
? null
: createPinchGesture(event, frameGeometry, zoomRef.current)
if (startedPinch) {
clearLongPressTimer()
pinchRef.current = startedPinch
panRef.current = null
startPointRef.current = null
}
const activePinch = pinchRef.current
const nextPinch = activePinch ? updatePinchZoom(event, frameGeometry, activePinch) : null
if (nextPinch) {
clearLongPressTimer()
zoomRef.current = nextPinch
setZoom(nextPinch)
return
}
if (activePinch) {
pinchRef.current = null
}
const moved = Math.hypot(gesture.dx, gesture.dy)
if (moved > TAP_SLOP) {
clearLongPressTimer()
}
const activePan = panRef.current
if (activePan && frameGeometry) {
const currentPoint = readLocalTouchPoint(event.nativeEvent)
if (!currentPoint) {
return
}
if (!scrollingRef.current && moved <= TAP_SLOP) {
return
}
scrollingRef.current = true
startPointRef.current = null
const nextZoom = clampBrowserZoomState(
{
scale: zoomRef.current.scale,
offsetX: activePan.offsetX + currentPoint.x - activePan.x,
offsetY: activePan.offsetY + currentPoint.y - activePan.y
},
frameGeometry,
MIN_ZOOM,
MAX_ZOOM
)
zoomRef.current = nextZoom
setZoom(nextZoom)
return
}
if (!scrollingRef.current) {
if (moved <= SCROLL_START_SLOP) {
return
}
scrollingRef.current = true
startPointRef.current = null
}
const now = Date.now()
if (now - lastWheelRef.current.at < WHEEL_INTERVAL_MS) {
return
}
const deltaX = gesture.dx - lastWheelRef.current.dx
const deltaY = gesture.dy - lastWheelRef.current.dy
if (Math.abs(deltaX) + Math.abs(deltaY) < 8) {
return
}
const currentPoint = readLocalTouchPoint(event.nativeEvent)
if (!currentPoint) {
return
}
const point = mapTouchPoint(currentPoint.x, currentPoint.y)
if (!point) {
return
}
lastWheelRef.current = { dx: gesture.dx, dy: gesture.dy, at: now }
sendWheel(point, deltaX, deltaY, wheelGestureIdRef.current)
},
[clearLongPressTimer, frameGeometry, mapTouchPoint, sendWheel]
)
const handleResponderRelease = useCallback(
(event: GestureResponderEvent, gesture: PanResponderGestureState) => {
clearLongPressTimer()
pinchRef.current = null
panRef.current = null
const start = startPointRef.current
startPointRef.current = null
const wasScrolling = scrollingRef.current
scrollingRef.current = false
if (!start || rightClickSentRef.current || wasScrolling) {
return
}
const moved = Math.hypot(gesture.dx, gesture.dy)
if (moved <= TAP_SLOP && Date.now() - start.t < LONG_PRESS_MS) {
// Why: native browser taps resolve at touch-up. Using touch-down makes
// tiny finger drift feel like the click lands left/up of the finger.
const release = readLocalTouchPoint(event.nativeEvent) ?? start
const point = mapTouchPoint(release.x, release.y)
if (point) {
void sendPointerClick(point, 'left')
}
}
},
[clearLongPressTimer, mapTouchPoint, sendPointerClick]
)
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => dialogRef.current === null,
onMoveShouldSetPanResponder: () => dialogRef.current === null,
onPanResponderGrant: handleResponderGrant,
onPanResponderMove: handleResponderMove,
onPanResponderRelease: handleResponderRelease,
onPanResponderTerminate: () => {
clearLongPressTimer()
pinchRef.current = null
panRef.current = null
scrollingRef.current = false
startPointRef.current = null
},
onPanResponderTerminationRequest: () => true
}),
[clearLongPressTimer, handleResponderGrant, handleResponderMove, handleResponderRelease]
)
return { panResponder, sendDialogCommand, sendKeyboardText, sendKeypress, togglePointerModifier }
}
@@ -0,0 +1,141 @@
import { useCallback } from 'react'
import type { Image, View } from 'react-native'
import {
updateBrowserImageSource,
updateBrowserLayerVisibility,
type FrameLayer
} from './mobile-browser-frame-state'
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
type BrowserLayerHandlersArgs = {
browserImageRefs: { current: [Image | null, Image | null] }
browserLayerRefs: { current: [View | null, View | null] }
frameUriRef: { current: string | null }
pendingFrameLayerRef: { current: FrameLayer | null }
visibleFrameLayerRef: { current: FrameLayer }
}
export function useMobileBrowserPaneLayers(args: BrowserLayerHandlersArgs) {
const {
browserImageRefs,
browserLayerRefs,
frameUriRef,
pendingFrameLayerRef,
visibleFrameLayerRef
} = args
const setBrowserImageRef = useCallback(
(layer: FrameLayer, image: Image | null) => {
browserImageRefs.current[layer] = image
const currentFrameUri = frameUriRef.current
if (image && currentFrameUri) {
updateBrowserImageSource(image, currentFrameUri)
}
},
[browserImageRefs, frameUriRef]
)
const setBrowserLayerRef = useCallback(
(layer: FrameLayer, view: View | null) => {
browserLayerRefs.current[layer] = view
updateBrowserLayerVisibility(browserLayerRefs.current, visibleFrameLayerRef.current)
},
[browserLayerRefs, visibleFrameLayerRef]
)
const setBrowserLayer0Ref = useCallback(
(view: View | null) => setBrowserLayerRef(0, view),
[setBrowserLayerRef]
)
const setBrowserLayer1Ref = useCallback(
(view: View | null) => setBrowserLayerRef(1, view),
[setBrowserLayerRef]
)
const setBrowserImageLayer0Ref = useCallback(
(image: Image | null) => setBrowserImageRef(0, image),
[setBrowserImageRef]
)
const setBrowserImageLayer1Ref = useCallback(
(image: Image | null) => setBrowserImageRef(1, image),
[setBrowserImageRef]
)
const handleBrowserImageLoad = useCallback(
(layer: FrameLayer) => {
if (pendingFrameLayerRef.current !== layer) {
return
}
pendingFrameLayerRef.current = null
visibleFrameLayerRef.current = layer
updateBrowserLayerVisibility(browserLayerRefs.current, layer)
},
[browserLayerRefs, pendingFrameLayerRef, visibleFrameLayerRef]
)
const handleBrowserImageLayer0Load = useCallback(
() => handleBrowserImageLoad(0),
[handleBrowserImageLoad]
)
const handleBrowserImageLayer1Load = useCallback(
() => handleBrowserImageLoad(1),
[handleBrowserImageLoad]
)
const handleBrowserImageError = useCallback(
(layer: FrameLayer) => {
if (pendingFrameLayerRef.current === layer) {
pendingFrameLayerRef.current = null
}
},
[pendingFrameLayerRef]
)
const handleBrowserImageLayer0Error = useCallback(
() => handleBrowserImageError(0),
[handleBrowserImageError]
)
const handleBrowserImageLayer1Error = useCallback(
() => handleBrowserImageError(1),
[handleBrowserImageError]
)
const frameLayerStyle = useCallback(
(layer: FrameLayer) => {
return [
styles.browserImageLayer,
visibleFrameLayerRef.current !== layer && styles.browserImageLayerHidden
]
},
[visibleFrameLayerRef]
)
const browserLayerRef = useCallback(
(layer: FrameLayer) => (layer === 0 ? setBrowserLayer0Ref : setBrowserLayer1Ref),
[setBrowserLayer0Ref, setBrowserLayer1Ref]
)
const frameLayerRef = useCallback(
(layer: FrameLayer) => (layer === 0 ? setBrowserImageLayer0Ref : setBrowserImageLayer1Ref),
[setBrowserImageLayer0Ref, setBrowserImageLayer1Ref]
)
const frameLayerLoadHandler = useCallback(
(layer: FrameLayer) =>
layer === 0 ? handleBrowserImageLayer0Load : handleBrowserImageLayer1Load,
[handleBrowserImageLayer0Load, handleBrowserImageLayer1Load]
)
const frameLayerErrorHandler = useCallback(
(layer: FrameLayer) =>
layer === 0 ? handleBrowserImageLayer0Error : handleBrowserImageLayer1Error,
[handleBrowserImageLayer0Error, handleBrowserImageLayer1Error]
)
return {
browserLayerRef,
frameLayerErrorHandler,
frameLayerLoadHandler,
frameLayerRef,
frameLayerStyle
}
}
@@ -0,0 +1,67 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import { browserErrorMessage, shouldSurfaceBrowserError } from './mobile-browser-frame-state'
type BrowserRequestArgs = {
busyRef: { current: boolean }
client: RpcClient | null
pageId: string | null
setBusy: Dispatch<SetStateAction<boolean>>
setError: Dispatch<SetStateAction<string | null>>
worktreeId: string
}
export function useMobileBrowserRequest(args: BrowserRequestArgs) {
const { busyRef, client, pageId, setBusy, setError, worktreeId } = args
const pageParams = useCallback(() => {
if (!pageId) {
return null
}
return {
worktree: `id:${worktreeId}`,
page: pageId
}
}, [pageId, worktreeId])
const sendBrowserRequest = useCallback(
async (
method: string,
params: Record<string, unknown> = {},
opts: { showBusy?: boolean; suppressError?: boolean; timeoutMs?: number } = {}
): Promise<unknown | null> => {
const base = pageParams()
if (!client || !base) {
return null
}
if (opts.showBusy) {
busyRef.current = true
setBusy(true)
}
try {
const response = await client.sendRequest(
method,
{ ...base, ...params },
{ timeoutMs: opts.timeoutMs ?? 15_000 }
)
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
setError(null)
return (response as RpcSuccess).result
} catch (err) {
const message = browserErrorMessage(err, 'Browser command failed')
if (!opts.suppressError && shouldSurfaceBrowserError(message)) {
setError(message)
}
return null
} finally {
if (opts.showBusy) {
busyRef.current = false
setBusy(false)
}
}
},
[client, pageParams]
)
return { pageParams, sendBrowserRequest }
}
@@ -0,0 +1,283 @@
import { useEffect, useMemo, type Dispatch, type SetStateAction } from 'react'
import { PixelRatio, type Image, type View } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import {
buildMobileBrowserScreencastRequest,
type MobileBrowserViewMode
} from './browser-screencast-request'
import {
MAX_ZOOM,
MIN_ZOOM,
getCachedBrowserFrame,
updateBrowserLayerVisibility,
type FrameLayer
} from './mobile-browser-frame-state'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import type { MobileBrowserTab } from './MobileBrowserPane'
import {
handleBrowserScreencastEvent,
type BrowserDialogState,
type ScreencastEvent
} from './mobile-browser-stream-events'
import { useMobileBrowserFrameApply } from './use-mobile-browser-frame-apply'
import { useMobileBrowserRequest } from './use-mobile-browser-request'
type PendingFrame = { frame: BrowserScreencastFrame; cacheKey: string }
type MobileBrowserStreamArgs = {
appActive: boolean
browserImageRefs: { current: [Image | null, Image | null] }
browserLayerRefs: { current: [View | null, View | null] }
browserViewMode: MobileBrowserViewMode
busyRef: { current: boolean }
cacheKey: string | null
client: RpcClient | null
frameMetadata: BrowserScreencastFrameMetadata | null
frameMetadataRef: { current: BrowserScreencastFrameMetadata | null }
frameMountedRef: { current: boolean }
frameThrottleTimerRef: { current: ReturnType<typeof setTimeout> | null }
frameUriRef: { current: string | null }
lastAppliedFrameAtRef: { current: number }
lastStreamCacheKeyRef: { current: string | null }
lastZoomResetUrlRef: { current: string }
layout: BrowserTouchLayout | null
pendingFrameLayerRef: { current: FrameLayer | null }
pendingThrottledFrameRef: { current: PendingFrame | null }
resetBrowserZoomState: () => void
screencastSupported: boolean | null
setAddressValue: Dispatch<SetStateAction<string>>
setBusy: Dispatch<SetStateAction<boolean>>
setDialog: Dispatch<SetStateAction<BrowserDialogState | null>>
setError: Dispatch<SetStateAction<string | null>>
setFrameMetadata: Dispatch<SetStateAction<BrowserScreencastFrameMetadata | null>>
setFrameUri: Dispatch<SetStateAction<string | null>>
setZoom: Dispatch<SetStateAction<BrowserZoomState>>
streamGenerationRef: { current: number }
tab: MobileBrowserTab
visibleFrameLayerRef: { current: FrameLayer }
worktreeId: string
zoomRef: { current: BrowserZoomState }
}
export function useMobileBrowserStream(args: MobileBrowserStreamArgs) {
const {
appActive,
browserImageRefs,
browserLayerRefs,
browserViewMode,
busyRef,
cacheKey,
client,
frameMetadata,
frameMetadataRef,
frameMountedRef,
frameThrottleTimerRef,
frameUriRef,
lastAppliedFrameAtRef,
lastStreamCacheKeyRef,
lastZoomResetUrlRef,
layout,
pendingFrameLayerRef,
pendingThrottledFrameRef,
resetBrowserZoomState,
screencastSupported,
setAddressValue,
setBusy,
setDialog,
setError,
setFrameMetadata,
setFrameUri,
setZoom,
streamGenerationRef,
tab,
visibleFrameLayerRef,
worktreeId,
zoomRef
} = args
const { pageParams, sendBrowserRequest } = useMobileBrowserRequest({
busyRef,
client,
pageId: tab.browserPageId,
setBusy,
setError,
worktreeId
})
const { applyFrameThrottled, clearFrameThrottle } = useMobileBrowserFrameApply({
browserImageRefs,
busyRef,
frameMetadataRef,
frameMountedRef,
frameThrottleTimerRef,
frameUriRef,
lastAppliedFrameAtRef,
pendingFrameLayerRef,
pendingThrottledFrameRef,
setBusy,
setFrameMetadata,
setFrameUri,
visibleFrameLayerRef
})
const streamRequest = useMemo(
() => buildMobileBrowserScreencastRequest(layout, PixelRatio.get(), browserViewMode),
[browserViewMode, layout]
)
const frameGeometry = useMemo(
() => computeBrowserFrameGeometry(layout, frameMetadata),
[frameMetadata, layout]
)
useEffect(() => {
if (!frameGeometry) {
return
}
setZoom((current) => {
const next = clampBrowserZoomState(current, frameGeometry, MIN_ZOOM, MAX_ZOOM)
if (
next.scale === current.scale &&
next.offsetX === current.offsetX &&
next.offsetY === current.offsetY
) {
return current
}
// Why: rotation/layout changes can shrink the legal pan range while the
// current zoom state still points at the previous viewport geometry.
zoomRef.current = next
return next
})
}, [frameGeometry])
useEffect(() => {
streamGenerationRef.current += 1
const generation = streamGenerationRef.current
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
lastStreamCacheKeyRef.current = cacheKey
if (!sameStream || !frameUriRef.current) {
const cachedFrame = getCachedBrowserFrame(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
frameMetadataRef.current = cachedFrame.metadata
setFrameUri(cachedFrame.uri)
setFrameMetadata(cachedFrame.metadata)
} else {
frameUriRef.current = null
frameMountedRef.current = false
setFrameUri(null)
setFrameMetadata(null)
frameMetadataRef.current = null
}
} else {
frameMountedRef.current = true
}
pendingFrameLayerRef.current = null
if (!sameStream || !frameUriRef.current) {
visibleFrameLayerRef.current = 0
}
updateBrowserLayerVisibility(browserLayerRefs.current, visibleFrameLayerRef.current)
lastAppliedFrameAtRef.current = 0
clearFrameThrottle()
busyRef.current = false
setDialog(null)
setError(null)
if (
!client ||
screencastSupported !== true ||
!tab.browserPageId ||
!appActive ||
!streamRequest
) {
busyRef.current = false
setBusy(false)
if (screencastSupported === false) {
setError('Update desktop Orca to stream browser tabs on mobile.')
} else if (screencastSupported === null) {
setError('Checking desktop browser streaming support.')
} else if (!tab.browserPageId) {
setError('Browser page is not available yet.')
}
return
}
busyRef.current = true
setBusy(true)
let startupTimer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
if (streamGenerationRef.current !== generation) {
return
}
busyRef.current = false
setBusy(false)
setError('Browser stream timed out.')
}, 15_000)
const clearStartupTimer = (): void => {
if (startupTimer) {
clearTimeout(startupTimer)
startupTimer = null
}
}
const unsubscribe = client.subscribe(
'browser.screencast',
{
worktree: `id:${worktreeId}`,
page: tab.browserPageId,
...streamRequest
},
(payload) => {
if (streamGenerationRef.current !== generation) {
return
}
handleBrowserScreencastEvent({
busyRef,
clearStartupTimer,
event: payload as ScreencastEvent,
lastZoomResetUrlRef,
resetBrowserZoomState,
setAddressValue,
setBusy,
setDialog,
setError
})
},
{
onBinaryFrame: (frame) => {
if (streamGenerationRef.current !== generation) {
return
}
clearStartupTimer()
if (cacheKey) {
applyFrameThrottled(frame, cacheKey)
}
}
}
)
return () => {
clearStartupTimer()
clearFrameThrottle()
unsubscribe()
}
}, [
appActive,
applyFrameThrottled,
clearFrameThrottle,
client,
resetBrowserZoomState,
screencastSupported,
streamRequest,
cacheKey,
tab.browserPageId,
worktreeId
])
return { frameGeometry, pageParams, sendBrowserRequest }
}
@@ -0,0 +1,134 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const troubleshootScreenStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
scroll: {
flex: 1
},
scrollContent: {
paddingBottom: spacing.xl
},
diagnosticButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: 10,
paddingVertical: spacing.md,
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg
},
diagnosticButtonPressed: {
opacity: 0.7
},
diagnosticButtonDisabled: {
opacity: 0.5
},
diagnosticButtonLabel: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textPrimary
},
checkRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.md + 2
},
checkLabel: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
checkDetail: {
flex: 1,
textAlign: 'right',
fontSize: typography.metaSize,
color: colors.textMuted
},
checkDetailFail: {
color: colors.statusRed
},
sectionHeading: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.sm,
marginTop: spacing.sm,
paddingHorizontal: spacing.xs
},
section: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.lg
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
rowPressed: {
backgroundColor: colors.bgRaised
},
accordionHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
accordionTitle: {
flex: 1,
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
accordionBody: {
paddingHorizontal: spacing.md + 2,
paddingBottom: spacing.md,
gap: spacing.xs + 2
},
stepRow: {
flexDirection: 'row',
gap: spacing.sm
},
bullet: {
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
},
stepText: {
flex: 1,
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+4 -7
View File
@@ -60,13 +60,10 @@ export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): Dire
Array.from(childrenByDir, ([path, children]) => [
path,
{
entries: Array.from(
children,
([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
})
)
entries: Array.from(children, ([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
}))
}
])
)
@@ -95,7 +95,7 @@ describe('useMobileDictation source invariants', () => {
/import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/
)
expect(source).toContain(
'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])'
'const keepAwakeOwner = useMemo(() => createMobileDictationKeepAwakeOwner(), [])'
)
expect(keepAwakeSource).toContain('activateKeepAwakeAsync')
expect(keepAwakeSource).toContain('deactivateKeepAwake')
+1 -1
View File
@@ -26,7 +26,7 @@ export type { UseMobileDictationResult } from './mobile-dictation-session-state'
export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult {
const { client, enabled, onTranscript, onError } = options
const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])
const keepAwakeOwner = useMemo(() => createMobileDictationKeepAwakeOwner(), [])
const [status, setStatus] = useState<DictationStatus>('idle')
const [error, setError] = useState<string | null>(null)
const activeIdRef = useRef<string | null>(null)
@@ -0,0 +1,337 @@
import { Pressable, Text, View } from 'react-native'
import {
ChevronLeft,
Filter,
Layers,
List,
PanelLeftClose,
Plus,
Search,
SlidersHorizontal,
SquareTerminal,
UserCircle,
X
} from 'lucide-react-native'
import { StatusDot } from '../components/StatusDot'
import { classifyConnection, type ConnectionVerdict } from '../transport/connection-health'
import { colors } from '../theme/mobile-theme'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
}
export function HostScreenHeader({ controller }: { controller: HostScreenController }) {
const {
actions,
connState,
embedded,
floatingWorkspaceEnabled,
forceReconnectHost,
hostId,
lastConnectedAt,
onHideSidebar,
reconnectAttempts,
relayRecovery,
settings,
state
} = controller
return (
<View style={styles.topChrome}>
<View style={styles.statusBar}>
<Pressable
style={styles.backButton}
onPress={actions.leaveHost}
accessibilityRole="button"
accessibilityLabel="Back to hosts"
hitSlop={8}
>
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
{(() => {
const headerVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt,
...relayRecovery
})
return (
<>
<View style={styles.hostIdentity}>
<StatusDot state={connState} verdict={headerVerdict} />
<Text style={styles.hostNameText} numberOfLines={1}>
{state.hostName || 'Host'}
</Text>
</View>
{connState !== 'connected' &&
(() => {
// Why: auth-failed has its own banner, so suppress the Reconnect button for that verdict.
const verdict = headerVerdict
const isError = isErrorVerdict(verdict)
const showReconnectButton = isError && hostId && verdict.kind !== 'auth-failed'
if (!showReconnectButton) {
return null
}
return (
<Pressable
style={styles.reconnectButton}
onPress={() => void forceReconnectHost(hostId!)}
hitSlop={8}
>
<Text style={styles.reconnectButtonText}>Reconnect</Text>
</Pressable>
)
})()}
</>
)
})()}
{!embedded && floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.floatingWorkspaceHeaderButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
hitSlop={8}
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
) : null}
{embedded && onHideSidebar ? (
<Pressable
style={styles.sidebarCollapseButton}
onPress={onHideSidebar}
accessibilityRole="button"
accessibilityLabel="Hide sidebar"
hitSlop={8}
>
<PanelLeftClose size={14} color={colors.textSecondary} />
</Pressable>
) : null}
</View>
{/* Filter/sort/group toolbar */}
{embedded ? (
<View style={styles.embeddedToolbar}>
<View style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.filterChip,
styles.embeddedFilterChip,
settings.activeFilterCount > 0 && styles.filterChipActive
]}
onPress={() => state.setShowFilterModal(true)}
accessibilityRole="button"
accessibilityLabel={`Filter workspaces${settings.activeFilterCount > 0 ? `, ${settings.activeFilterCount} active` : ''}`}
>
<Filter
size={12}
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
settings.activeFilterCount > 0 && styles.filterChipTextActive
]}
numberOfLines={1}
>
Filter{settings.activeFilterCount > 0 ? ` ${settings.activeFilterCount}` : ''}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowSortPicker(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${settings.selectedSortLabel}`}
>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => state.setShowGroupPicker(true)}
accessibilityRole="button"
accessibilityLabel="Group workspaces"
>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{state.groupMode === 'none'
? 'Group'
: state.groupMode === 'workspaceStatus'
? 'Status'
: state.groupMode === 'repo'
? 'Repo'
: 'PR'}
</Text>
</Pressable>
</View>
<View style={styles.embeddedToolbarRow}>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Accounts"
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Tasks"
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
{floatingWorkspaceEnabled ? (
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openFloatingWorkspace}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="Floating Workspace"
>
<SquareTerminal
size={18}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
) : null}
<Pressable
style={[
styles.embeddedToolbarIconButton,
connState !== 'connected' && styles.toolbarIconDisabled
]}
onPress={actions.openNewWorktreeModal}
disabled={connState !== 'connected'}
accessibilityRole="button"
accessibilityLabel="New workspace"
>
<Plus
size={16}
color={connState === 'connected' ? colors.textPrimary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.embeddedToolbarIconButton}
onPress={() => state.setShowSearch((s) => !s)}
accessibilityRole="button"
accessibilityLabel={state.showSearch ? 'Close search' : 'Search workspaces'}
>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
</View>
) : (
<View style={styles.toolbar}>
<Pressable
style={[styles.filterChip, settings.activeFilterCount > 0 && styles.filterChipActive]}
onPress={() => state.setShowFilterModal(true)}
>
<Filter
size={12}
color={settings.activeFilterCount > 0 ? colors.textPrimary : colors.textSecondary}
/>
<Text
style={[
styles.filterChipText,
settings.activeFilterCount > 0 && styles.filterChipTextActive
]}
>
Filter{settings.activeFilterCount > 0 ? ` (${settings.activeFilterCount})` : ''}
</Text>
</Pressable>
<Pressable style={styles.modeButton} onPress={() => state.setShowSortPicker(true)}>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{settings.selectedSortLabel}
</Text>
</Pressable>
<Pressable style={styles.modeButton} onPress={() => state.setShowGroupPicker(true)}>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{state.groupMode === 'none'
? 'Group'
: state.groupMode === 'workspaceStatus'
? 'Status'
: state.groupMode === 'repo'
? 'Repo'
: 'PR'}
</Text>
</Pressable>
<View style={styles.toolbarSpacer} />
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/accounts`)}
disabled={connState !== 'connected'}
>
<UserCircle
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable
style={styles.searchToggle}
onPress={() => actions.navigateFromHostList(`/h/${hostId}/tasks`)}
disabled={connState !== 'connected'}
>
<List
size={16}
color={connState === 'connected' ? colors.textSecondary : colors.textMuted}
/>
</Pressable>
<Pressable style={styles.searchToggle} onPress={() => state.setShowSearch((s) => !s)}>
{state.showSearch ? (
<X size={16} color={colors.textSecondary} />
) : (
<Search size={16} color={colors.textSecondary} />
)}
</Pressable>
</View>
)}
</View>
)
}
@@ -0,0 +1,231 @@
import { Pressable, Text, View } from 'react-native'
import { Check, Moon } from 'lucide-react-native'
import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions'
import { ActionSheetContent } from '../components/ActionSheetModal'
import { BottomDrawer } from '../components/BottomDrawer'
import { ConfirmModal } from '../components/ConfirmModal'
import { NewWorktreeModalController } from '../components/NewWorktreeModalController'
import { PickerModal } from '../components/PickerModal'
import { colors } from '../theme/mobile-theme'
import { hostNewWorktreeSessionRoute } from '../host-route-action-state'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import {
WORKSPACE_GROUP_OPTIONS as GROUP_OPTIONS,
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../worktree/workspace-list-picker-options'
import { isWorktreePinned } from '../worktree/workspace-list-sections'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenOverlays({ controller }: { controller: HostScreenController }) {
const {
actions,
catalog,
client,
existingWorktreePaths,
hostCapabilities,
hostId,
settings,
showNewWorktree,
state
} = controller
const actionTarget = state.actionTarget
return (
<>
<PickerModal
visible={state.showSortPicker}
title="Sort By"
options={SORT_OPTIONS}
selected={state.sortMode}
onSelect={settings.handleSortChange}
onClose={() => state.setShowSortPicker(false)}
/>
<PickerModal
visible={state.showGroupPicker}
title="Group By"
options={GROUP_OPTIONS}
selected={state.groupMode}
onSelect={settings.handleGroupChange}
onClose={() => state.setShowGroupPicker(false)}
/>
<BottomDrawer visible={state.showFilterModal} onClose={() => state.setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
{settings.activeFilterCount > 0 && (
<Pressable onPress={settings.clearFilters}>
<Text style={styles.clearFiltersText}>Clear filters</Text>
</Pressable>
)}
</View>
<Text style={styles.filterSectionLabel}>Workspaces</Text>
<View style={styles.filterGroup}>
<Pressable style={styles.filterRow} onPress={settings.toggleHideSleeping}>
<Text style={styles.filterRowText}>Hide sleeping</Text>
{state.filters.hideSleeping && <Check size={14} color={colors.textPrimary} />}
</Pressable>
<View style={styles.filterSeparator} />
<Pressable style={styles.filterRow} onPress={settings.toggleHideDefaultBranch}>
<Text style={styles.filterRowText}>Hide default branch</Text>
{state.filters.hideDefaultBranch && <Check size={14} color={colors.textPrimary} />}
</Pressable>
</View>
{controller.sectionsResult.uniqueRepos.length > 1 && (
<>
<Text style={styles.filterSectionLabel}>Repositories</Text>
<View style={styles.filterGroup}>
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
<View key={repo.id}>
{i > 0 && <View style={styles.filterSeparator} />}
<Pressable
style={styles.filterRow}
onPress={() => settings.toggleRepoFilter(repo.id)}
>
<View style={[styles.filterRepoDot, { backgroundColor: repo.color }]} />
<Text style={styles.filterRowText} numberOfLines={1}>
{repo.name}
</Text>
{state.filters.filterRepoIds.has(repo.id) && (
<Check size={14} color={colors.textPrimary} />
)}
</Pressable>
</View>
))}
</View>
</>
)}
</BottomDrawer>
{/* Worktree long-press action sheet (inline confirm to avoid double-Modal lag) */}
<BottomDrawer
visible={actionTarget != null}
onClose={() => {
state.setConfirmDelete(null)
state.setActionTarget(null)
}}
>
{state.confirmDelete ? (
<View>
<View style={styles.confirmContent}>
<Text style={styles.confirmTitle}>Delete Worktree</Text>
<Text style={styles.confirmMessage}>
Delete "{state.confirmDelete.displayName || state.confirmDelete.repo}" (
{state.confirmDelete.branch})?
</Text>
</View>
<View style={styles.confirmButtons}>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnCancel,
pressed && styles.confirmBtnPressed
]}
onPress={() => state.setConfirmDelete(null)}
>
<Text style={styles.confirmBtnCancelText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.confirmBtn,
styles.confirmBtnDestructive,
pressed && styles.confirmBtnPressed
]}
onPress={() => {
if (state.confirmDelete) {
void actions.handleDeleteWorktree(state.confirmDelete)
}
state.setConfirmDelete(null)
state.setActionTarget(null)
}}
>
<Text style={styles.confirmBtnDestructiveText}>Delete</Text>
</Pressable>
</View>
</View>
) : (
<ActionSheetContent
title={actionTarget ? actionTarget.displayName || actionTarget.repo : undefined}
message={actionTarget?.branch}
actions={
actionTarget
? [
...buildWorktreeNavigationActions({
hostId,
worktreeId: actionTarget.worktreeId,
worktreeName: actionTarget.displayName || actionTarget.repo,
hostCapabilities,
navigate: actions.navigateFromHostList,
onDone: () => state.setActionTarget(null)
}),
{
label: 'Sleep',
icon: Moon,
onPress: () => {
if (client) {
state.setSleptIds((prev) =>
new Set(prev).add(getWorktreeRowIdentity(actionTarget))
)
void client
.sendRequest('worktree.sleep', {
worktree: `id:${actionTarget.worktreeId}`
})
.catch(() => null)
}
state.setActionTarget(null)
}
},
{
label: isWorktreePinned(actionTarget, state.pinnedIds) ? 'Unpin' : 'Pin',
onPress: () => {
actions.togglePin(actionTarget.worktreeId)
state.setActionTarget(null)
}
},
{
label: 'Delete',
destructive: true,
onPress: () => state.setConfirmDelete(actionTarget)
}
]
: []
}
/>
)}
</BottomDrawer>
{/* Host remove confirmation */}
<ConfirmModal
visible={state.confirmRemoveHost}
title="Remove Host"
message={`Remove "${state.hostName}"? You can re-pair later.`}
confirmLabel="Remove"
destructive
onConfirm={() => void actions.handleRemoveHost()}
onCancel={() => state.setConfirmRemoveHost(false)}
/>
<NewWorktreeModalController
ref={state.newWorktreeModalRef}
routeVisible={showNewWorktree}
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={state.worktrees}
onVisibleChange={(visible) => {
state.newWorktreeModalVisibleRef.current = visible
}}
onCreated={(worktreeId, worktreeName) => {
void catalog.fetchWorktrees({ allowDuringModal: true })
actions.navigateFromHostList(
hostNewWorktreeSessionRoute(hostId, worktreeId, worktreeName)
)
}}
onRouteVisibleChange={actions.setShowNewWorktreeVisible}
/>
</>
)
}
@@ -0,0 +1,25 @@
import { Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { hostScreenStyles as styles } from './host-screen-styles'
import { HostScreenHeader } from './host-screen-header'
import { HostScreenOverlays } from './host-screen-overlays'
import { HostWorkspaceList } from './host-workspace-list'
import type { HostScreenController } from './use-host-screen-controller'
export function HostScreenView({ controller }: { controller: HostScreenController }) {
if (controller.state.error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{controller.state.error}</Text>
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<HostScreenHeader controller={controller} />
<HostWorkspaceList controller={controller} />
<HostScreenOverlays controller={controller} />
</SafeAreaView>
)
}
@@ -0,0 +1,199 @@
import { Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
import { AuthFailedBanner } from '../components/AuthFailedBanner'
import { HostDiagnosticsLink } from '../components/HostDiagnosticsLink'
import { HostRouteNoticeBanner } from '../components/HostRouteNoticeBanner'
import { MobileRepoIcon } from '../components/MobileRepoIcon'
import { MobileSearchField } from '../components/MobileSearchField'
import { NewWorkspaceFab, FAB_SIZE } from '../components/NewWorkspaceFab'
import { WorktreeListRow } from '../components/WorktreeListRow'
import { colors, spacing } from '../theme/mobile-theme'
import { getWorktreeRowIdentity } from '../worktree/worktree-host-row-identity'
import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { repoColor } from '../worktree/repo-color'
import { hostScreenStyles as styles } from './host-screen-styles'
import type { HostScreenController } from './use-host-screen-controller'
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
const {
actions,
activeWorktreeScroll,
catalog,
connState,
contentMaxWidth,
displayWorktrees,
embedded,
forceReconnectHost,
hostId,
insets,
isReadOnly,
isWideLayout,
noticeParam,
now,
reconnectAttempts,
relayRecovery,
routeNotice,
router,
sectionsResult,
setDismissedNotice,
settings,
state
} = controller
const { rawSections, sections, uniqueRepoColors } = sectionsResult
return (
<>
{/* Auth failed: a latched relay rejection must reach the same re-pair affordance. */}
{(connState === 'auth-failed' || relayRecovery.pairingRejected) && (
<AuthFailedBanner
canRetry={!!hostId}
onRetry={() => hostId && void forceReconnectHost(hostId)}
onRepair={() => router.push('/pair-scan')}
onRemove={() => state.setConfirmRemoveHost(true)}
/>
)}
{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
message={routeNotice}
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
/>
)}
{/* Search bar */}
{state.showSearch && (
<View style={styles.searchBar}>
<MobileSearchField
value={state.search}
onChangeText={state.setSearch}
placeholder="Search worktrees…"
autoFocus
// Why: new key per open remounts the focus effect across rapid toggles so the keyboard reappears.
focusKey={state.showSearch}
accessibilityLabel="Search worktrees"
/>
</View>
)}
<HostWorkspaceListStates
connState={connState}
worktreesLoaded={state.worktreesLoaded}
displayCount={displayWorktrees.length}
sectionCount={sections.length}
catalogError={state.catalogError}
search={state.search}
activeFilterCount={settings.activeFilterCount}
/>
{sections.length > 0 && (
<SectionList
ref={activeWorktreeScroll.sectionListRef}
sections={sections}
keyExtractor={(w) => w.sectionListKey ?? getWorktreeRowIdentity(w)}
stickySectionHeadersEnabled={false}
// Why: keep the search IME up while tapping clear / scrolling results.
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
onScrollToIndexFailed={activeWorktreeScroll.onScrollToIndexFailed}
// Why: edge-to-edge under the system nav bar; insets.bottom keeps the last row above it.
contentContainerStyle={[
styles.list,
// Reserve room so the last row stays tappable above the phone's floating "+" (embedded uses the toolbar +).
{ paddingBottom: (embedded ? spacing.lg : FAB_SIZE + spacing.xl) + insets.bottom },
isWideLayout &&
!embedded && { maxWidth: contentMaxWidth, width: '100%', alignSelf: 'center' }
]}
renderSectionHeader={({ section }) => {
if (!section.title) {
return null
}
const isCollapsed = state.collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon =
state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null
return (
<Pressable
style={styles.sectionHeader}
onPress={() => settings.toggleCollapsed(section.key)}
>
{isCollapsed ? (
<ChevronRight size={12} color={colors.textMuted} style={styles.sectionIcon} />
) : (
<ChevronDown size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{section.icon === 'pin' && (
<Pin size={12} color={colors.textMuted} style={styles.sectionIcon} />
)}
{state.groupMode === 'repo' ? (
<View style={styles.sectionRepoIcon}>
<MobileRepoIcon
repoIcon={repoSectionIcon}
size={14}
color={repoSectionColor ?? colors.textSecondary}
/>
</View>
) : null}
<Text style={styles.sectionTitle}>{section.title}</Text>
<Text style={styles.sectionCount}>{count}</Text>
</Pressable>
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh snapshot after a stale-cache reconnect.
refreshControl={
<RefreshControl
refreshing={catalog.refreshing}
onRefresh={catalog.onRefresh}
tintColor={colors.textSecondary}
colors={[colors.textSecondary]}
/>
}
renderItem={({ item }) => (
<WorktreeListRow
item={item}
isReadOnly={isReadOnly}
now={now}
status={getWorktreeStatus(item)}
repoColor={uniqueRepoColors.get(item.repo) ?? repoColor(item.repo)}
repoIcon={state.repoIconsByName.get(item.repo) ?? null}
hideRepo={state.groupMode === 'repo'}
onPress={actions.openWorktreeSession}
onLongPress={
item.workspaceKind === 'folder-workspace' ? undefined : state.setActionTarget
}
onToggleLineage={settings.toggleWorktreeLineage}
/>
)}
/>
)}
{/* Floating "new workspace" button — phone only; embedded sidebars keep the toolbar +. */}
{!embedded && (
<NewWorkspaceFab
onPress={actions.openNewWorktreeModal}
disabled={connState !== 'connected'}
/>
)}
</>
)
}
function ListSeparator() {
return <View style={styles.separator} />
}
@@ -0,0 +1,85 @@
import { useCallback } from 'react'
import { setCachedRepos } from '../cache/repo-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RepoSummary } from '../worktree/host-worktree-rpc-types'
import { repoColor } from '../worktree/repo-color'
import type { HostScreenState } from './use-host-screen-state'
const REPO_METADATA_REFRESH_MS = 60_000
export function useHostRepoMetadata(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: HostScreenState
}) {
const { client, connState, hostId, state } = args
const {
clientRef,
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
repoMetadataFetchedAtRef,
setRepoColorsByName,
setRepoIconsByName,
setRepoIdsByName
} = state
const fetchRepoMetadata = useCallback(
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (fetchRepoMetadataInFlightRef.current.has(client)) {
if (options.queueIfInFlight) {
fetchRepoMetadataPendingRef.current.add(client)
}
return
}
const now = Date.now()
if (!options.force && now - repoMetadataFetchedAtRef.current < REPO_METADATA_REFRESH_MS) {
return
}
fetchRepoMetadataInFlightRef.current.add(client)
const requestClient = client,
requestHostId = hostId
try {
do {
fetchRepoMetadataPendingRef.current.delete(requestClient)
const repoResponse = await requestClient.sendRequest('repo.list')
if (clientRef.current !== requestClient || hostId !== requestHostId || !repoResponse.ok) {
return
}
const repoResult = (repoResponse as RpcSuccess).result as { repos: RepoSummary[] }
repoMetadataFetchedAtRef.current = Date.now()
setCachedRepos(requestHostId, repoResult.repos)
setRepoColorsByName(
new Map(
repoResult.repos.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
setRepoIconsByName(
new Map(
repoResult.repos.flatMap((repo) =>
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
)
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
} while (fetchRepoMetadataPendingRef.current.has(requestClient))
} catch {
// Repo metadata is decorative; the next refresh can retry.
} finally {
fetchRepoMetadataInFlightRef.current.delete(requestClient)
}
},
[client, connState, hostId]
)
return fetchRepoMetadata
}
export type FetchHostRepoMetadata = ReturnType<typeof useHostRepoMetadata>
@@ -0,0 +1,158 @@
import { useMemo, useState } from 'react'
import { useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useHostProtocolGates } from '../components/HostProtocolGate'
import { visibleHostRouteNotice } from '../host-route-notice'
import { resolveHostRouteActionState } from '../host-route-action-state'
import { useActiveWorktreeScroll } from '../hooks/use-active-worktree-scroll'
import { useNow } from '../hooks/use-now'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useForgetHostClient, useForceReconnect, useHostClient } from '../transport/client-context'
import {
useLastConnectedAt,
useReconnectAttempt,
useRelayRecoveryStatus
} from '../transport/client-context-connection-metrics'
import { applyWorktreeRowDisplayState } from '../worktree/worktree-host-row-identity'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostScreenState } from './use-host-screen-state'
import { useHostViewSettings } from './use-host-view-settings'
import { useHostWorktreeActions } from './use-host-worktree-actions'
import { useHostWorktreeCatalog } from './use-host-worktree-catalog'
export type HostScreenProps = {
// When true, rendered as the persistent tablet sidebar by the host layout, not as its own routed screen.
embedded?: boolean
// Route params aren't in scope when rendered from the layout, so the caller passes these explicitly.
hostId?: string
action?: string
onHideSidebar?: () => void
}
export function useHostScreenController({
embedded = false,
hostId: hostIdProp,
action: actionProp,
onHideSidebar
}: HostScreenProps = {}) {
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
const noticeParam = params.notice?.trim()
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
const router = useRouter()
const pathname = usePathname()
const insets = useSafeAreaInsets()
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
// Shared client per host owned by RpcClientProvider. See docs/mobile-shared-client-per-host.md.
const { client, state: connState } = useHostClient(hostId)
const reconnectAttempts = useReconnectAttempt(hostId)
const lastConnectedAt = useLastConnectedAt(hostId)
const relayRecovery = useRelayRecoveryStatus(hostId)
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
// One tick drives every visible agent row's relative timestamp.
const now = useNow(30_000)
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
const state = useHostScreenState(hostId, action)
const settings = useHostViewSettings({ client, connState, hostId, state })
useHostScreenIdentity({ client, hostId, state })
const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state })
const catalog = useHostWorktreeCatalog({
client,
connState,
embedded,
fetchRepoMetadata,
hostId,
state,
syncViewSettingsFromDesktop: settings.syncViewSettingsFromDesktop
})
const actions = useHostWorktreeActions({
client,
connState,
embedded,
fetchWorktrees: catalog.fetchWorktrees,
forgetHostClient,
hostId,
pathname,
router,
state
})
const resolvedRouteActionState = resolveHostRouteActionState(state.routeActionState, action)
// Why: resolve `action=newWorktree` before commit, but don't reopen after the user closes while the URL persists.
if (resolvedRouteActionState !== state.routeActionState) {
state.setRouteActionState(resolvedRouteActionState)
}
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const displayWorktrees = useMemo(() => {
// Why: live `worktrees` is authoritative only while connected; under the amber
// mount default, connecting/handshaking must keep the pre-reconnect list too.
const base = connState === 'connected' ? state.worktrees : state.lastKnownWorktrees
return applyWorktreeRowDisplayState(
base,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
)
}, [
connState,
state.worktrees,
state.lastKnownWorktrees,
state.sleptIds,
state.optimisticActiveWorktreeIdentity
])
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.sortMode,
filters: state.filters,
search: state.search,
groupMode: state.groupMode,
pinnedIds: state.pinnedIds,
repoIdsByName: state.repoIdsByName,
repoColorsByName: state.repoColorsByName,
collapsedGroups: state.collapsedGroups,
workspaceStatuses: state.workspaceStatuses
})
const existingWorktreePaths = useMemo(() => state.worktrees.map((w) => w.path), [state.worktrees])
const activeWorktreeScroll = useActiveWorktreeScroll(sectionsResult.sections)
return {
actions,
activeWorktreeScroll,
catalog,
client,
connState,
contentMaxWidth,
displayWorktrees,
embedded,
existingWorktreePaths,
floatingWorkspaceEnabled,
forceReconnectHost,
hostCapabilities,
hostId,
insets,
isReadOnly: connState === 'auth-failed',
isWideLayout,
lastConnectedAt,
noticeParam,
now,
onHideSidebar,
reconnectAttempts,
relayRecovery,
routeNotice,
router,
sectionsResult,
setDismissedNotice,
settings,
showNewWorktree,
state
}
}
export type HostScreenController = ReturnType<typeof useHostScreenController>
@@ -0,0 +1,90 @@
import { useEffect } from 'react'
import { getCachedWorktrees } from '../cache/worktree-cache'
import { loadPinnedIds } from '../storage/preferences'
import { loadHosts, updateLastConnected } from '../transport/host-store'
import type { RpcClient } from '../transport/rpc-client'
import type { Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostScreenIdentity(args: {
client: RpcClient | null
hostId: string | undefined
state: HostScreenState
}): void {
const { client, hostId, state } = args
const {
clientRef,
repoMetadataFetchedAtRef,
setCatalogError,
setError,
setHostName,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoIconsByName,
setWorktrees,
setWorktreesLoaded
} = state
// Load persisted pins from local cache; view settings are no longer local (they sync via ui.get).
useEffect(() => {
if (!hostId) {
return
}
let stale = false
void (async () => {
const pins = await loadPinnedIds(hostId)
if (stale) {
return
}
setPinnedIds(pins)
})()
return () => {
stale = true
}
}, [hostId])
// Why: mirror client into a ref so imperative call sites read it without re-subscribing.
useEffect(() => {
clientRef.current = client
}, [client])
useEffect(() => {
setHostName('')
setError('')
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
repoMetadataFetchedAtRef.current = 0
// Why: useState initializer runs only on first mount, so re-seed the cache when Expo Router reuses this screen for a new hostId.
const freshCache = hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
setCatalogError(null)
if (freshCache) {
setWorktrees(freshCache)
setLastKnownWorktrees(freshCache)
setWorktreesLoaded(true)
} else {
setWorktreesLoaded(false)
setWorktrees([])
setLastKnownWorktrees([])
}
if (!hostId) {
return
}
let stale = false
void loadHosts().then((hosts) => {
if (stale) {
return
}
const host = hosts.find((h) => h.id === hostId)
if (!host) {
setError('Host not found')
return
}
setHostName(host.name)
void updateLastConnected(host.id)
})
return () => {
stale = true
}
}, [hostId])
}
@@ -0,0 +1,148 @@
import { useRef, useState } from 'react'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
import { getCachedWorktrees } from '../cache/worktree-cache'
import { createInitialHostRouteActionState } from '../host-route-action-state'
import type { RpcClient } from '../transport/rpc-client'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../worktree/mobile-workspace-statuses'
import { WorktreeCatalogSnapshotClient } from '../worktree/worktree-catalog-snapshot-client'
import type {
MobileGroupMode,
MobileSortMode,
MobileViewState
} from '../worktree/workspace-view-settings'
import type { FilterState, Worktree } from '../worktree/workspace-list-sections'
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
const [initialCache] = useState(() =>
hostId ? (getCachedWorktrees(hostId) as Worktree[] | null) : null
)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
// Why: useRef, not useMemo — React may discard memoized values, which would silently
// reset the snapshot token this object exists to own.
const worktreeCatalogRef = useRef(new WorktreeCatalogSnapshotClient())
const fetchRepoMetadataInFlightRef = useRef(new WeakSet<RpcClient>())
const fetchRepoMetadataPendingRef = useRef(new WeakSet<RpcClient>())
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
// Why (STA-3123): error code of the last failed worktree.ps, so a broken catalog
// path renders as a failure instead of an empty host. Cleared on the next success.
const [catalogError, setCatalogError] = useState<string | null>(null)
// Why: track the locally-opened worktree so the active-row highlight moves instantly instead of waiting for the next poll.
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
string | null
>(null)
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, RepoIcon>>(new Map())
const [hostName, setHostName] = useState('')
const [error, setError] = useState('')
const [lastKnownWorktrees, setLastKnownWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [search, setSearch] = useState('')
const [showSearch, setShowSearch] = useState(false)
const [sortMode, setSortMode] = useState<MobileSortMode>('recent')
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
const [showSortPicker, setShowSortPicker] = useState(false)
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
createInitialHostRouteActionState(action)
)
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
const [pinnedIds, setPinnedIds] = useState<Set<string>>(new Set())
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
// Why: ref so the ui.get merge and ui.set writes read the latest values without re-creating callbacks on every state change.
const viewStateRef = useRef<MobileViewState>({
groupMode: 'repo',
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
})
return {
actionTarget,
catalogError,
clientRef,
collapsedGroups,
confirmDelete,
confirmRemoveHost,
error,
fetchRepoMetadataInFlightRef,
fetchRepoMetadataPendingRef,
fetchWorktreesInFlightRef,
filters,
groupMode,
hostName,
lastKnownWorktrees,
newWorktreeModalRef,
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoIconsByName,
repoIdsByName,
repoMetadataFetchedAtRef,
routeActionState,
search,
setActionTarget,
setCatalogError,
setCollapsedGroups,
setConfirmDelete,
setConfirmRemoveHost,
setError,
setFilters,
setGroupMode,
setHostName,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoIconsByName,
setRepoIdsByName,
setRouteActionState,
setSearch,
setShowFilterModal,
setShowGroupPicker,
setShowSearch,
setShowSortPicker,
setSleptIds,
setSortMode,
setWorkspaceStatuses,
setWorktrees,
setWorktreesLoaded,
showFilterModal,
showGroupPicker,
showSearch,
showSortPicker,
sleptIds,
sortMode,
viewStateRef,
workspaceStatuses,
worktreeCatalogRef,
worktrees,
worktreesLoaded
}
}
export type HostScreenState = ReturnType<typeof useHostScreenState>
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import { getMobileWorkspaceLineageGroupKey } from '../worktree/mobile-workspace-lineage'
import { WORKSPACE_SORT_OPTIONS as SORT_OPTIONS } from '../worktree/workspace-list-picker-options'
import {
applyDesktopViewSettings,
buildWorkspaceViewSettingsUpdate,
type MobileGroupMode,
type MobileSortMode,
type MobileViewState,
type WorkspaceViewSettings
} from '../worktree/workspace-view-settings'
import type { Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostViewSettings(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: HostScreenState
}) {
const { client, connState, hostId, state } = args
const {
clientRef,
collapsedGroups,
filters,
groupMode,
setCollapsedGroups,
setFilters,
setGroupMode,
setSortMode,
setWorkspaceStatuses,
sortMode,
viewStateRef,
workspaceStatuses
} = state
useEffect(() => {
viewStateRef.current = {
groupMode,
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
}
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState onto the individual states and the snapshot ref in one shot.
const applyViewState = useCallback((next: MobileViewState) => {
viewStateRef.current = next
setGroupMode(next.groupMode)
setSortMode(next.sortMode)
setWorkspaceStatuses(next.workspaceStatuses)
setCollapsedGroups(new Set(next.collapsedGroups))
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
// Apply the change locally, then patch the desktop's shared store (ui.set) so both apps stay in sync.
const persistViewSettings = useCallback(
(patch: Partial<MobileViewState>) => {
const next: MobileViewState = { ...viewStateRef.current, ...patch }
applyViewState(next)
if (!client) {
return
}
// Send only the touched fields: the host merges partial updates, so a stale
// mirror can no longer revert sibling settings another client just changed
// (STA-5781; supersedes the #8873 whole-payload special case).
const payload: WorkspaceViewSettings = buildWorkspaceViewSettingsUpdate(patch, next)
if (Object.keys(payload).length === 0) {
return
}
void client.sendRequest('ui.set', payload).catch(() => {
// Best-effort: view settings are a convenience preference.
})
},
[client, applyViewState]
)
// Merge the desktop's shared view settings (PersistedUIState) onto local state so desktop changes appear here.
const syncViewSettingsFromDesktop = useCallback(async () => {
if (!client || connState !== 'connected') {
return
}
const requestClient = client
const requestHostId = hostId
try {
const response = await requestClient.sendRequest('ui.get')
if (clientRef.current !== requestClient || hostId !== requestHostId || !response.ok) {
return
}
const ui = ((response as RpcSuccess).result as { ui?: WorkspaceViewSettings }).ui
if (!ui) {
return
}
applyViewState(applyDesktopViewSettings(viewStateRef.current, ui))
} catch {
// Transient transport failure; retry on the next focus/connect.
}
}, [client, connState, hostId, applyViewState])
const handleSortChange = useCallback(
(value: MobileSortMode) => {
persistViewSettings({ sortMode: value })
},
[persistViewSettings]
)
const toggleHideSleeping = useCallback(() => {
persistViewSettings({ hideSleeping: !viewStateRef.current.hideSleeping })
}, [persistViewSettings])
const toggleHideDefaultBranch = useCallback(() => {
persistViewSettings({ hideDefaultBranch: !viewStateRef.current.hideDefaultBranch })
}, [persistViewSettings])
const toggleRepoFilter = useCallback(
(repoId: string) => {
const next = new Set(viewStateRef.current.filterRepoIds)
if (next.has(repoId)) {
next.delete(repoId)
} else {
next.add(repoId)
}
persistViewSettings({ filterRepoIds: [...next] })
},
[persistViewSettings]
)
const clearFilters = useCallback(() => {
persistViewSettings({ hideSleeping: false, hideDefaultBranch: false, filterRepoIds: [] })
}, [persistViewSettings])
const activeFilterCount = useMemo(() => {
let count = 0
if (filters.hideSleeping) {
count++
}
if (filters.hideDefaultBranch) {
count++
}
count += filters.filterRepoIds.size
return count
}, [filters])
const selectedSortLabel =
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? 'Recent'
const handleGroupChange = useCallback(
(value: MobileGroupMode) => {
persistViewSettings({ groupMode: value })
},
[persistViewSettings]
)
const toggleCollapsed = useCallback(
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (!next.delete(key)) {
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
[persistViewSettings]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item)),
[toggleCollapsed]
)
return {
activeFilterCount,
clearFilters,
handleGroupChange,
handleSortChange,
selectedSortLabel,
syncViewSettingsFromDesktop,
toggleCollapsed,
toggleHideDefaultBranch,
toggleHideSleeping,
toggleRepoFilter,
toggleWorktreeLineage
}
}
export type HostViewSettings = ReturnType<typeof useHostViewSettings>
@@ -0,0 +1,212 @@
import { useCallback } from 'react'
import { Alert } from 'react-native'
import type { useRouter } from 'expo-router'
import { floatingWorkspaceSessionPath } from '../session/floating-workspace'
import { savePinnedIds } from '../storage/preferences'
import type { useForgetHostClient } from '../transport/client-context'
import { removeHostAndCloseClient } from '../transport/host-removal-lifecycle'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { setHostRouteNewWorktreeVisible } from '../host-route-action-state'
import { leaveHostRoute } from '../host-route-exit'
import { getWorktreeRowIdentity, removeWorktreeRow } from '../worktree/worktree-host-row-identity'
import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections'
import type { HostScreenState } from './use-host-screen-state'
export function useHostWorktreeActions(args: {
client: RpcClient | null
connState: ConnectionState
embedded: boolean
fetchWorktrees: (options?: { allowDuringModal?: boolean }) => Promise<void>
forgetHostClient: ReturnType<typeof useForgetHostClient>
hostId: string | undefined
pathname: string
router: ReturnType<typeof useRouter>
state: HostScreenState
}) {
const {
client,
connState,
embedded,
fetchWorktrees,
forgetHostClient,
hostId,
pathname,
router,
state
} = args
const {
newWorktreeModalRef,
newWorktreeModalVisibleRef,
pinnedIds,
setConfirmRemoveHost,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRouteActionState,
setWorktrees,
worktrees
} = state
const leaveHost = useCallback(() => {
leaveHostRoute(router)
}, [router])
const openNewWorktreeModal = useCallback(() => {
const modal = newWorktreeModalRef.current
if (!modal) {
return
}
newWorktreeModalVisibleRef.current = true
modal.open()
}, [])
const setShowNewWorktreeVisible = useCallback((visible: boolean) => {
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
setPinnedIds((prev) => {
const next = new Set(prev)
if (pinned) {
next.add(worktreeId)
} else {
next.delete(worktreeId)
}
if (hostId) {
void savePinnedIds(hostId, next)
}
return next
})
},
[hostId]
)
const togglePin = useCallback(
(worktreeId: string) => {
const worktree = worktrees.find((w) => w.worktreeId === worktreeId)
const currentlyPinned = worktree
? isWorktreePinned(worktree, pinnedIds)
: pinnedIds.has(worktreeId)
const newPinned = !currentlyPinned
setWorktrees((prev) =>
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
)
setLastKnownWorktrees((prev) =>
prev.map((w) => (w.worktreeId === worktreeId ? { ...w, isPinned: newPinned } : w))
)
updateLocalPins(worktreeId, newPinned)
if (client) {
client
.sendRequest('worktree.set', {
worktree: `id:${worktreeId}`,
isPinned: newPinned
})
.catch(() => {})
}
},
[client, worktrees, pinnedIds, updateLocalPins]
)
const handleDeleteWorktree = useCallback(
async (item: Worktree) => {
if (!client) {
return
}
const removeFromList = (list: Worktree[]) => removeWorktreeRow(list, item)
setWorktrees(removeFromList)
setLastKnownWorktrees(removeFromList)
try {
const response = await client.sendRequest('worktree.rm', {
worktree: `id:${item.worktreeId}`,
force: true
})
if (!response.ok) {
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
void fetchWorktrees()
} catch {
setWorktrees((prev) => [...prev, item])
setLastKnownWorktrees((prev) => [...prev, item])
}
},
[client, fetchWorktrees]
)
const handleRemoveHost = useCallback(async () => {
if (!hostId) {
return
}
try {
await removeHostAndCloseClient(hostId, forgetHostClient)
leaveHost()
} catch {
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
}, [hostId, leaveHost, forgetHostClient])
const navigateFromHostList = useCallback(
(target: string) => {
if (!embedded) {
router.push(target)
return
}
if (pathname === (target.split('?')[0] ?? target)) {
return
}
if (pathname === `/h/${hostId}`) {
router.push(target)
return
}
router.replace(target)
},
[embedded, hostId, pathname, router]
)
const openWorktreeSession = useCallback(
(item: Worktree) => {
setOptimisticActiveWorktreeIdentity(getWorktreeRowIdentity(item))
if (client && connState === 'connected') {
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`,
notifyClients: false,
navigation: 'caller'
})
.catch(() => null)
}
const target = `/h/${hostId}/session/${encodeURIComponent(item.worktreeId)}?name=${encodeURIComponent(item.displayName || item.repo)}`
navigateFromHostList(target)
},
[client, connState, hostId, navigateFromHostList]
)
const openFloatingWorkspace = useCallback(() => {
// Why: no worktree.activate here — the floating sentinel has no worktree
// record; session.tabs.list hydrates its host-owned tabs on open.
navigateFromHostList(floatingWorkspaceSessionPath(hostId))
}, [hostId, navigateFromHostList])
return {
handleDeleteWorktree,
handleRemoveHost,
leaveHost,
navigateFromHostList,
openFloatingWorkspace,
openNewWorktreeModal,
openWorktreeSession,
setShowNewWorktreeVisible,
togglePin
}
}
export type HostWorktreeActions = ReturnType<typeof useHostWorktreeActions>
@@ -0,0 +1,174 @@
import { useCallback, useEffect } from 'react'
import { useFocusEffect } from 'expo-router'
import { setCachedWorktrees } from '../cache/worktree-cache'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import { useWorktreeResync } from '../transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../worktree/host-worktree-refresh'
import { areWorktreeListsEqual } from '../worktree/worktree-list-snapshot'
import {
clearConfirmedActiveWorktreeIdentity,
retainLiveSleptWorktreeIdentities
} from '../worktree/worktree-host-row-identity'
import { savePinnedIds } from '../storage/preferences'
import type { FetchHostRepoMetadata } from './use-host-repo-metadata'
import type { HostScreenState } from './use-host-screen-state'
export function useHostWorktreeCatalog(args: {
client: RpcClient | null
connState: ConnectionState
embedded: boolean
fetchRepoMetadata: FetchHostRepoMetadata
hostId: string | undefined
state: HostScreenState
syncViewSettingsFromDesktop: () => Promise<void>
}) {
const {
client,
connState,
embedded,
fetchRepoMetadata,
hostId,
state,
syncViewSettingsFromDesktop
} = args
const {
clientRef,
fetchWorktreesInFlightRef,
newWorktreeModalVisibleRef,
setCatalogError,
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setSleptIds,
setWorktrees,
setWorktreesLoaded,
worktreeCatalogRef
} = state
const fetchWorktrees = useCallback(
async (options: { allowDuringModal?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// Why: prevent slow remote hosts from stacking overlapping worktree.ps requests during polling.
if (fetchWorktreesInFlightRef.current) {
return
}
fetchWorktreesInFlightRef.current = true
const requestClient = client
const requestHostId = hostId
try {
const fetched = await worktreeCatalogRef.current.fetch(requestClient, requestHostId)
if (clientRef.current !== requestClient || hostId !== requestHostId) {
return
}
if (!options.allowDuringModal && newWorktreeModalVisibleRef.current) {
return
}
// Why (STA-3123): a failed catalog request must not pass for "0 worktrees";
// surface it so a broken remote host is diagnosable instead of looking empty.
if (fetched.kind === 'request_failed') {
setCatalogError(fetched.code)
return
}
if (fetched.pending.admission.kind === 'invalid') {
setCatalogError('invalid_response')
}
// Why: unchanged responses still yield the confirmed rows, so every poll reasserts
// host truth over optimistic local edits regardless of payload size.
const confirmed = worktreeCatalogRef.current.admit(fetched.pending)
if (confirmed) {
setCatalogError(null)
// Why: reuse the existing array on identical snapshots to keep SectionList/sort rebuilds off the tap path.
setWorktrees((current) =>
areWorktreeListsEqual(current, confirmed) ? current : confirmed
)
setLastKnownWorktrees((current) =>
areWorktreeListsEqual(current, confirmed) ? current : confirmed
)
setWorktreesLoaded(true)
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
if (hostId) {
setCachedWorktrees(hostId, confirmed, { proven: true })
}
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
setOptimisticActiveWorktreeIdentity((pending) =>
clearConfirmedActiveWorktreeIdentity(pending, confirmed)
)
// Clear optimistic sleep overrides once the server confirms inactive (liveTerminalCount === 0).
setSleptIds((prev) => retainLiveSleptWorktreeIdentities(prev, confirmed))
// Sync pin state from server so desktop-initiated pins reflect without relying on stale AsyncStorage.
const serverPinned = new Set(confirmed.filter((w) => w.isPinned).map((w) => w.worktreeId))
setPinnedIds((prev) => {
if (serverPinned.size === prev.size && [...serverPinned].every((id) => prev.has(id))) {
return prev
}
if (hostId) {
void savePinnedIds(hostId, serverPinned)
}
return serverPinned
})
}
} catch {
// Will retry on reconnect
if (clientRef.current === requestClient && hostId === requestHostId) {
setCatalogError('network_error')
}
} finally {
fetchWorktreesInFlightRef.current = false
}
},
[client, connState, hostId]
)
useFocusEffect(
useCallback(() => {
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
clientRef.current?.notifyForeground('focus')
}, [])
)
const startWorktreeRefresh = useCallback(() => {
if (!client || connState !== 'connected') {
return
}
void syncViewSettingsFromDesktop()
return startHostWorktreeRefresh({ client, fetchWorktrees, fetchRepoMetadata })
}, [client, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
useFocusEffect(
useCallback(() => {
// The embedded sidebar isn't a routed screen (focus never fires); it refreshes via the mount effect below.
if (!embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
)
// Why: the embedded sidebar is never the focused route, so wire its refresh lifecycle from a mount effect.
useEffect(() => {
if (embedded) {
return startWorktreeRefresh()
}
}, [embedded, startWorktreeRefresh])
// Why (#8498): steady-state polls miss the transition INTO 'connected' after background/sleep, when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
fetchWorktrees,
fetchRepoMetadata
})
return { fetchWorktrees, onRefresh, refreshing }
}
export type HostWorktreeCatalog = ReturnType<typeof useHostWorktreeCatalog>
@@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync(
new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url),
'utf8'
)
const terminalInventoryRecoverySource = readFileSync(
new URL('./use-mobile-terminal-inventory-recovery.ts', import.meta.url),
'utf8'
)
const autoCreateHookSource = readFileSync(
new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url),
'utf8'
@@ -68,25 +72,44 @@ describe('mobile session startup', () => {
expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs')
})
it('delegates stream ownership while retaining the exact terminal polling cadence', () => {
it('delegates stream ownership while retaining degraded polling and a certified sweep', () => {
expect(source).toContain('useMobileSessionTabsReconciliation<')
expect(source).toContain('const applicationRevision = ++appliedSessionTabsRevisionRef.current')
expect(source).toContain('getApplicationRevision: getSessionTabsApplicationRevision')
expect(source).not.toContain("client.subscribe(\n 'session.tabs.subscribe'")
expect(reconciliationHookSource).toContain("client.subscribe(\n 'session.tabs.subscribe'")
expect(reconciliationHookSource).toContain(
"if (AppState.currentState !== 'active') {\n controller.setReconciliationActive(false)"
)
expect(reconciliationHookSource).toContain('void controller.poll()')
expect(reconciliationHookSource).toContain('void fetchTerminals()')
expect(reconciliationHookSource).toContain("if (AppState.currentState !== 'active')")
expect(reconciliationHookSource).toContain('suspendTerminalInventoryRecovery(true)')
expect(reconciliationHookSource).toContain('controller.poll()')
expect(reconciliationHookSource).toContain('tabsRequest !== null')
expect(reconciliationHookSource).toContain('void refreshTerminalInventory()')
expect(reconciliationHookSource).toContain("AppState.addEventListener('change'")
expect(reconciliationHookSource).toContain('const interval = setInterval(')
expect(reconciliationHookSource).toContain('2000')
expect(reconciliationHookSource).toContain('RECONCILIATION_INTERVAL_MS = 2000')
expect(terminalInventoryRecoverySource).toContain('CERTIFIED_TERMINAL_SWEEP_MS = 60_000')
expect(reconciliationHookSource).toContain('controller.setReconciliationActive(false)')
expect(reconciliationHookSource).toContain('clearInterval(interval)')
expect(reconciliationHookSource).toContain('appStateSubscription.remove()')
})
it('confirms terminal stream teardown with a committed inventory-recovery bridge', () => {
expect(source).toContain("if (data.type === 'end' || data.type === 'error')")
expect(source).toContain('signalTerminalInventoryRecovery()')
expect(terminalInventoryRecoverySource).toContain('actionRef.current = recoveryAction')
expect(terminalInventoryRecoverySource).toContain('pendingSignalScopeRef.current = scopeKey')
expect(terminalInventoryRecoverySource).toContain(
'committedScope !== null && committedScope !== scopeKey'
)
expect(source).toContain('return terminalInventoryRequest.activate()')
expect(source).toContain('if (!isCurrent())')
expect(terminalInventoryRecoverySource).toContain(
'TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750'
)
expect(terminalInventoryRecoverySource).toContain(
'refreshTerminalInventory({ allowEmptyLoaded: true })'
)
})
it('loads session tabs without waiting for desktop activation', () => {
const startupEffect = sliceBetween(
'void (async () => {',
@@ -72,12 +72,10 @@ function makeHarness(options?: {
} as unknown as RpcClient
const apply =
options?.apply ??
vi.fn(
(value: TestResult): SessionTabsApplyOutcome<string> => ({
accepted: true,
effectiveTabs: value.tabs
})
)
vi.fn((value: TestResult): SessionTabsApplyOutcome<string> => ({
accepted: true,
effectiveTabs: value.tabs
}))
const consumeAccepted = vi.fn()
let recoveryNeeded = false
const controller = new MobileSessionTabsStreamHealth<TestResult, string>({
@@ -306,9 +304,8 @@ describe('MobileSessionTabsStreamHealth', () => {
})
it('does not consume a rejected stream snapshot or update', () => {
const apply = vi.fn(
(value: TestResult): SessionTabsApplyOutcome<string> =>
value.type ? { accepted: false } : { accepted: true, effectiveTabs: value.tabs }
const apply = vi.fn((value: TestResult): SessionTabsApplyOutcome<string> =>
value.type ? { accepted: false } : { accepted: true, effectiveTabs: value.tabs }
)
const harness = makeHarness({ apply })
const subscription = harness.controller.beginSubscription()
@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileTerminalInventoryRequest } from './mobile-terminal-inventory-request'
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
describe('MobileTerminalInventoryRequest', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('shares an in-flight request and upgrades its empty-list handling', async () => {
const response = deferred<void>()
const execute = vi.fn(async (allowsEmpty: () => boolean) => {
await response.promise
return allowsEmpty()
})
const requests = new MobileTerminalInventoryRequest()
const startupStarted = vi.fn()
const recoveryStarted = vi.fn()
vi.spyOn(Date, 'now').mockReturnValueOnce(100).mockReturnValue(200)
const startup = requests.run(false, execute, startupStarted)
const recovery = requests.run(true, execute, recoveryStarted)
expect(recovery).toBe(startup)
expect(startupStarted).toHaveBeenCalledExactlyOnceWith(100)
expect(recoveryStarted).toHaveBeenCalledExactlyOnceWith(100)
await Promise.resolve()
expect(execute).toHaveBeenCalledTimes(1)
response.resolve()
await expect(startup).resolves.toBe(true)
await expect(recovery).resolves.toBe(true)
})
it('starts fresh after the shared request settles or rejects', async () => {
const requests = new MobileTerminalInventoryRequest()
const failure = new Error('transport lost')
await expect(
requests.run(true, async () => {
throw failure
})
).rejects.toBe(failure)
await expect(requests.run(true, async () => true)).resolves.toBe(true)
})
it('fences a response after its route activation is replaced', async () => {
const oldResponse = deferred<void>()
const nextResponse = deferred<void>()
const applied: string[] = []
const oldRequest = new MobileTerminalInventoryRequest()
const deactivateOld = oldRequest.activate()
const oldResult = oldRequest.run(true, async (_allowsEmpty, isCurrent) => {
await oldResponse.promise
if (!isCurrent()) {
return false
}
applied.push('old')
return true
})
await Promise.resolve()
deactivateOld()
const nextRequest = new MobileTerminalInventoryRequest()
nextRequest.activate()
const nextResult = nextRequest.run(true, async (_allowsEmpty, isCurrent) => {
await nextResponse.promise
if (!isCurrent()) {
return false
}
applied.push('next')
return true
})
await Promise.resolve()
nextResponse.resolve()
await expect(nextResult).resolves.toBe(true)
oldResponse.resolve()
await expect(oldResult).resolves.toBe(false)
expect(applied).toEqual(['next'])
})
})
@@ -0,0 +1,52 @@
type InFlightTerminalInventoryRequest = {
allowEmptyLoaded: boolean
promise: Promise<boolean>
startedAt: number
}
export class MobileTerminalInventoryRequest {
private activation: symbol | null = null
private inFlight: InFlightTerminalInventoryRequest | null = null
activate(): () => void {
const activation = Symbol('terminal-inventory-activation')
this.activation = activation
return () => {
if (this.activation === activation) {
this.activation = null
}
}
}
run(
allowEmptyLoaded: boolean,
execute: (allowsEmpty: () => boolean, isCurrent: () => boolean) => Promise<boolean>,
onPhysicalRequestStarted?: (startedAt: number) => void
): Promise<boolean> {
if (this.inFlight) {
this.inFlight.allowEmptyLoaded ||= allowEmptyLoaded
onPhysicalRequestStarted?.(this.inFlight.startedAt)
return this.inFlight.promise
}
const activation = this.activation
const request: InFlightTerminalInventoryRequest = {
allowEmptyLoaded,
promise: Promise.resolve(false),
startedAt: Date.now()
}
onPhysicalRequestStarted?.(request.startedAt)
const execution = Promise.resolve().then(() =>
execute(
() => request.allowEmptyLoaded,
() => activation !== null && this.activation === activation
)
)
request.promise = execution.finally(() => {
if (this.inFlight === request) {
this.inFlight = null
}
})
this.inFlight = request
return request.promise
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
getTerminalRecordsFromSessionTabs,
hasConnectedTerminalAbsentFromSessionTabs,
mergeTerminalListWithKnownRecords,
mergeTerminalRecordsByCurrentOrder,
mobileSessionTabsEqual,
@@ -150,4 +151,70 @@ describe('mobile terminal records', () => {
)
).toBe(false)
})
const record = (over: Partial<TerminalRecord> & { handle: string }): TerminalRecord => ({
title: 'Terminal',
terminalTheme: undefined,
isActive: false,
...over
})
const terminalTab = (handle: string): MobileTerminalSessionTab => ({
id: `tab-${handle}`,
type: 'terminal',
terminal: handle,
title: 'Terminal',
isActive: false
})
it('reports a connected terminal the tab snapshot dropped', () => {
const held = [
record({ handle: 'pty-1', connected: true }),
record({ handle: 'pty-2', connected: true })
]
expect(hasConnectedTerminalAbsentFromSessionTabs(held, [terminalTab('pty-1')])).toBe(true)
})
it('ignores parked handles that tabs never carry', () => {
const parked = [
record({ handle: 'pty-1', connected: false }),
record({ handle: 'pty-2', connected: false })
]
// A worktree with no live PTY lists every parked leaf while tabs publish none;
// treating that as absence would pin the caller to the fast cadence forever.
expect(hasConnectedTerminalAbsentFromSessionTabs(parked, [])).toBe(false)
})
it('ignores orphaned PTYs, which have no leaf and so never appear as a tab', () => {
const orphan = [record({ handle: 'pty-1', connected: true, orphaned: true })]
expect(hasConnectedTerminalAbsentFromSessionTabs(orphan, [])).toBe(false)
})
it('ignores a host that omits connected rather than assuming liveness', () => {
expect(hasConnectedTerminalAbsentFromSessionTabs([record({ handle: 'pty-1' })], [])).toBe(false)
})
it('clears once the snapshot covers every connected terminal', () => {
const held = [record({ handle: 'pty-1', connected: true })]
expect(
hasConnectedTerminalAbsentFromSessionTabs(held, [terminalTab('pty-1'), terminalTab('pty-2')])
).toBe(false)
})
it('keeps the merge additive so absence only schedules the sweep', () => {
const held = [
record({ handle: 'pty-1', connected: true }),
record({ handle: 'pty-2', connected: true })
]
const tabs = [terminalTab('pty-1')]
expect(
mergeTerminalRecordsByCurrentOrder(getTerminalRecordsFromSessionTabs(tabs), held).map(
(terminal) => terminal.handle
)
).toEqual(['pty-1', 'pty-2'])
})
})
+26 -1
View File
@@ -6,6 +6,10 @@ export type TerminalRecord = {
title: string
terminalTheme?: MobileTerminalTheme
isActive: boolean
/** From `terminal.list`; parked and proven-absent leaves report false. */
connected?: boolean
/** From `terminal.list`; a live PTY with no leaf, so it never appears as a tab. */
orphaned?: boolean
}
export type MobileTerminalSessionTab = {
@@ -157,6 +161,26 @@ export function mergeTerminalRecordsByCurrentOrder(
]
}
// Why: tab snapshots are partial and can transiently omit a live terminal, so absence
// here is only a hint to schedule the `terminal.list` sweep -- never a reason to prune.
// Restricted to connected, non-orphaned handles: parked leaves and orphaned PTYs are
// legitimately absent from tabs forever and would pin the caller to the fast cadence.
export function hasConnectedTerminalAbsentFromSessionTabs(
currentTerminals: readonly TerminalRecord[],
tabs: readonly MobileSessionTabLike[]
): boolean {
const tabbable = currentTerminals.filter(
(terminal) => terminal.connected === true && terminal.orphaned !== true
)
if (tabbable.length === 0) {
return false
}
const tabHandles = new Set(
getTerminalRecordsFromSessionTabs(tabs).map((terminal) => terminal.handle)
)
return tabbable.some((terminal) => !tabHandles.has(terminal.handle))
}
export function getTerminalRecordsFromSessionTabs(
tabs: readonly MobileSessionTabLike[]
): TerminalRecord[] {
@@ -169,7 +193,8 @@ export function getTerminalRecordsFromSessionTabs(
handle: tab.terminal,
title: tab.title || 'Terminal',
terminalTheme: tab.terminalTheme,
isActive: tab.isActive === true
isActive: tab.isActive === true,
connected: true
}
]
})
@@ -1,9 +1,11 @@
import { createElement } from 'react'
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { SessionTabsApplyOutcome } from './mobile-session-tabs-stream-health'
import { useMobileSessionTabsReconciliation } from './use-mobile-session-tabs-reconciliation'
import type { MobileTerminalInventoryRefreshOptions } from './use-mobile-terminal-inventory-recovery'
const lifecycle = vi.hoisted(() => ({
appState: 'active',
@@ -38,13 +40,14 @@ type TestResult = {
tabs: string[]
}
const fetchTerminals = vi.fn(async () => {})
const applySessionTabs = vi.fn(
(value: TestResult): SessionTabsApplyOutcome<string> => ({
accepted: true,
effectiveTabs: value.tabs
})
)
const fetchTerminals = vi.fn(async (options?: MobileTerminalInventoryRefreshOptions) => {
options?.onPhysicalRequestStarted?.(Date.now())
return true
})
const applySessionTabs = vi.fn((value: TestResult): SessionTabsApplyOutcome<string> => ({
accepted: true,
effectiveTabs: value.tabs
}))
const consumeAcceptedSessionTabs = vi.fn()
let recoveryNeeded = false
let clearRecoveryAt = Number.POSITIVE_INFINITY
@@ -52,6 +55,9 @@ const hasRecoveryNeed = () => recoveryNeeded
const subscribe = vi.fn()
const unsubscribe = vi.fn()
let streamListener: ((payload: unknown) => void) | null = null
let requestTerminalInventoryRecovery: (() => void) | null = null
let connectionState: ConnectionState = 'connected'
let clientConnectionState: ConnectionState = 'connected'
let listSequence = 0
const sendRequest = vi.fn(async () => ({
id: `list-${++listSequence}`,
@@ -64,8 +70,17 @@ const sendRequest = vi.fn(async () => ({
}))
const client = {
sendRequest,
subscribe
subscribe,
getState: () => clientConnectionState
} as unknown as RpcClient
const replacementClient = {
sendRequest,
subscribe,
getState: () => clientConnectionState
} as unknown as RpcClient
let currentClient: RpcClient = client
let currentWorktreeId = 'repo::worktree'
let currentTerminalInventoryRecoveryScopeKey = 'host::repo::worktree'
function applyWithRecovery(value: TestResult): SessionTabsApplyOutcome<string> {
const outcome = applySessionTabs(value)
@@ -76,15 +91,24 @@ function applyWithRecovery(value: TestResult): SessionTabsApplyOutcome<string> {
}
function Harness(): null {
useMobileSessionTabsReconciliation<TestResult, string>({
client,
connState: 'connected',
worktreeId: 'repo::worktree',
const actions = useMobileSessionTabsReconciliation<TestResult, string>({
client: currentClient,
connState: connectionState,
worktreeId: currentWorktreeId,
applySessionTabs: applyWithRecovery,
consumeAcceptedSessionTabs,
fetchTerminals,
terminalInventoryRecoveryScopeKey: currentTerminalInventoryRecoveryScopeKey,
hasRecoveryNeed
})
useEffect(() => {
requestTerminalInventoryRecovery = actions.requestTerminalInventoryRecovery
return () => {
if (requestTerminalInventoryRecovery === actions.requestTerminalInventoryRecovery) {
requestTerminalInventoryRecovery = null
}
}
}, [actions.requestTerminalInventoryRecovery])
return null
}
@@ -110,6 +134,13 @@ async function setAppState(state: string): Promise<void> {
})
}
function expectedRecoveryInventoryOptions() {
return expect.objectContaining({
allowEmptyLoaded: true,
onPhysicalRequestStarted: expect.any(Function)
})
}
describe('useMobileSessionTabsReconciliation', () => {
let renderer: ReactTestRenderer | null = null
async function mount(): Promise<void> {
@@ -125,10 +156,19 @@ describe('useMobileSessionTabsReconciliation', () => {
lifecycle.appState = 'active'
lifecycle.focused = true
lifecycle.listeners.clear()
connectionState = 'connected'
clientConnectionState = 'connected'
currentClient = client
currentWorktreeId = 'repo::worktree'
currentTerminalInventoryRecoveryScopeKey = 'host::repo::worktree'
recoveryNeeded = false
clearRecoveryAt = Number.POSITIVE_INFINITY
listSequence = 0
fetchTerminals.mockClear()
fetchTerminals.mockReset()
fetchTerminals.mockImplementation(async (options?: MobileTerminalInventoryRefreshOptions) => {
options?.onPhysicalRequestStarted?.(Date.now())
return true
})
applySessionTabs.mockClear()
consumeAcceptedSessionTabs.mockClear()
unsubscribe.mockClear()
@@ -147,10 +187,11 @@ describe('useMobileSessionTabsReconciliation', () => {
act(() => renderer?.unmount())
renderer = null
streamListener = null
requestTerminalInventoryRecovery = null
vi.useRealTimers()
})
it('does zero tab lists and thirty terminal lists in a certified warm minute', async () => {
it('runs one terminal health sweep and zero tab lists in a certified warm minute', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
sendRequest.mockClear()
@@ -161,7 +202,275 @@ describe('useMobileSessionTabsReconciliation', () => {
})
expect(sendRequest).not.toHaveBeenCalled()
expect(fetchTerminals).toHaveBeenCalledTimes(30)
expect(fetchTerminals).toHaveBeenCalledTimes(1)
})
it('backs off a failed certified terminal sweep for another minute', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
fetchTerminals.mockResolvedValue(false)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(58_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
})
it('coalesces terminal teardown into two separated inventory passes', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
requestTerminalInventoryRecovery?.()
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledExactlyOnceWith(expectedRecoveryInventoryOptions())
await act(async () => {
await vi.advanceTimersByTimeAsync(749)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
expect(fetchTerminals).toHaveBeenLastCalledWith(expectedRecoveryInventoryOptions())
})
it('moves the certified sweep deadline after teardown recovery', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
await vi.advanceTimersByTimeAsync(59_000)
requestTerminalInventoryRecovery?.()
await flush()
await vi.advanceTimersByTimeAsync(1_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
expect(fetchTerminals).toHaveBeenNthCalledWith(1, expectedRecoveryInventoryOptions())
expect(fetchTerminals).toHaveBeenNthCalledWith(2, expectedRecoveryInventoryOptions())
})
it.each(['failure', 'rejection'] as const)(
'does not confirm terminal absence after an unverifiable first-pass %s',
async (outcome) => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
if (outcome === 'failure') {
fetchTerminals.mockResolvedValueOnce(false)
} else {
fetchTerminals.mockRejectedValueOnce(new Error('transport lost'))
}
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledExactlyOnceWith(expectedRecoveryInventoryOptions())
}
)
it.each(['background', 'blur', 'disconnect', 'socket-loss', 'unmount'] as const)(
'cancels terminal inventory confirmation on %s',
async (lifecycleChange) => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
if (lifecycleChange === 'background') {
await setAppState('background')
} else if (lifecycleChange === 'blur') {
lifecycle.focused = false
await act(async () => {
renderer?.update(createElement(Harness))
await flush()
})
} else if (lifecycleChange === 'disconnect') {
connectionState = 'disconnected'
await act(async () => {
renderer?.update(createElement(Harness))
await flush()
})
} else if (lifecycleChange === 'socket-loss') {
clientConnectionState = 'disconnected'
} else {
await act(async () => {
renderer?.unmount()
await flush()
})
renderer = null
}
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
}
)
it('resumes a pending terminal confirmation after returning to the foreground', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await setAppState('background')
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await setAppState('active')
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(4)
expect(fetchTerminals).toHaveBeenNthCalledWith(3, expectedRecoveryInventoryOptions())
expect(fetchTerminals).toHaveBeenNthCalledWith(4, expectedRecoveryInventoryOptions())
})
it('resumes pending terminal recovery after a silent socket reconnect', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
clientConnectionState = 'disconnected'
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
clientConnectionState = 'connected'
await act(async () => {
await vi.advanceTimersByTimeAsync(1_250)
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(3)
expect(fetchTerminals).toHaveBeenNthCalledWith(2, expectedRecoveryInventoryOptions())
expect(fetchTerminals).toHaveBeenNthCalledWith(3, expectedRecoveryInventoryOptions())
})
it('resumes a pending terminal confirmation after controller replacement', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
currentClient = replacementClient
await act(async () => {
renderer?.update(createElement(Harness))
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(3)
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(4)
expect(fetchTerminals).toHaveBeenLastCalledWith(expectedRecoveryInventoryOptions())
})
it('drops pending terminal confirmation when the route identity changes', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
fetchTerminals.mockClear()
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
currentWorktreeId = 'repo::other-worktree'
currentTerminalInventoryRecoveryScopeKey = 'host::repo::other-worktree'
await act(async () => {
renderer?.update(createElement(Harness))
await flush()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
expect(fetchTerminals).toHaveBeenLastCalledWith(
expect.objectContaining({ onPhysicalRequestStarted: expect.any(Function) })
)
})
it('starts replacement-tab polling after confirmed terminal inventory absence', async () => {
await mount()
await emitStream({ type: 'updated', snapshotVersion: 1, tabs: ['tab-1'] })
sendRequest.mockClear()
fetchTerminals.mockClear()
clearRecoveryAt = 4_000
let successfulEmptyInventories = 0
let terminalPruned = false
fetchTerminals.mockImplementation(async () => {
successfulEmptyInventories += 1
if (successfulEmptyInventories === 2) {
terminalPruned = true
recoveryNeeded = true
}
return true
})
await act(async () => {
requestTerminalInventoryRecovery?.()
await flush()
})
expect(terminalPruned).toBe(false)
await act(async () => {
await vi.advanceTimersByTimeAsync(750)
})
expect(successfulEmptyInventories).toBe(2)
expect(terminalPruned).toBe(true)
await act(async () => {
await vi.advanceTimersByTimeAsync(5_250)
})
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(recoveryNeeded).toBe(false)
})
it('runs an immediate list plus five fallback lists over ten probing seconds', async () => {
@@ -262,7 +571,7 @@ describe('useMobileSessionTabsReconciliation', () => {
})
expect(sendRequest).toHaveBeenCalledTimes(5)
expect(fetchTerminals).toHaveBeenCalledTimes(6)
expect(fetchTerminals).toHaveBeenCalledTimes(5)
expect(recoveryNeeded).toBe(false)
})
@@ -9,6 +9,10 @@ import {
type SessionTabsStreamSource
} from './mobile-session-tabs-stream-health'
import { PendingTerminalHandleRecoveryBudget } from './pending-terminal-handle-recovery'
import {
useMobileTerminalInventoryRecovery,
type MobileTerminalInventoryRefreshOptions
} from './use-mobile-terminal-inventory-recovery'
type Params<Result, Tab> = {
client: RpcClient | null
@@ -20,7 +24,8 @@ type Params<Result, Tab> = {
effectiveTabs: readonly Tab[],
source: SessionTabsStreamSource
) => void
fetchTerminals: () => Promise<void>
fetchTerminals: (options?: MobileTerminalInventoryRefreshOptions) => Promise<boolean>
terminalInventoryRecoveryScopeKey: string
hasRecoveryNeed: () => boolean
pendingTerminalRecoveryContextKey?: string | null
getPendingTerminalRecoveryContextKey?: () => string | null
@@ -37,9 +42,11 @@ type ResultActions = {
ensureSessionTabs: () => Promise<void>
fetchPendingBrowserSessionTabs: () => Promise<void>
retryPendingTerminalRecovery: () => Promise<void>
requestTerminalInventoryRecovery: () => void
}
const resolved = Promise.resolve()
const RECONCILIATION_INTERVAL_MS = 2000
export function useMobileSessionTabsReconciliation<Result, Tab>({
client,
@@ -48,6 +55,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
applySessionTabs,
consumeAcceptedSessionTabs,
fetchTerminals,
terminalInventoryRecoveryScopeKey,
hasRecoveryNeed,
pendingTerminalRecoveryContextKey,
getPendingTerminalRecoveryContextKey,
@@ -122,11 +130,27 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
]
)
const {
activateTerminalInventoryRecovery,
isCertifiedTerminalSweepDue,
refreshTerminalInventory,
requestTerminalInventoryRecovery,
resetCertifiedTerminalSweep,
resumePendingTerminalInventoryRecovery,
suspendTerminalInventoryRecovery
} = useMobileTerminalInventoryRecovery({
client,
connState,
fetchTerminals,
scopeKey: terminalInventoryRecoveryScopeKey
})
useEffect(
() => () => {
suspendTerminalInventoryRecovery(true)
controller?.dispose()
},
[controller]
[controller, suspendTerminalInventoryRecovery]
)
useEffect(() => {
@@ -149,38 +173,57 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
useFocusEffect(
useCallback(() => {
if (!controller || connState !== 'connected') {
suspendTerminalInventoryRecovery(true)
return
}
activateTerminalInventoryRecovery()
resetCertifiedTerminalSweep()
const refresh = (forceTabs: boolean): void => {
if (AppState.currentState !== 'active') {
suspendTerminalInventoryRecovery(true)
controller.setReconciliationActive(false)
return
}
activateTerminalInventoryRecovery()
controller.setReconciliationActive(true)
if (forceTabs) {
void controller.requestReconciliation()
} else {
void controller.poll()
const tabsRequest = forceTabs ? controller.requestReconciliation() : controller.poll()
const now = Date.now()
// Why: healthy tab streams own liveness; retain only a slow inventory sweep for stale handles and metadata.
if (forceTabs || tabsRequest !== null || isCertifiedTerminalSweepDue(now)) {
void refreshTerminalInventory()
}
void fetchTerminals()
resumePendingTerminalInventoryRecovery()
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
activateTerminalInventoryRecovery()
resetPendingTerminalRecovery()
refresh(true)
} else {
suspendTerminalInventoryRecovery(true)
controller.setReconciliationActive(false)
}
})
const interval = setInterval(() => refresh(false), 2000)
const interval = setInterval(() => refresh(false), RECONCILIATION_INTERVAL_MS)
resetPendingTerminalRecovery()
refresh(true)
return () => {
suspendTerminalInventoryRecovery(true)
controller.setReconciliationActive(false)
clearInterval(interval)
appStateSubscription.remove()
}
}, [connState, controller, fetchTerminals, resetPendingTerminalRecovery])
}, [
activateTerminalInventoryRecovery,
connState,
controller,
isCertifiedTerminalSweepDue,
refreshTerminalInventory,
resetPendingTerminalRecovery,
resetCertifiedTerminalSweep,
resumePendingTerminalInventoryRecovery,
suspendTerminalInventoryRecovery
])
)
return {
@@ -199,6 +242,7 @@ export function useMobileSessionTabsReconciliation<Result, Tab>({
retryPendingTerminalRecovery: useCallback(() => {
resetPendingTerminalRecovery()
return controller?.retryReconciliation() ?? resolved
}, [controller, resetPendingTerminalRecovery])
}, [controller, resetPendingTerminalRecovery]),
requestTerminalInventoryRecovery
}
}
@@ -0,0 +1,322 @@
import { createElement, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
useMobileTerminalInventoryRecovery,
useMobileTerminalInventoryRecoveryBridge
} from './use-mobile-terminal-inventory-recovery'
const lifecycle = vi.hoisted(() => ({ appState: 'active' }))
vi.mock('react-native', () => ({
AppState: {
get currentState() {
return lifecycle.appState
}
}
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, reject, resolve }
}
const client = {
getState: () => 'connected'
} as unknown as RpcClient
const fetchTerminals = vi.fn(async () => true)
type RecoveryActions = ReturnType<typeof useMobileTerminalInventoryRecovery>
let actions: RecoveryActions | null = null
function Harness({ scopeKey }: { scopeKey: string }): null {
const recovery = useMobileTerminalInventoryRecovery({
client,
connState: 'connected',
fetchTerminals,
scopeKey
})
useEffect(() => {
actions = recovery
return () => {
if (actions === recovery) {
actions = null
}
}
}, [recovery])
return null
}
function BridgeHarness({
scopeKey,
connect,
request
}: {
scopeKey: string
connect: boolean
request: () => void
}): null {
const bridge = useMobileTerminalInventoryRecoveryBridge(scopeKey)
useEffect(() => {
bridgeSignal = bridge.signalTerminalInventoryRecovery
if (!connect) {
return () => {
if (bridgeSignal === bridge.signalTerminalInventoryRecovery) {
bridgeSignal = null
}
}
}
return bridge.registerTerminalInventoryRecoveryAction(request)
}, [bridge, connect, request])
return null
}
let bridgeSignal: (() => void) | null = null
async function flush(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
describe('useMobileTerminalInventoryRecovery', () => {
let renderer: ReactTestRenderer | null = null
let bridgeRenderer: ReactTestRenderer | null = null
async function mount(scopeKey = 'host::worktree-a'): Promise<void> {
await act(async () => {
renderer = create(createElement(Harness, { scopeKey }))
await flush()
})
act(() => actions?.activateTerminalInventoryRecovery())
}
beforeEach(() => {
vi.useFakeTimers()
lifecycle.appState = 'active'
fetchTerminals.mockReset()
fetchTerminals.mockResolvedValue(true)
bridgeSignal = null
})
afterEach(() => {
act(() => renderer?.unmount())
act(() => bridgeRenderer?.unmount())
renderer = null
bridgeRenderer = null
actions = null
bridgeSignal = null
vi.useRealTimers()
})
it('queues a bridge signal until the recovery action connects', async () => {
const request = vi.fn()
await act(async () => {
bridgeRenderer = create(
createElement(BridgeHarness, { scopeKey: 'scope-a', connect: false, request })
)
await flush()
})
const preConnectSignal = bridgeSignal
expect(preConnectSignal).toEqual(expect.any(Function))
act(() => preConnectSignal?.())
expect(request).not.toHaveBeenCalled()
await act(async () => {
bridgeRenderer?.update(
createElement(BridgeHarness, { scopeKey: 'scope-a', connect: true, request })
)
await flush()
})
expect(request).toHaveBeenCalledExactlyOnceWith()
})
it('ignores a stale bridge signal after the committed scope changes', async () => {
const requestA = vi.fn()
const requestB = vi.fn()
await act(async () => {
bridgeRenderer = create(
createElement(BridgeHarness, { scopeKey: 'scope-a', connect: true, request: requestA })
)
await flush()
})
const staleSignal = bridgeSignal
await act(async () => {
bridgeRenderer?.update(
createElement(BridgeHarness, { scopeKey: 'scope-b', connect: true, request: requestB })
)
await flush()
})
act(() => staleSignal?.())
expect(requestA).not.toHaveBeenCalled()
expect(requestB).not.toHaveBeenCalled()
act(() => bridgeSignal?.())
expect(requestB).toHaveBeenCalledExactlyOnceWith()
})
it('coalesces signals before confirmation into the scheduled pass', async () => {
const firstPass = deferred<boolean>()
const confirmation = deferred<boolean>()
fetchTerminals
.mockImplementationOnce(() => firstPass.promise)
.mockImplementationOnce(() => confirmation.promise)
await mount()
act(() => {
actions?.requestTerminalInventoryRecovery()
actions?.requestTerminalInventoryRecovery()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
await act(async () => {
firstPass.resolve(true)
await flush()
actions?.requestTerminalInventoryRecovery()
await vi.advanceTimersByTimeAsync(750)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
await act(async () => {
confirmation.resolve(true)
await flush()
await vi.advanceTimersByTimeAsync(10_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
})
it.each(['failure', 'rejection'] as const)(
'retries one queued signal after a first-pass %s',
async (outcome) => {
const firstPass = deferred<boolean>()
fetchTerminals.mockImplementationOnce(() => firstPass.promise).mockResolvedValueOnce(false)
await mount()
act(() => {
actions?.requestTerminalInventoryRecovery()
actions?.requestTerminalInventoryRecovery()
})
await act(async () => {
if (outcome === 'failure') {
firstPass.resolve(false)
} else {
firstPass.reject(new Error('transport lost'))
}
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(2)
}
)
it('runs one follow-up cycle for signals received during confirmation', async () => {
const confirmation = deferred<boolean>()
fetchTerminals
.mockResolvedValueOnce(true)
.mockImplementationOnce(() => confirmation.promise)
.mockResolvedValueOnce(false)
await mount()
act(() => actions?.requestTerminalInventoryRecovery())
await act(async () => {
await flush()
await vi.advanceTimersByTimeAsync(750)
actions?.requestTerminalInventoryRecovery()
actions?.requestTerminalInventoryRecovery()
confirmation.resolve(false)
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(3)
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000)
})
expect(fetchTerminals).toHaveBeenCalledTimes(3)
})
it('retains one cycle across lifecycle suspension and fences the old pass', async () => {
const oldPass = deferred<boolean>()
fetchTerminals.mockImplementationOnce(() => oldPass.promise).mockResolvedValueOnce(false)
await mount()
act(() => {
actions?.requestTerminalInventoryRecovery()
actions?.suspendTerminalInventoryRecovery(true)
})
await act(async () => {
oldPass.resolve(false)
await flush()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
act(() => {
actions?.activateTerminalInventoryRecovery()
actions?.resumePendingTerminalInventoryRecovery()
})
await act(flush)
expect(fetchTerminals).toHaveBeenCalledTimes(2)
})
it('drops queued work when the recovery scope changes', async () => {
const oldPass = deferred<boolean>()
fetchTerminals.mockImplementationOnce(() => oldPass.promise)
await mount()
act(() => {
actions?.requestTerminalInventoryRecovery()
actions?.requestTerminalInventoryRecovery()
})
await act(async () => {
renderer?.update(createElement(Harness, { scopeKey: 'host::worktree-b' }))
})
await act(async () => {
oldPass.resolve(false)
await flush()
})
act(() => {
actions?.activateTerminalInventoryRecovery()
actions?.resumePendingTerminalInventoryRecovery()
})
expect(fetchTerminals).toHaveBeenCalledTimes(1)
})
it('does not let an old inventory completion move the new scope deadline', async () => {
const oldPass = deferred<boolean>()
let reportPhysicalStart: ((startedAt: number) => void) | undefined
fetchTerminals.mockImplementationOnce(async (options) => {
reportPhysicalStart = options?.onPhysicalRequestStarted
return oldPass.promise
})
await mount('scope-a')
vi.setSystemTime(100)
const oldRefresh = actions?.refreshTerminalInventory
expect(oldRefresh).toEqual(expect.any(Function))
const oldRequest = oldRefresh!()
await flush()
reportPhysicalStart?.(100)
expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(false)
await act(async () => {
renderer?.update(createElement(Harness, { scopeKey: 'scope-b' }))
await flush()
})
expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(true)
reportPhysicalStart?.(100)
oldPass.resolve(true)
await oldRequest
await flush()
expect(actions?.isCertifiedTerminalSweepDue(100)).toBe(true)
})
})
@@ -0,0 +1,248 @@
import { useCallback, useEffect, useRef } from 'react'
import { AppState } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
export type MobileTerminalInventoryRefreshOptions = {
allowEmptyLoaded?: boolean
onPhysicalRequestStarted?: (startedAt: number) => void
}
type MobileTerminalInventoryRecoveryAction = {
request: () => void
scope: string
}
/** Bridges terminal stream callbacks to the committed route's recovery action. */
export function useMobileTerminalInventoryRecoveryBridge(scopeKey: string) {
const committedScopeRef = useRef<string | null>(null)
const actionRef = useRef<MobileTerminalInventoryRecoveryAction | null>(null)
const pendingSignalScopeRef = useRef<string | null>(null)
useEffect(() => {
committedScopeRef.current = scopeKey
return () => {
if (committedScopeRef.current === scopeKey) {
committedScopeRef.current = null
}
}
}, [scopeKey])
const signalTerminalInventoryRecovery = useCallback(() => {
const committedScope = committedScopeRef.current
if (committedScope !== null && committedScope !== scopeKey) {
return
}
const recoveryAction = actionRef.current
if (recoveryAction?.scope === scopeKey) {
recoveryAction.request()
return
}
pendingSignalScopeRef.current = scopeKey
}, [scopeKey])
const registerTerminalInventoryRecoveryAction = useCallback(
(request: () => void): (() => void) => {
const recoveryAction = { request, scope: scopeKey }
actionRef.current = recoveryAction
const pendingScope = pendingSignalScopeRef.current
pendingSignalScopeRef.current = null
if (pendingScope === scopeKey) {
request()
}
return () => {
if (actionRef.current === recoveryAction) {
actionRef.current = null
}
}
},
[scopeKey]
)
return {
registerTerminalInventoryRecoveryAction,
signalTerminalInventoryRecovery
}
}
type Params = {
client: RpcClient | null
connState: ConnectionState
fetchTerminals: (options?: MobileTerminalInventoryRefreshOptions) => Promise<boolean>
scopeKey: string
}
type RecoveryState = {
active: boolean
generation: number
pending: boolean
phase: 'idle' | 'first-pass' | 'confirmation-wait' | 'confirmation-pass'
timer: ReturnType<typeof setTimeout> | null
}
const CERTIFIED_TERMINAL_SWEEP_MS = 60_000
const TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS = 750
export function useMobileTerminalInventoryRecovery({
client,
connState,
fetchTerminals,
scopeKey
}: Params) {
const stateRef = useRef<RecoveryState>({
active: false,
generation: 0,
pending: false,
phase: 'idle',
timer: null
})
const lastAttemptAtRef = useRef(Number.NEGATIVE_INFINITY)
const refreshTerminalInventory = useCallback(
async (options?: MobileTerminalInventoryRefreshOptions): Promise<boolean> => {
const logicalStartedAt = Date.now()
const generation = stateRef.current.generation
let physicalStartReported = false
try {
return await fetchTerminals({
...options,
onPhysicalRequestStarted: (startedAt) => {
physicalStartReported = true
if (stateRef.current.generation === generation) {
lastAttemptAtRef.current = startedAt
}
}
})
} finally {
if (!physicalStartReported && stateRef.current.generation === generation) {
lastAttemptAtRef.current = logicalStartedAt
}
}
},
[fetchTerminals]
)
const isCertifiedTerminalSweepDue = useCallback((now: number): boolean => {
const elapsed = now - lastAttemptAtRef.current
return elapsed < 0 || elapsed >= CERTIFIED_TERMINAL_SWEEP_MS
}, [])
const resetCertifiedTerminalSweep = useCallback(() => {
lastAttemptAtRef.current = Number.NEGATIVE_INFINITY
}, [])
const canRun = useCallback(
() =>
stateRef.current.active &&
AppState.currentState === 'active' &&
connState === 'connected' &&
(client?.getState?.() ?? connState) === 'connected',
[client, connState]
)
const suspendTerminalInventoryRecovery = useCallback((retainPending: boolean): void => {
const state = stateRef.current
state.active = false
state.generation += 1
state.pending = retainPending && (state.pending || state.phase !== 'idle')
state.phase = 'idle'
if (state.timer !== null) {
clearTimeout(state.timer)
state.timer = null
}
}, [])
const activateTerminalInventoryRecovery = useCallback(() => {
stateRef.current.active = true
}, [])
const requestTerminalInventoryRecovery = useCallback((): void => {
const state = stateRef.current
if (!canRun()) {
state.pending = true
return
}
if (state.phase === 'first-pass' || state.phase === 'confirmation-pass') {
state.pending = true
return
}
if (state.phase === 'confirmation-wait') {
return
}
const startCycle = (): void => {
state.pending = false
state.phase = 'first-pass'
const generation = state.generation
const finishPass = (): void => {
state.phase = 'idle'
if (state.pending && canRun()) {
startCycle()
}
}
void (async () => {
let firstPassSucceeded = false
try {
firstPassSucceeded = await refreshTerminalInventory({ allowEmptyLoaded: true })
} catch {
// A failed inventory is unverifiable.
}
if (generation !== state.generation) {
return
}
if (!firstPassSucceeded) {
finishPass()
return
}
// The confirmation pass also satisfies signals received during the first pass.
state.pending = false
if (!canRun()) {
state.pending = true
state.phase = 'idle'
return
}
state.phase = 'confirmation-wait'
state.timer = setTimeout(() => {
state.timer = null
if (generation !== state.generation) {
return
}
if (!canRun()) {
state.pending = true
state.phase = 'idle'
return
}
state.phase = 'confirmation-pass'
void refreshTerminalInventory({ allowEmptyLoaded: true })
.catch(() => {
// A transport failure cannot confirm terminal absence.
})
.finally(() => {
if (generation === state.generation) {
finishPass()
}
})
}, TERMINAL_INVENTORY_CONFIRMATION_DELAY_MS)
})()
}
startCycle()
}, [canRun, refreshTerminalInventory])
const resumePendingTerminalInventoryRecovery = useCallback(() => {
if (stateRef.current.pending) {
requestTerminalInventoryRecovery()
}
}, [requestTerminalInventoryRecovery])
useEffect(
() => () => {
suspendTerminalInventoryRecovery(false)
lastAttemptAtRef.current = Number.NEGATIVE_INFINITY
},
[scopeKey, suspendTerminalInventoryRecovery]
)
return {
activateTerminalInventoryRecovery,
isCertifiedTerminalSweepDue,
refreshTerminalInventory,
requestTerminalInventoryRecovery,
resetCertifiedTerminalSweep,
resumePendingTerminalInventoryRecovery,
suspendTerminalInventoryRecovery
}
}
@@ -4,14 +4,12 @@ import { openAuthenticatedDirectEndpoint } from './mobile-direct-endpoint-probe'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
class FakeClient implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
}))
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
@@ -41,14 +41,12 @@ vi.mock('./e2ee', () => ({
}))
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
})
)
readonly sendRequest = vi.fn(async (): Promise<RpcResponse> => ({
id: 'rpc-1',
ok: true,
result: {},
_meta: { runtimeId: 'runtime-1' }
}))
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly notifyForeground = vi.fn()
@@ -1,7 +1,10 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/index.tsx', import.meta.url), 'utf8')
const source = readFileSync(
new URL('../host-screen/use-host-worktree-actions.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -15,7 +18,7 @@ describe('mobile worktree activation', () => {
it('opens mobile sessions without foregrounding other paired clients', () => {
const openSession = sliceBetween(
'const openWorktreeSession = useCallback(',
'const handleSortChange = useCallback'
'const openFloatingWorkspace = useCallback'
)
expect(openSession).toContain("sendRequest('worktree.activate'")

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