mirror of
https://github.com/stablyai/orca.git
synced 2026-10-03 00:02:19 +00:00
Merge commit 'ab8234fb38d6b8171ae07d4000a0a32dddac2f1a' into HEAD
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 +0,0 @@
|
||||
minimum-release-age=4320
|
||||
@@ -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": {
|
||||
|
||||
+8
-1408
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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.
|
||||
*/
|
||||
Generated
+1460
-1189
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user