Merge origin/main: combine async cold-restore reads with bounded history file limits

Semantic resolutions where #10179 bounded the same reads this PR made
asynchronous:
- add async variants of the bounded terminal-history readers and use them
  for checkpoint.json, output.log, and legacy scrollback.bin so the reads
  are both off-thread and size/JSON-structure capped
- keep the header-only stat bypass ahead of the replay semaphore; the
  bounded log read now rejects oversized logs inside the slot
- daemon-pty-adapter keeps the shared ColdRestorePayload type from
  cold-restore-payload-cache alongside main's PtyProcessListAdmission
- await the now-async detectColdRestore in main's history-reader-memory
  tests
- extract the legacy scrollback restore path into
  terminal-history-legacy-scrollback-restore.ts to stay within max-lines
This commit is contained in:
Brennan Benson
2026-07-23 16:21:22 -07:00
2248 changed files with 147639 additions and 22804 deletions
+4
View File
@@ -117,6 +117,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
@@ -158,6 +159,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
linux:
@@ -185,6 +187,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
@@ -204,4 +207,5 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
- run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
required: false
default: false
type: boolean
+8
View File
@@ -30,6 +30,11 @@ jobs:
with:
node-version-file: package.json
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
@@ -56,6 +61,9 @@ jobs:
- name: Test
run: pnpm test
- name: Test iOS release version resolution
run: ruby fastlane/ios_release_version_test.rb
- name: Lint
run: pnpm lint
+2 -1
View File
@@ -1330,7 +1330,8 @@ jobs:
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
@@ -271,7 +271,7 @@ jobs:
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
+1
View File
@@ -92,6 +92,7 @@ docs/**
!docs/reference/
!docs/reference/git-compatibility.md
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -27,6 +27,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
+1 -1
View File
@@ -238,7 +238,7 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Groups 1 and 2 are both full — now you can join the third one.
- **WeChat:** All other groups are full, now we're on group 5.
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
+7
View File
@@ -11,6 +11,7 @@ const {
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
@@ -133,6 +134,12 @@ module.exports = {
'node_modules/sherpa-onnx*/**'
],
afterPack: async (context) => {
// Why: a Linux runner-image glibc bump silently shipped a node-pty pty.node
// requiring GLIBC_2.34, crashing the app on startup on Ubuntu 20.04 (#9902).
// Fail packaging if any bundled native binary exceeds the supported floor.
if (context.electronPlatformName === 'linux') {
verifyLinuxGlibcFloor(context.appOutDir)
}
const resourcesDir =
context.electronPlatformName === 'darwin'
? join(
+80 -35
View File
@@ -1,5 +1,5 @@
diff --git a/binding.gyp b/binding.gyp
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc3500acacae 100644
index 5f63978b07ab50aaf7523219a2170ec737a6b5db..bbd9e06136e8922f40b5779e35d4fc835f1479ab 100644
--- a/binding.gyp
+++ b/binding.gyp
@@ -5,9 +5,6 @@
@@ -12,6 +12,23 @@ index 5f63978b07ab50aaf7523219a2170ec737a6b5db..b3309a07ef99dea7967d7bdd04b9fc35
'msvs_settings': {
'VCCLCompilerTool': {
'AdditionalOptions': [
@@ -88,6 +85,16 @@
'libraries!': [
'-lutil'
]
+ }],
+ # Orca: pair with the .symver pins in pty.cc. Force the real
+ # libutil.so.1/libpthread.so.0 into DT_NEEDED (gcc's default
+ # --as-needed drops them because the pinned symbols resolve from
+ # libc's compat aliases at build time) so openpty/forkpty/
+ # pthread_sigmask still resolve on Ubuntu 20.04 (glibc 2.31).
+ ['OS=="linux"', {
+ 'ldflags': [
+ '-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0,--as-needed'
+ ]
}]
]
}
diff --git a/deps/winpty/src/winpty.gyp b/deps/winpty/src/winpty.gyp
index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8360130ef 100644
--- a/deps/winpty/src/winpty.gyp
@@ -54,6 +71,27 @@ index 1ac5758bedd8cf54f32280dea4e4aeb5afdee30d..e619813759c6f14694838bdfbd0ea5f8
'msvs_settings': {
# Specify this setting here to override a setting from somewhere
# else, such as node's common.gypi.
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index 8c4fca9022a6d6f015bca87f61625cde2278f428..0a01730616488119aa21ef441cf3c441e02a974c 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -10,7 +10,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
\ No newline at end of file
diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088bf1865877 100644
--- a/lib/unixTerminal.js
@@ -73,31 +111,12 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
var DEFAULT_FILE = 'sh';
var DEFAULT_NAME = 'xterm';
var DESTROY_SOCKET_TIMEOUT_MS = 200;
diff --git a/lib/conpty_console_list_agent.js b/lib/conpty_console_list_agent.js
index ccc111c9e03a4a661ccfd5d8e8f0ee699571b5dd..f92c6bef7d46dc35c941c87ef186aa46d8ed9c44 100644
--- a/lib/conpty_console_list_agent.js
+++ b/lib/conpty_console_list_agent.js
@@ -9,7 +9,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
-var consoleProcessList = getConsoleProcessList(shellPid);
+var consoleProcessList;
+try {
+ consoleProcessList = getConsoleProcessList(shellPid);
+}
+catch (_a) {
+ // Why: AttachConsole can fail after the shell exits; parent already has this fallback.
+ consoleProcessList = [shellPid];
+}
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
//# sourceMappingURL=conpty_console_list_agent.js.map
diff --git a/src/conpty_console_list_agent.ts b/src/conpty_console_list_agent.ts
index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc02d29c95 100644
index 181ccabbbe9c4948a9725fb1db907a68e9de01fc..67f31facf85562b67adbfbd04ce28ddd8eeb4a79 100644
--- a/src/conpty_console_list_agent.ts
+++ b/src/conpty_console_list_agent.ts
@@ -11,5 +11,11 @@ import { loadNativeModule } from './utils';
@@ -10,6 +10,12 @@ import { loadNativeModule } from './utils';
const getConsoleProcessList = loadNativeModule('conpty_console_list').module.getConsoleProcessList;
const shellPid = parseInt(process.argv[2], 10);
-const consoleProcessList = getConsoleProcessList(shellPid);
@@ -111,7 +130,7 @@ index f6a653893e0b9b548c514db29d75599538ee1acb..1d5400489f200ef0161ca687e672e1cc
process.send!({ consoleProcessList });
process.exit(0);
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..383df0c9c48355547c65e6c9bbba593d15c4dd44 100644
--- a/src/unix/pty.cc
+++ b/src/unix/pty.cc
@@ -23,7 +23,9 @@
@@ -124,7 +143,33 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#include <thread>
#include <sys/types.h>
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
@@ -47,6 +49,25 @@
#include <termios.h>
#endif
+/* Orca: glibc 2.32-2.34 relocated pthread_sigmask/openpty/forkpty into libc
+ * under new symbol versions, so building on a newer glibc produces references
+ * (GLIBC_2.32/2.34) absent on Ubuntu 20.04 (glibc 2.31) and the app fails to
+ * launch. Pin these to the pre-merge version glibc still ships as a compat
+ * alias; the binding.gyp ldflags force libutil/libpthread into DT_NEEDED so
+ * those aliases are actually loaded on the target. */
+#if defined(__linux__)
+# if defined(__x86_64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.2.5"
+# elif defined(__aarch64__)
+# define ORCA_GLIBC_COMPAT_VERSION "GLIBC_2.17"
+# endif
+# ifdef ORCA_GLIBC_COMPAT_VERSION
+__asm__(".symver openpty,openpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver forkpty,forkpty@" ORCA_GLIBC_COMPAT_VERSION);
+__asm__(".symver pthread_sigmask,pthread_sigmask@" ORCA_GLIBC_COMPAT_VERSION);
+# endif
+#endif
+
/* Some platforms name VWERASE and VDISCARD differently */
#if !defined(VWERASE) && defined(VWERSE)
#define VWERASE VWERSE
@@ -237,13 +258,23 @@ pty_getproc(int, char *);
#endif
#if defined(__APPLE__) || defined(__OpenBSD__)
@@ -149,7 +194,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
#endif
struct DelBuf {
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
@@ -367,10 +398,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
argv[i + 3] = strdup(arg.c_str());
}
@@ -165,7 +210,7 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
}
if (pty_nonblock(master) == -1) {
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
@@ -684,15 +716,73 @@ pty_getproc(int fd, char *tty) {
#endif
#if defined(__APPLE__)
@@ -241,25 +286,25 @@ index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca85056
for (; count < 3; count++) {
low_fds[count] = posix_openpt(O_RDWR);
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
@@ -706,80 +796,118 @@ pty_posix_spawn(char** argv, char** env,
POSIX_SPAWN_SETSID;
*master = posix_openpt(O_RDWR);
if (*master == -1) {
- return;
+ pty_set_spawn_error(err, "posix_openpt", errno);
+ goto done;
+ }
+
+ res = grantpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
}
- int res = grantpt(*master) || unlockpt(*master);
+ res = unlockpt(*master);
+ res = grantpt(*master);
if (res == -1) {
- return;
+ pty_set_spawn_error(err, "grantpt", errno);
+ goto done;
+ }
+
+ res = unlockpt(*master);
+ if (res == -1) {
+ pty_set_spawn_error(err, "unlockpt", errno);
+ goto done;
}
+433 -32
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-20",
"updatedAt": "2026-07-22",
"policy": {
"maturityLevels": [
"experimental",
@@ -1387,7 +1387,10 @@
"surfaces": [
"remote agent launch and explicit resume",
"multi-client remote runtime sessions",
"headed desktop remote-server pairing",
"headless remote-server parity",
"daemon and relay reconnect",
"remote completion classification across disconnect and reconnect",
"terminal exit retirement and restart restore",
"mixed-version fallback"
],
@@ -1410,21 +1413,39 @@
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, and durable terminal retirement. The real repro runs two independent clients against one headless remote Orca runtime over the encrypted pairing path and a real daemon-backed PTY. SSH coverage is contract/fault-injection coverage; WSL and live SSH hosts remain gaps.",
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, guarded adoption of legacy live PTYs, and completion classification when either the outer remote transport or authoritative host/provider process inspection becomes unreachable. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. The primary headed topology was exercised with an isolated macOS Orca desktop server, a separate paired Edge client, a real Codex process, injected WebSocket loss, reconnect, and explicit stop; host inspection and visible client state agreed throughout. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, Linux, WSL, and SSH hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8878",
"https://github.com/stablyai/orca/issues/9151",
"https://github.com/stablyai/orca/issues/9352",
"https://github.com/stablyai/orca/pull/9687"
],
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, and old daemon/relay capabilities; assert safe adoption or explicit failure without a second spawn. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm test:repro:remote-agent-session"
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"pnpm test:repro:remote-agent-session",
"Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1",
"pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts"
],
"testFiles": [
"src/main/providers/pty-process-inspection.test.ts",
"src/main/daemon/terminal-host.test.ts",
"src/main/daemon/daemon-pty-router.test.ts",
"src/main/daemon/degraded-daemon-pty-provider.test.ts",
"src/relay/pty-handler.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"tests/e2e/remote-agent-completion-authority.unit.test.ts",
"src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
"src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/lib/codex-session-restart.test.ts",
"src/shared/claimed-agent-pty-owner.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
@@ -1434,9 +1455,71 @@
"src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts",
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts"
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/terminal-orphan-owner.test.ts",
"src/main/runtime/terminal-orphan-topology.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
"src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"completion-sensitive process inspection preserves authoritative host/provider failures"
]
},
{
"file": "src/main/providers/pty-process-inspection.test.ts",
"assertions": [
"dedicated provider inspection preserves failures and rejects missing PTYs instead of returning idle evidence"
]
},
{
"file": "src/main/daemon/daemon-pty-router.test.ts",
"assertions": [
"completion inspection rejects an unmapped session instead of borrowing the current daemon"
]
},
{
"file": "src/main/daemon/degraded-daemon-pty-provider.test.ts",
"assertions": [
"completion inspection rejects an unmapped session instead of borrowing the local fallback"
]
},
{
"file": "src/relay/pty-handler.test.ts",
"assertions": [
"strict relay inspection rejects a missing PTY"
]
},
{
"file": "tests/e2e/remote-agent-completion-authority.unit.test.ts",
"assertions": [
"transport loss remains unknown through reconnect and cannot dispatch completion",
"returned unavailability or a thrown transport failure interrupts consecutive-idle proof and requires two fresh authoritative idle samples",
"explicit stop, real exit status, and genuine successful completion remain distinct"
]
},
{
"file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
"assertions": [
"direct SSH terminals use strict main-process inspection rather than lax split IPC evidence"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"completion polling uses the atomic process-inspection boundary without regressing established lifecycle behavior"
]
},
{
"file": "src/renderer/src/lib/codex-session-restart.test.ts",
"assertions": [
"one unreachable pane cannot suppress restart notices for another authoritatively confirmed Codex pane"
]
},
{
"file": "src/shared/claimed-agent-pty-owner.test.ts",
"assertions": [
@@ -1485,9 +1568,60 @@
"only an exact structured-create handoff retires its provisional tab",
"an absent host tab retires its exact provisional handoff only after a causally post-create snapshot while unrelated tabs remain"
]
},
{
"file": "tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"assertions": [
"a durable host exit removes the terminal from two independent viewer mirrors instead of publishing a handle-less phantom",
"one exact exit produces one same-epoch higher-version host publication and one durable persistence flush",
"same-epoch stale publications cannot resurrect the retired surface after reconnect"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"v1.4.150-shaped agent, setup, and shell PTYs adopt as one CAS transaction while stale incarnation and competing clients fail safely",
"current-generation restart and disconnect/reconnect preserve output, input, resize, title, tab, leaf, handle, and incarnation identity",
"split-pane and multi-group legacy topology merges beside a newer host-owned terminal without replacing it",
"equivalent Windows and separator-normalized persisted worktree keys canonicalize without duplicate terminal topology",
"connection mismatch, reused handles, SSH ownership mismatch, and stale topology revisions cannot claim a live PTY while WSL ownership succeeds"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
"assertions": [
"absence stays pending until an exact live orphan adoption settles",
"client pane and group topology is pruned to exact orphan claims and translated to host tab identities",
"a missing split leaf remains recoverable when another leaf in the same tab is already host-owned"
]
},
{
"file": "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts",
"assertions": [
"mixed-version inventory without incarnation evidence remains visible but cannot adopt",
"a truncated legacy unfiltered inventory cannot hide a candidate whose liveness is unresolved"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "manual",
"platform": "macos",
"command": "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
"result": "passed",
"durationSeconds": 549,
"summary": "The primary user topology used an isolated headed Orca desktop as the owning server and a separate paired Edge client. Host inspection reported Codex alive before, during, and after a page-scoped WebSocket fault; the client showed no completion toast, reconnected to the same live Codex TUI, and explicit stop restored the shell prompt with no child process."
},
{
"date": "2026-07-23",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts",
"result": "passed",
"durationSeconds": 11.96,
"summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane."
},
{
"date": "2026-07-21",
"runner": "local",
@@ -1498,13 +1632,40 @@
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
},
{
"date": "2026-07-21",
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm test:repro:remote-agent-session",
"result": "passed",
"durationSeconds": 48.47,
"summary": "The build-backed headless remote Orca harness passed over encrypted WebSocket pairing with two independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
"durationSeconds": 48.63,
"summary": "The secondary build-backed headless parity harness passed post-rebase on main@72a2d7bc7 over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
},
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"result": "failed",
"durationSeconds": 3.11,
"summary": "The exact cross-boundary oracle failed on pre-#9687 commit 2a32c5c9a because the retired publication still contained the pinned persisted terminal surface."
},
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"result": "failed",
"durationSeconds": 3.48,
"summary": "The exact strengthened oracle failed on PR #9053 head d3a1d3047 because its stale-headless pruning retained the pinned persisted terminal surface."
},
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
"result": "passed",
"durationSeconds": 3.18,
"summary": "The same strengthened oracle passed on main@4fce2de49."
}
],
"runtimeBudget": {
@@ -1517,25 +1678,166 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The motivating remote-client duplicate-resume and exited-surface repros are encoded in deterministic lower-layer tests and the real remote harness; saved CI red/green artifacts are still needed."
"evidence": "Issue #9151 has local red/green evidence: current main dispatches process-exit from a stale remote handle, while the fix preserves unknown liveness and requires fresh consecutive idle evidence after reconnect. The exact retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
"evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
"evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Completion inspection reuses the coordinator's per-pane in-flight guard, global concurrency/rate queue, and existing error backoff; the strict daemon path reduces two foreground RPCs to one. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
},
"promotionCriteria": [
"Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.",
"Add live Linux/Windows and SSH/WSL provider evidence before claiming full platform/provider coverage."
"Automate the headed Orca desktop-server journey with a separate paired client; add a physical host only when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
"Add live SSH/WSL provider evidence before claiming full provider coverage; Docker SSH proves only the SSH provider/relay path."
],
"knownGaps": [
"The real remote-server harness currently runs on macOS and uses a local daemon-backed execution owner; Linux and Windows runs remain uncollected.",
"SSH and relay failure ordering is deterministic contract coverage, not a live SSH-host journey; WSL has no provider-specific run.",
"The primary headed macOS desktop-server journey is collected but not automated in CI; Windows and Linux window, ConPTY, update, sleep/wake, and firewall behavior remain uncollected.",
"Mixed-version pairings remain conservative only when the completion-aware client and strict-inspection host changes are both present; older peers retain their legacy classification behavior.",
"The secondary headless parity harness runs on macOS with a local daemon-backed execution owner and independent short-lived encrypted RPC clients; two persistent viewer-store mirrors and reconnect ordering are joined deterministically in the cross-boundary unit test rather than mounted live.",
"SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run, and Linux and Windows runs remain uncollected.",
"Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.",
"Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1."
],
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if a retry can physically spawn twice, if a stale exit/publication can replace or resurrect a terminal, or if mixed-version fallback occurs after an authority side effect."
},
{
"id": "runtime-routing.active-server-preference",
"title": "Active Server changes only through its explicit Advanced control",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-routing",
"layer": "main-preload-renderer-persistence-contract",
"surfaces": [
"Advanced Active Server setting",
"saved server Connect and Disconnect",
"remote workspace navigation",
"terminal reveal and create",
"browser and mobile handoff",
"app restart"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"remote-runtime",
"ssh",
"wsl"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"remote-runtime",
"ssh",
"wsl"
],
"coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/9687"
],
"invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.",
"oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1"
],
"testFiles": [
"src/main/ipc/settings.test.ts",
"src/main/ipc/runtime-environments.test.ts",
"src/renderer/src/store/slices/settings.test.ts",
"src/renderer/src/store/slices/browser.test.ts",
"src/renderer/src/components/settings/browser-session-host-selection.test.ts",
"src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
"src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
"src/renderer/src/hooks/useIpcEvents.test.ts",
"src/renderer/src/web/web-preload-api.test.ts"
],
"assertionRefs": [
{
"file": "src/main/ipc/settings.test.ts",
"assertions": [
"generic settings IPC strips activeRuntimeEnvironmentId while the dedicated validated IPC persists it",
"invalid preference types and unknown server identities cannot mutate the durable preference"
]
},
{
"file": "src/renderer/src/hooks/useIpcEvents.test.ts",
"assertions": [
"Local desktop remains the durable default after transient Windows 2 navigation and a focused local terminal reveal succeeds",
"local and remote terminal create route by target workspace ownership instead of the durable preference"
]
},
{
"file": "src/renderer/src/store/slices/browser.test.ts",
"assertions": [
"multiple clients select different transient browser hosts without changing Active Server",
"restart clears transient browser host override while retaining the durable local preference",
"late profile and import results update only their captured host and cannot overwrite a newer selection"
]
},
{
"file": "src/renderer/src/components/settings/browser-session-host-selection.test.ts",
"assertions": [
"a removed transient server override falls back to an available host instead of leaving browser settings on an invalid option"
]
},
{
"file": "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
"assertions": [
"connection status and the Advanced default-host selection are distinct concepts"
]
},
{
"file": "src/renderer/src/web/web-preload-api.test.ts",
"assertions": [
"generic web settings writes cannot mutate Active Server",
"the dedicated web preference setter rejects unknown server identities without corrupting the saved choice"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
"result": "passed",
"durationSeconds": 6.55,
"summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "focused persistence and routing contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate with no soak history."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached."
},
"performanceBudget": {
"required": false,
"evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes."
},
"promotionCriteria": [
"Run the focused gate in soak across macOS, Linux, and Windows.",
"Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.",
"Attach saved red/green evidence for generic settings mutation and transient connection routing."
],
"knownGaps": [
"The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.",
"Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session."
],
"demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart."
},
{
"id": "terminal-geometry.visible-convergence",
"title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size",
@@ -2350,7 +2652,7 @@
"id": "terminal-performance.no-hot-list-sessions",
"title": "Hot terminal interactions do not call global PTY session listing",
"maturity": "experimental",
"protection": "none",
"protection": "partial",
"owner": "terminal-performance",
"layer": "ipc-count-contract",
"surfaces": [
@@ -2374,19 +2676,74 @@
"wsl",
"remote-runtime"
],
"coveredPlatforms": [],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [],
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/7002",
"https://github.com/stablyai/orca/pull/6858"
"https://github.com/stablyai/orca/pull/6858",
"https://github.com/stablyai/orca/issues/9386",
"https://github.com/stablyai/orca/pull/9387"
],
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [],
"testFiles": [],
"assertionRefs": [],
"evidenceRuns": [],
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
"assertions": [
"the false-to-true workspace readiness transition performs one daemon inventory seed",
"a failed readiness seed surfaces an error and a later inventory refresh recovers",
"known-session reattach signals perform zero additional inventory reads",
"multiple unknown background spawn signals coalesce to one inventory read",
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
"unknown sessions that exit before reconciliation cancel their queued inventory read",
"unmount during a slow inventory read cannot schedule follow-up work",
"exit and out-of-order refresh races cannot resurrect stale sessions"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"global inventory starts local and SSH provider listings concurrently"
]
},
{
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
"assertions": [
"daemon inventory construction copies its source and preserves count parity",
"single and batch removals preserve unrelated sessions and no-op references"
]
},
{
"file": "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"assertions": [
"the closed inventory hook installs no interval",
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
"result": "passed",
"durationSeconds": 4.3,
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "unit or focused Electron count gate"
@@ -2397,7 +2754,7 @@
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
},
"performanceBudget": {
"required": true,
@@ -2409,8 +2766,8 @@
"Run with enough preserved sessions/providers to make a broad listing observable."
],
"knownGaps": [
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
"Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.",
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
],
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
@@ -4663,16 +5020,18 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
"oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.",
"oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
"ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
@@ -4680,6 +5039,12 @@
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/rpc/terminal-output-batching.test.ts",
"src/main/runtime/rpc/terminal-multiplex.test.ts",
"src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts",
"src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
"src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"src/renderer/src/runtime/runtime-terminal-stream.test.ts",
"mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
@@ -4704,7 +5069,33 @@
"assertions": [
"requested snapshots fall back smaller when serialized data exceeds the send budget",
"oversized live output frames are bounded for subscribed binary streams",
"multibyte live output flushes when encoded bytes reach the batch budget"
"multibyte live output flushes when encoded bytes reach the batch budget",
"adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin",
"send and recovery serialization failures detach once instead of leaking credit or retrying forever",
"32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter"
]
},
{
"file": "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
"assertions": [
"nested synchronous deliveries restore the outer credit owner",
"unclaimed intentional discards settle automatically while every claimed scheduler child must settle before the parent credits"
]
},
{
"file": "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
"assertions": [
"paired renderer ACK waits for xterm parse completion or explicit discard",
"192 KiB parsed output batches into one ACK while the 4 ms timer releases interactive output",
"malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output"
]
},
{
"file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"assertions": [
"one through eight viewers stay within the 8 MiB aggregate adaptive window",
"the 100 ms RTT model sustains more than 7 MiB/s per viewer with less than 200 ms fairness spread",
"the opt-in benchmark reports RTT throughput, scheduler CPU time, exact protocol frame allocations, completion spread, and measured @xterm/headless parser CPU and retained heap"
]
},
{
@@ -4741,6 +5132,15 @@
"result": "passed",
"durationSeconds": 0.2,
"summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree."
},
{
"date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"result": "passed",
"durationSeconds": 0.91,
"summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer."
}
],
"runtimeBudget": {
@@ -4757,7 +5157,7 @@
},
"performanceBudget": {
"required": true,
"evidence": "This gate is the byte and batching budget for runtime/mobile terminal streaming."
"evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers."
},
"promotionCriteria": [
"Gate binary multiplex first.",
@@ -4766,7 +5166,8 @@
],
"knownGaps": [
"The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.",
"Legacy JSON subscribe parity is undecided."
"Legacy JSON subscribe parity is undecided.",
"The parser measurement uses @xterm/headless; browser renderer/WebGL CPU, GPU, and allocation behavior still need packaged-app performance evidence."
],
"demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering."
},
@@ -177,7 +177,7 @@ describe('computer-use e2e workflow', () => {
)
})
it('builds Electron main output before every computer-use e2e run', () => {
it('builds Electron main output and verifies the CLI before every computer-use e2e run', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
@@ -187,6 +187,7 @@ describe('computer-use e2e workflow', () => {
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const cliVerifyIndex = runs.indexOf('pnpm verify:cli-runtime')
const e2eIndexes = runs
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
.filter((index) => index >= 0)
@@ -195,10 +196,17 @@ describe('computer-use e2e workflow', () => {
buildIndex,
`${jobName} should build out/main before computer e2e`
).toBeGreaterThanOrEqual(0)
expect(
cliVerifyIndex,
`${jobName} should verify the CLI after the main build`
).toBeGreaterThan(buildIndex)
for (const e2eIndex of e2eIndexes) {
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
e2eIndex
)
expect(cliVerifyIndex, `${jobName} should verify the CLI before computer e2e`).toBeLessThan(
e2eIndex
)
}
}
})
@@ -0,0 +1,18 @@
// Regenerate the electron-updater `.blockmap` for a (re-signed) Windows installer.
// electron-builder 26 dropped the app-builder-bin Go binary; blockmap generation
// now lives in app-builder-lib's pure-JS `buildBlockMap`. Mirrors createBlockmap's
// "gzip" format for the standalone NSIS installer blockmap.
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const [input, output] = process.argv.slice(2)
if (!input || !output) {
console.error('usage: generate-windows-blockmap.mjs <input.exe> <output.exe.blockmap>')
process.exit(1)
}
const { buildBlockMap } = require('app-builder-lib/out/targets/blockmap/blockmap')
const info = await buildBlockMap(input, 'gzip', output)
console.log(`blockmap written: ${output} (installer sha512=${info.sha512}, size=${info.size})`)
@@ -194,8 +194,21 @@ async function validateAuthenticatedPairing() {
status?._meta?.runtimeId === payload.runtimeId,
'paired client runtime ID does not match ready contract'
)
assert(
typeof statusResult?.runtime?.appVersion === 'string',
'paired server did not report its Orca app version'
)
assert(
statusResult?.runtime?.capabilities?.includes('updater.remote-control.v1'),
'paired server did not advertise remote updater capability'
)
assert(
statusResult?.runtime?.remoteUpdateSupport?.automatic === false &&
statusResult.runtime.remoteUpdateSupport.reason === 'manual-service-update-required',
'direct headless server did not require a safe manual service update'
)
stopContainer(server.name)
console.log('PASS authenticated E2EE pairing from an independent Debian container')
console.log('PASS paired E2EE updater inventory and manual-service fallback')
}
async function validateUnreachableOffer() {
@@ -0,0 +1,32 @@
import { spawnSync } from 'node:child_process'
const result = spawnSync(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'exec',
'playwright',
'test',
'tests/e2e/nested-runtime-ssh-routing.spec.ts',
'tests/e2e/nested-runtime-ssh-lifecycle.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1'
],
{
cwd: process.cwd(),
env: {
...process.env,
ORCA_E2E_NESTED_RUNTIME_SSH: '1',
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
},
stdio: 'inherit'
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
+8
View File
@@ -47,6 +47,14 @@ describe('verifyPackageCliBin', () => {
})
})
it('rejects a compiled CLI with a missing runtime dependency', () => {
const { projectDir } = makeProjectWithCli(
'#!/usr/bin/env node\nrequire("./missing-runtime-dependency")\n'
)
expect(() => verifyPackageCliBin({ projectDir, runHelp: true })).toThrow()
})
it('rejects an empty package bin target', () => {
const { projectDir } = makeProjectWithCli('')
+394
View File
@@ -0,0 +1,394 @@
const { readdirSync, openSync, readSync, closeSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join, relative } = require('node:path')
// Why: v1.4.150 shipped a Linux build whose node-pty pty.node required
// GLIBC_2.34 (openpty/forkpty were relocated into libc by glibc's
// libutil/libpthread merge), so the app crashed on startup on Ubuntu 20.04
// (glibc 2.31) — the runner image silently bumped the build-host glibc. This
// gate fails Linux packaging if any bundled native binary requires a glibc (or
// libstdc++) symbol version newer than stock Ubuntu 20.04 ships, so a future
// runner bump or dependency change cannot reintroduce the regression unnoticed.
// See docs/reference/linux-glibc-compatibility.md.
const MIN_GLIBC = Object.freeze([2, 31])
// The symbol-version families this gate checks, each with the highest version
// node stock Ubuntu 20.04 provides. glibc is the #9902 launch-crash axis;
// libstdc++ (GLIBCXX_/CXXABI_) is the same crash class for C++ native modules
// against the system libstdc++ (Orca does not bundle one).
const VERSION_FLOORS = Object.freeze([
Object.freeze({ prefix: 'GLIBC_', floor: MIN_GLIBC }),
Object.freeze({ prefix: 'GLIBCXX_', floor: Object.freeze([3, 4, 28]) }),
Object.freeze({ prefix: 'CXXABI_', floor: Object.freeze([1, 3, 12]) })
])
const FLOOR_LABEL = 'Ubuntu 20.04 (glibc 2.31 / libstdc++ GLIBCXX_3.4.28)'
// Why: the sherpa-onnx speech prebuilt is a third-party manylinux binary that
// already requires GLIBCXX_3.4.29 (GCC 11 / Ubuntu 21.10+, 22.04 LTS). It loads
// lazily in the speech worker (src/main/speech/stt-worker.ts), never at app
// launch, so it cannot cause the #9902 startup crash. Exempt it from the
// libstdc++ floor (its glibc is still gated) rather than fail the release on a
// pre-existing, non-launch condition — speech needs libstdc++ >= GCC 11.
const LIBSTDCXX_FLOOR_EXEMPT = /(?:^|[/\\])sherpa-onnx/
// VER_FLG_WEAK: a version need whose references are all weak. The loader
// tolerates its absence (resolves to null and the caller's fallback runs)
// instead of refusing to load, so a weak need must not count as a requirement.
const VER_FLG_WEAK = 0x2
/** Parse a "2.34" / "3.4.28" version string into a numeric tuple. */
function parseGlibcVersion(versionStr) {
return versionStr.split('.').map((part) => Number.parseInt(part, 10))
}
/** Compare two numeric version tuples; missing trailing parts are 0. */
function compareGlibcVersions(a, b) {
const length = Math.max(a.length, b.length)
for (let i = 0; i < length; i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0)
if (diff !== 0) {
return diff < 0 ? -1 : 1
}
}
return 0
}
/**
* Parse `objdump -p` "Version References" (the ELF `.gnu.version_r` section)
* into the version nodes this binary requires from each shared library. This is
* the authoritative load-time requirement list: unlike the dynamic symbol table
* (`objdump -T`), it also captures symbol-less ABI markers such as
* `GLIBC_ABI_DT_RELR` (packed relative relocations, glibc 2.36+) that still
* block loading on an older glibc. Each entry: `0xHASH 0xFLAGS <n> <NAME>`.
*/
function parseVersionNeeds(objdumpOutput) {
const needs = []
let library = null
let inSection = false
for (const line of objdumpOutput.split('\n')) {
if (line.startsWith('Version References:')) {
inSection = true
continue
}
if (!inSection) {
continue
}
// Any new non-indented line ends the Version References block.
if (!/^\s/.test(line)) {
inSection = false
continue
}
const libraryMatch = line.match(/^\s+required from (\S+):/)
if (libraryMatch) {
library = libraryMatch[1]
continue
}
const entryMatch = line.match(/^\s+0x[0-9a-fA-F]+\s+0x([0-9a-fA-F]+)\s+\d+\s+(\S+)/)
if (entryMatch) {
const flags = Number.parseInt(entryMatch[1], 16)
needs.push({ library, name: entryMatch[2], weak: (flags & VER_FLG_WEAK) !== 0 })
}
}
return needs
}
/**
* Whether a version node is newer than the floor Ubuntu 20.04 provides. Numeric
* nodes (`GLIBC_2.34`, `GLIBCXX_3.4.29`) compare by version. Any non-numeric
* glibc node is rejected: `GLIBC_ABI_DT_RELR` is a 2.36+ marker, and
* `GLIBC_PRIVATE` is not a stable ABI contract — its symbols differ across
* glibc releases, so a binary needing one can fail to load on the floor even
* though the version node itself exists (a well-formed addon needs neither).
* Named libstdc++ nodes (`CXXABI_TM_1`, `GLIBCXX_LDBL_*`) ship on 20.04.
* Families we do not gate (`GCC_`, `NSS_`) return false.
*/
function isVersionNodeAboveFloor(name) {
for (const { prefix, floor } of VERSION_FLOORS) {
if (!name.startsWith(prefix)) {
continue
}
const rest = name.slice(prefix.length)
if (/^[0-9]+(?:\.[0-9]+)*$/.test(rest)) {
return compareGlibcVersions(parseGlibcVersion(rest), floor) > 0
}
// Non-numeric suffix: reject every glibc node (ABI markers and PRIVATE).
return prefix === 'GLIBC_'
}
return false
}
function isLibstdcxxNode(name) {
return name.startsWith('GLIBCXX_') || name.startsWith('CXXABI_')
}
/**
* Version needs from `filePath` that would prevent loading on the floor OS.
* `sherpa-onnx` is exempt from the libstdc++ floor (see LIBSTDCXX_FLOOR_EXEMPT)
* but its glibc needs are still checked.
*/
function findFloorViolations(needs, filePath = '') {
const exemptLibstdcxx = LIBSTDCXX_FLOOR_EXEMPT.test(filePath)
return needs.filter(
(need) =>
!need.weak &&
isVersionNodeAboveFloor(need.name) &&
!(exemptLibstdcxx && isLibstdcxxNode(need.name))
)
}
// On stock Ubuntu 20.04 (glibc 2.31) these symbols live ONLY in these DSOs —
// glibc kept openpty/forkpty in libutil until the 2.34 merge. A binary that
// imports them must keep the DSO in DT_NEEDED or they will not resolve on the
// floor. This guards config/patches/node-pty@1.1.0.patch's forced
// `-l:libutil.so.1`: if a toolchain change ever dropped that ldflag, the pinned
// openpty@GLIBC_2.2.5 would still resolve from libc's compat alias at build time
// (so the version-floor check passes) yet fail to load on 20.04. libpthread
// (pthread_sigmask) is intentionally omitted — the Node/Electron host always
// loads it, so it resolves regardless of this addon's DT_NEEDED.
const RELOCATED_SYMBOL_PROVIDERS = Object.freeze({
openpty: 'libutil.so.1',
forkpty: 'libutil.so.1'
})
/**
* Relocated symbols the binary imports whose providing DSO is absent from
* DT_NEEDED — meaning they resolve at build time but not on the floor OS.
*/
function findMissingProviderDeps(importedSymbols, neededLibraries) {
const missing = []
for (const [symbol, library] of Object.entries(RELOCATED_SYMBOL_PROVIDERS)) {
if (importedSymbols.has(symbol) && !neededLibraries.has(library)) {
missing.push({ symbol, library })
}
}
return missing
}
function isElfFile(filePath) {
let fd
try {
fd = openSync(filePath, 'r')
const header = Buffer.alloc(4)
const bytesRead = readSync(fd, header, 0, 4, 0)
return bytesRead === 4 && header[0] === 0x7f && header.toString('latin1', 1, 4) === 'ELF'
} catch {
return false
} finally {
if (fd !== undefined) {
closeSync(fd)
}
}
}
/** Recursively collect ELF native binaries (`.node`, `.so[.N]`, executables). */
function collectNativeBinaries(rootDir) {
const binaries = []
const walk = (dir) => {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const fullPath = join(dir, entry.name)
if (entry.isSymbolicLink()) {
continue
}
if (entry.isDirectory()) {
walk(fullPath)
continue
}
if (!entry.isFile()) {
continue
}
// Why: .node/.so are always native; extensionless files (the Electron
// executable, chrome-sandbox) are checked via the ELF magic so we cover
// every launch-critical binary without objdump-ing app.asar or assets.
const looksNative = entry.name.endsWith('.node') || /\.so(\.\d+)*$/.test(entry.name)
if (looksNative || !entry.name.includes('.')) {
if (isElfFile(fullPath)) {
binaries.push(fullPath)
}
}
}
}
walk(rootDir)
return binaries.sort()
}
function resolveObjdump(explicitPath) {
const candidates = [explicitPath, 'objdump', 'llvm-objdump'].filter(Boolean)
for (const candidate of candidates) {
const probe = spawnSync(candidate, ['--version'], { encoding: 'utf8', env: cLocaleEnv() })
if (!probe.error && probe.status === 0) {
return candidate
}
}
return null
}
// Why: GNU objdump localizes its section headers ("Version References:") via
// gettext, and the parser anchors on the English text. Force the C locale so
// output stays deterministic on non-English packaging hosts (LC_ALL=C also
// disables LANGUAGE-based message translation).
function cLocaleEnv() {
return { ...process.env, LC_ALL: 'C', LANG: 'C' }
}
/**
* Run objdump with one flag on `filePath`. Fail-closed: a spawn error, non-zero
* exit, or signal throws, because a silently-unreadable binary (truncated,
* corrupt, or an objdump that cannot decode its format) would let a too-new
* binary slip past the gate.
*/
function runObjdump(objdumpPath, flag, filePath) {
const result = spawnSync(objdumpPath, [flag, filePath], {
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
env: cLocaleEnv()
})
if (result.error) {
throw new Error(
`[verify-linux-glibc-floor] could not run objdump on ${filePath}: ${result.error.message}`
)
}
if (result.signal || result.status !== 0) {
throw new Error(
`[verify-linux-glibc-floor] objdump ${flag} failed for ${filePath} ` +
`(status ${result.status}, signal ${result.signal ?? 'none'}): ${(result.stderr || '').trim()}`
)
}
return result.stdout || ''
}
/** DT_NEEDED shared-library names from `objdump -p` (` NEEDED <lib>`). */
function parseNeededLibraries(objdumpOutput) {
const needed = new Set()
for (const line of objdumpOutput.split('\n')) {
const match = line.match(/^\s+NEEDED\s+(\S+)/)
if (match) {
needed.add(match[1])
}
}
return needed
}
/** Undefined (imported) dynamic symbol base names from `objdump -T` (`*UND*`). */
function parseImportedSymbols(objdumpOutput) {
const imported = new Set()
for (const line of objdumpOutput.split('\n')) {
if (!line.includes('*UND*')) {
continue
}
// The symbol name is the final token; strip any @VERSION suffix.
const token = line.trim().split(/\s+/).pop()
if (token) {
imported.add(token.split('@')[0])
}
}
return imported
}
/** Version needs + DT_NEEDED from a single `objdump -p` (fail-closed). */
function readDynamicInfo(filePath, objdumpPath) {
const output = runObjdump(objdumpPath, '-p', filePath)
return {
versionNeeds: parseVersionNeeds(output),
neededLibraries: parseNeededLibraries(output)
}
}
/** Imported (undefined) dynamic symbols from `objdump -T` (fail-closed). */
function readImportedSymbols(filePath, objdumpPath) {
return parseImportedSymbols(runObjdump(objdumpPath, '-T', filePath))
}
/**
* Fail Linux packaging if any bundled native binary under `rootDir` requires a
* glibc/libstdc++ symbol version newer than the floor OS. No-op is not allowed
* on Linux: a missing objdump throws, because a silent skip would defeat the
* regression gate on exactly the host where it matters.
*/
function verifyLinuxGlibcFloor(rootDir, options = {}) {
const binaries = collectNativeBinaries(rootDir)
if (binaries.length === 0) {
console.log(`[verify-linux-glibc-floor] OK — no bundled native binaries under ${rootDir}`)
return
}
// Why: resolve objdump only once there is something to inspect, so a fixture
// with no ELF binaries does not fail on a host that lacks binutils.
const objdumpPath = resolveObjdump(options.objdumpPath)
if (!objdumpPath) {
throw new Error(
'[verify-linux-glibc-floor] objdump not found. Install binutils on the Linux ' +
'packaging host so the glibc-floor gate can inspect bundled native binaries.'
)
}
const offenders = []
for (const filePath of binaries) {
const { versionNeeds, neededLibraries } = readDynamicInfo(filePath, objdumpPath)
const floorViolations = findFloorViolations(versionNeeds, filePath)
// Only pay for `objdump -T` when a relocated-symbol provider is not already
// in DT_NEEDED (the common, healthy case short-circuits without it).
const providerViolations = Object.values(RELOCATED_SYMBOL_PROVIDERS).some(
(library) => !neededLibraries.has(library)
)
? findMissingProviderDeps(readImportedSymbols(filePath, objdumpPath), neededLibraries)
: []
if (floorViolations.length > 0 || providerViolations.length > 0) {
offenders.push({ filePath, floorViolations, providerViolations })
}
}
if (offenders.length > 0) {
const detail = offenders
.map(({ filePath, floorViolations, providerViolations }) => {
const reasons = []
if (floorViolations.length > 0) {
const nodes = [...new Set(floorViolations.map((v) => v.name))].sort()
const libraries = [...new Set(floorViolations.map((v) => v.library).filter(Boolean))]
reasons.push(
`needs ${nodes.join(', ')}${libraries.length > 0 ? ` (from ${libraries.join(', ')})` : ''}`
)
}
for (const { symbol, library } of providerViolations) {
reasons.push(`imports ${symbol} but ${library} is not in DT_NEEDED`)
}
return ` ${relative(rootDir, filePath) || filePath} ${reasons.join('; ')}`
})
.join('\n')
throw new Error(
`[verify-linux-glibc-floor] ${offenders.length} bundled native binar${offenders.length === 1 ? 'y' : 'ies'} ` +
`will not load on ${FLOOR_LABEL}, so the app will crash on startup there:\n${detail}\n` +
'See docs/reference/linux-glibc-compatibility.md — rebuild the offending module against an older ' +
'toolchain or pin the relocated symbols (as config/patches/node-pty@1.1.0.patch does).'
)
}
console.log(
`[verify-linux-glibc-floor] OK — ${binaries.length} bundled native binaries all load on ${FLOOR_LABEL}`
)
}
module.exports = {
MIN_GLIBC,
VERSION_FLOORS,
FLOOR_LABEL,
RELOCATED_SYMBOL_PROVIDERS,
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
isLibstdcxxNode,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
readDynamicInfo,
readImportedSymbols,
verifyLinuxGlibcFloor
}
@@ -0,0 +1,323 @@
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
parseGlibcVersion,
compareGlibcVersions,
parseVersionNeeds,
parseNeededLibraries,
parseImportedSymbols,
isVersionNodeAboveFloor,
findFloorViolations,
findMissingProviderDeps,
collectNativeBinaries,
verifyLinuxGlibcFloor
} = require('./verify-linux-glibc-floor.cjs')
// 0x7f 'E' 'L' 'F' + class/data/version padding — enough for the magic check.
const ELF_HEADER = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0x01, 0x01, 0x00])
// Real `objdump -p` "Version References" shape (entry: 0xHASH 0xFLAGS <n> NAME;
// flags 0x02 = VER_FLG_WEAK). Includes a symbol-less ABI marker, a weak need,
// and a libstdc++ need.
const OBJDUMP_P = [
'Dynamic Section:',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x09691a75 0x00 06 GLIBC_2.2.5',
' 0x069691b4 0x00 05 GLIBC_2.34',
' 0x0d696914 0x02 04 GLIBC_2.18',
' 0x00fd0e42 0x00 03 GLIBC_ABI_DT_RELR',
' required from libstdc++.so.6:',
' 0x0b481abc 0x00 07 GLIBCXX_3.4.29',
''
].join('\n')
describe('verify-linux-glibc-floor parsing', () => {
it('parses and compares numeric version tuples', () => {
expect(parseGlibcVersion('2.34')).toEqual([2, 34])
expect(parseGlibcVersion('3.4.28')).toEqual([3, 4, 28])
expect(compareGlibcVersions([2, 2, 5], [2, 14])).toBe(-1)
expect(compareGlibcVersions([2, 31], [2, 32])).toBe(-1)
expect(compareGlibcVersions([2, 34], [2, 31])).toBe(1)
expect(compareGlibcVersions([2, 31], [2, 31])).toBe(0)
expect(compareGlibcVersions([2, 31], [2, 31, 0])).toBe(0)
expect(compareGlibcVersions([3, 4, 29], [3, 4, 28])).toBe(1)
})
it('parses objdump -p Version References into per-library version needs', () => {
const needs = parseVersionNeeds(OBJDUMP_P)
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_ABI_DT_RELR', weak: false })
expect(needs).toContainEqual({ library: 'libc.so.6', name: 'GLIBC_2.18', weak: true })
expect(needs).toContainEqual({ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false })
})
it('classifies version nodes across glibc and libstdc++ families', () => {
expect(isVersionNodeAboveFloor('GLIBC_2.34')).toBe(true)
expect(isVersionNodeAboveFloor('GLIBC_2.31')).toBe(false)
expect(isVersionNodeAboveFloor('GLIBC_ABI_DT_RELR')).toBe(true) // symbol-less marker (2.36+)
// GLIBC_PRIVATE is not a stable ABI contract; a needed private symbol can be
// absent on the floor even though the version node exists — reject it.
expect(isVersionNodeAboveFloor('GLIBC_PRIVATE')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_TM_1')).toBe(false) // named libstdc++ node on 20.04
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.29')).toBe(true) // GCC 11, above 20.04's 3.4.28
expect(isVersionNodeAboveFloor('GLIBCXX_3.4.28')).toBe(false)
expect(isVersionNodeAboveFloor('CXXABI_1.3.13')).toBe(true)
expect(isVersionNodeAboveFloor('CXXABI_1.3.12')).toBe(false)
expect(isVersionNodeAboveFloor('GCC_3.0')).toBe(false) // family not gated
})
it('flags strong too-new glibc + libstdc++ needs, skipping weak and ungated families', () => {
const violations = findFloorViolations(parseVersionNeeds(OBJDUMP_P), '/opt/app/pty.node')
const names = violations.map((v) => v.name).sort()
// GLIBC_2.34, GLIBC_ABI_DT_RELR, GLIBCXX_3.4.29 fail; weak GLIBC_2.18 and
// GLIBC_2.2.5 are excluded.
expect(names).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34', 'GLIBC_ABI_DT_RELR'].sort())
})
it('exempts sherpa-onnx from the libstdc++ floor but still gates its glibc', () => {
const needs = [
{ library: 'libstdc++.so.6', name: 'GLIBCXX_3.4.29', weak: false },
{ library: 'libc.so.6', name: 'GLIBC_2.34', weak: false }
]
// A launch-critical module: both are violations.
expect(
findFloorViolations(needs, '/opt/app/node_modules/node-pty/pty.node').map((v) => v.name)
).toEqual(['GLIBCXX_3.4.29', 'GLIBC_2.34'])
// sherpa: GLIBCXX exempt (lazy speech prebuilt), glibc still enforced.
expect(
findFloorViolations(
needs,
'/opt/app/node_modules/sherpa-onnx-linux-x64/sherpa-onnx.node'
).map((v) => v.name)
).toEqual(['GLIBC_2.34'])
})
it('reports no violations when every strong need is at or below the floor', () => {
const needs = parseVersionNeeds(
[
'Version References:',
' required from libc.so.6:',
' 0x00 0x00 02 GLIBC_2.2.5',
' 0x00 0x00 03 GLIBC_2.28',
' required from libstdc++.so.6:',
' 0x00 0x00 04 GLIBCXX_3.4.22'
].join('\n')
)
expect(findFloorViolations(needs, '/opt/app/pty.node')).toEqual([])
})
})
describe('DT_NEEDED provider check', () => {
const OBJDUMP_P_DYNAMIC = [
'Dynamic Section:',
' NEEDED libutil.so.1',
' NEEDED libpthread.so.0',
' NEEDED libc.so.6',
'',
'Version References:',
' required from libc.so.6:',
' 0x0 0x00 02 GLIBC_2.2.5'
].join('\n')
it('parses DT_NEEDED shared libraries from objdump -p', () => {
const needed = parseNeededLibraries(OBJDUMP_P_DYNAMIC)
expect([...needed].sort()).toEqual(['libc.so.6', 'libpthread.so.0', 'libutil.so.1'])
})
it('parses undefined imported symbols from objdump -T, stripping @VERSION', () => {
const output = [
'0000000000000000 DF *UND*\t0000000000000000 (GLIBC_2.2.5) openpty',
'0000000000000000 w DF *UND*\t0000000000000000 __cxa_finalize@GLIBC_2.2.5',
'0000000000000000 DF .text\t0000000000000000 defined_symbol'
].join('\n')
const imported = parseImportedSymbols(output)
expect(imported.has('openpty')).toBe(true)
expect(imported.has('__cxa_finalize')).toBe(true)
expect(imported.has('defined_symbol')).toBe(false) // not *UND*
})
it('flags a binary that imports openpty/forkpty without libutil.so.1 in DT_NEEDED', () => {
const importsPty = new Set(['openpty', 'forkpty', 'free'])
// Missing libutil.so.1 -> the pinned symbols would not resolve on the floor.
expect(
findMissingProviderDeps(importsPty, new Set(['libc.so.6'])).map((m) => m.symbol)
).toEqual(['openpty', 'forkpty'])
// With libutil.so.1 present, no violation.
expect(findMissingProviderDeps(importsPty, new Set(['libc.so.6', 'libutil.so.1']))).toEqual([])
// A binary that doesn't import the relocated symbols is never flagged.
expect(findMissingProviderDeps(new Set(['free']), new Set(['libc.so.6']))).toEqual([])
})
})
describe('collectNativeBinaries', () => {
it('collects only ELF .node/.so/executable files, skipping non-ELF and symlinks', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-collect-'))
try {
await mkdir(join(root, 'nested'), { recursive: true })
await writeFile(join(root, 'addon.node'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so'), ELF_HEADER)
await writeFile(join(root, 'nested', 'lib.so.1'), ELF_HEADER)
await writeFile(join(root, 'orca-ide'), ELF_HEADER) // extensionless executable
await writeFile(join(root, 'script.js'), ELF_HEADER) // has extension, not native
await writeFile(join(root, 'text.node'), 'not an elf file') // native name, non-ELF
await writeFile(join(root, 'notes.md'), ELF_HEADER)
try {
await symlink(join(root, 'addon.node'), join(root, 'alias.node'))
} catch {
// Symlink creation can be restricted; the rest of the assertions still hold.
}
const found = collectNativeBinaries(root).map((p) => p.slice(root.length + 1))
expect(found).toContain('addon.node')
expect(found).toContain(join('nested', 'lib.so'))
expect(found).toContain(join('nested', 'lib.so.1'))
expect(found).toContain('orca-ide')
expect(found).not.toContain('script.js')
expect(found).not.toContain('text.node')
expect(found).not.toContain('notes.md')
expect(found).not.toContain('alias.node')
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
describe.skipIf(process.platform === 'win32')('verifyLinuxGlibcFloor', () => {
// A stub objdump keyed on the inspected file's basename. Handles `-p` (Dynamic
// Section DT_NEEDED + Version References) and `-T` (undefined symbols).
// `*fail*` exits non-zero (fail-closed branch); `*noutil*` omits libutil.so.1
// from DT_NEEDED; `*pty*` imports openpty. Match on basename only so the
// (random) temp-dir path cannot collide.
async function writeStubObjdump(dir) {
const stubPath = join(dir, 'objdump-stub.sh')
await writeFile(
stubPath,
[
'#!/bin/sh',
'if [ "$1" = "--version" ]; then echo "GNU objdump (stub)"; exit 0; fi',
'f=$(basename "$2")',
'case "$f" in',
' *fail*) echo "objdump: $f: File format not recognized" >&2; exit 1 ;;',
'esac',
'if [ "$1" = "-T" ]; then',
' case "$f" in',
' *pty*) printf "0000 DF *UND* 0000 (GLIBC_2.2.5) openpty\\n" ;;',
' esac',
' exit 0',
'fi',
'printf "Dynamic Section:\\n NEEDED libc.so.6\\n"',
'case "$f" in',
' *noutil*) : ;;',
' *) printf " NEEDED libutil.so.1\\n NEEDED libpthread.so.0\\n" ;;',
'esac',
'printf "\\nVersion References:\\n required from libc.so.6:\\n"',
'case "$f" in',
' *bad*) printf " 0x0 0x00 03 GLIBC_2.34\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *relr*) printf " 0x0 0x00 05 GLIBC_ABI_DT_RELR\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *weakonly*) printf " 0x0 0x02 06 GLIBC_2.32\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
' *cxx*|*sherpa*)',
' printf " required from libstdc++.so.6:\\n 0x0 0x00 07 GLIBCXX_3.4.29\\n" ;;',
' *) printf " 0x0 0x00 08 GLIBC_2.28\\n 0x0 0x00 04 GLIBC_2.2.5\\n" ;;',
'esac',
'exit 0'
].join('\n'),
{ mode: 0o755 }
)
return stubPath
}
it('throws listing binaries over the floor (glibc, DT_RELR marker, and libstdc++)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-over-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app', 'resources'), { recursive: true })
await writeFile(join(root, 'app', 'resources', 'bad-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'relr-exe.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'cxx-addon.node'), ELF_HEADER) // launch-critical GLIBCXX_3.4.29
await writeFile(join(root, 'app', 'good.so'), ELF_HEADER)
let error
try {
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })
} catch (e) {
error = e
}
expect(error).toBeDefined()
expect(error.message).toMatch(/bad-pty\.node needs GLIBC_2\.34/)
expect(error.message).toMatch(/relr-exe\.node needs GLIBC_ABI_DT_RELR/)
expect(error.message).toMatch(/cxx-addon\.node needs GLIBCXX_3\.4\.29/)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('throws when a pinned binary imports openpty without libutil.so.1 in DT_NEEDED', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-noutil-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
// Below the version floor (so the version check passes) but libutil.so.1
// is missing from DT_NEEDED — openpty would not resolve on Ubuntu 20.04.
await writeFile(join(root, 'app', 'noutil-pty.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/noutil-pty\.node imports openpty but libutil\.so\.1 is not in DT_NEEDED/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('passes weak/at-floor needs and the exempt sherpa-onnx libstdc++ prebuilt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-under-'))
try {
const objdumpPath = await writeStubObjdump(root)
const sherpaDir = join(root, 'app', 'node_modules', 'sherpa-onnx-linux-x64')
await mkdir(sherpaDir, { recursive: true })
await writeFile(join(root, 'app', 'good-pty.node'), ELF_HEADER)
await writeFile(join(root, 'app', 'weakonly-lib.so'), ELF_HEADER) // weak GLIBC_2.32 → OK
await writeFile(join(root, 'app', 'orca-ide'), ELF_HEADER)
await writeFile(join(sherpaDir, 'sherpa-onnx.node'), ELF_HEADER) // GLIBCXX_3.4.29, exempt
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('fails closed when objdump cannot read a binary (non-zero exit)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-closed-'))
try {
const objdumpPath = await writeStubObjdump(root)
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'unreadable-fail.node'), ELF_HEADER)
expect(() => verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath })).toThrow(
/objdump -p failed/
)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('is a no-op (no objdump needed) when there are no native binaries', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-glibc-empty-'))
try {
await mkdir(join(root, 'app'), { recursive: true })
await writeFile(join(root, 'app', 'readme.txt'), 'no binaries here')
expect(() =>
verifyLinuxGlibcFloor(join(root, 'app'), { objdumpPath: '/nonexistent/objdump' })
).not.toThrow()
} finally {
await rm(root, { recursive: true, force: true })
}
})
})
+16
View File
@@ -3,6 +3,10 @@
"include": [
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-state-file-reader.ts",
"../src/main/agent-hooks/agent-hook-file-comparison.ts",
"../src/main/agent-hooks/agent-hook-file-limits.ts",
"../src/main/agent-hooks/agent-hook-sftp-text-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
@@ -19,21 +23,31 @@
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
"../src/main/codex/codex-app-server-grant-json.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-config-settings-preservation.ts",
"../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-host-retry-deadlines.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
"../src/main/codex/codex-process-exit-deadline.ts",
"../src/main/codex/codex-trust-config-rollback.ts",
"../src/main/codex/codex-trust-grant-telemetry.ts",
"../src/main/codex/codex-trust-grant-host.ts",
"../src/main/codex/codex-trust-grant-ledger.ts",
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/codex-wsl-reconciliation-generations.ts",
"../src/main/codex/config-settings-baseline.ts",
"../src/main/codex/config-settings-conflict-resolution.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
@@ -41,12 +55,14 @@
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
"../src/main/codex-cli/nvm-version-directory-discovery.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/generated-node-bounded-file-reader.ts",
"../src/main/grok/hook-service.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.5m">
<title>downloads: 7.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 8.0m">
<title>downloads: 8.0m</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="93.5" y="15" fill="#010101" fill-opacity=".3">7.5m</text>
<text x="93.5" y="14">7.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">8.0m</text>
<text x="93.5" y="14">8.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 399 KiB

+1 -1
View File
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 第一、二个微信群均已满,现在可以加入第三个群。
- **微信:** 其他群已满,现在加入第 5 群。
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
+4 -2
View File
@@ -10,8 +10,10 @@ startup. Current Orca builds start Xvfb automatically for `orca serve` when no
not required. When `DISPLAY` is set, Orca uses that display instead of starting
a competing Xvfb process.
The supported deployment matrix covers Ubuntu 22.04 and 24.04 and current
Debian stable. Package names can differ on other Debian-derived releases.
The supported deployment matrix covers Ubuntu 20.04, 22.04, and 24.04 and
current Debian stable — anything with glibc 2.31 or newer (see
[Linux glibc compatibility](./linux-glibc-compatibility.md)). Package names can
differ on other Debian-derived releases.
## Ubuntu and Debian prerequisites
@@ -0,0 +1,99 @@
# Linux glibc Compatibility
Orca's Linux builds target **stock Ubuntu 20.04 and newer** — glibc 2.31 and
libstdc++ `GLIBCXX_3.4.28` (also Debian 11, RHEL 9), on both x64 and arm64.
Packaging enforces this floor automatically; keep it in mind when adding or
upgrading native dependencies. (The optional speech feature is the one
exception — see below.)
## Why this needs attention
A native module (`.node`) links against the glibc of the machine that compiled
it. Our release CI compiles node-pty from source on GitHub's `ubuntu-latest`
runner, whose glibc rises over time as the image is bumped. A binary compiled on
a newer glibc can reference symbol versions that do not exist on an older target,
and the dynamic loader then refuses to load it:
```
/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by .../pty.node)
```
Because the Orca main process loads node-pty at startup, that failure crashes the
whole app before a window appears — this is exactly what shipped in v1.4.150 and
broke launch on Ubuntu 20.04 ([#9902](https://github.com/stablyai/orca/issues/9902)).
The specific trap is glibc's 2.32–2.34 "libpthread/libutil merge", which moved
several long-stable functions into libc under brand-new symbol versions:
| Symbol | New version | node-pty use |
| ----------------- | ------------- | ----------------------- |
| `pthread_sigmask` | `GLIBC_2.32` | reset child signal mask |
| `openpty` | `GLIBC_2.34` | allocate the pty |
| `forkpty` | `GLIBC_2.34` | fork the shell |
Electron itself (glibc 2.25) and the other bundled native modules
(`sherpa-onnx`, `@parcel/watcher`, both prebuilt on old glibc) stay well under
the floor, so node-pty was the sole blocker.
## How we keep the floor
**1. Pin the relocated symbols (the fix).**
[`config/patches/node-pty@1.1.0.patch`](../../config/patches/node-pty@1.1.0.patch)
adds a `.symver` shim in `src/unix/pty.cc` that binds `openpty`, `forkpty`, and
`pthread_sigmask` to their pre-merge version node — `GLIBC_2.2.5` on x64,
`GLIBC_2.17` on arm64 (each architecture's baseline glibc). glibc still ships
those as compatibility aliases, so the reference resolves on both new build hosts
and old targets.
The catch: gcc defaults to `--as-needed` and, since the pinned symbols now
resolve from libc's compat aliases at build time, it drops `libutil`/`libpthread`
from `DT_NEEDED`. On the target those libraries are where the symbols actually
live, so the patch's `binding.gyp` `ldflags` force
`-Wl,--no-as-needed,-l:libutil.so.1,-l:libpthread.so.0` back into `DT_NEEDED`.
The shim is guarded by `#if defined(__linux__)`; macOS and Windows are untouched.
**2. Gate packaging (the regression guard).**
[`config/scripts/verify-linux-glibc-floor.cjs`](../../config/scripts/verify-linux-glibc-floor.cjs)
runs in the electron-builder `afterPack` hook for Linux. It reads every bundled
native binary's version needs (`objdump -p` "Version References" — the
authoritative load-time list, which also captures symbol-less markers like
`GLIBC_ABI_DT_RELR`) and fails the build if any strong `GLIBC_`/`GLIBCXX_`/
`CXXABI_` node is newer than stock Ubuntu 20.04 provides, naming the file and the
offending node. Weak needs are ignored (the loader tolerates them). It also
asserts the flip side of the `.symver` fix: any binary that imports
`openpty`/`forkpty` must keep `libutil.so.1` in `DT_NEEDED` — otherwise the
pinned `openpty@GLIBC_2.2.5` resolves from libc's compat alias at build time (so
the version check passes) yet fails to load on 20.04, where those functions live
only in libutil. A future runner bump, a new native dependency, or a dropped
ldflag therefore fails the release build instead of shipping a Linux app that
crashes on launch.
> The gate is a static invariant, not an integration test. The load path was
> verified by hand for this fix (real Ubuntu 20.04, x64 + arm64: `require`
> node-pty and spawn a shell). A CI smoke test that loads the packaged
> `pty.node` in a glibc-2.31 container and spawns a shell is the recommended
> follow-up — it would make the load path self-verifying and stay valid even if
> the build ever moves to an old-glibc sysroot.
The one carve-out is the `sherpa-onnx` speech prebuilt, which already requires
`GLIBCXX_3.4.29` (GCC 11). It loads lazily in the speech worker
(`src/main/speech/stt-worker.ts`), never at app launch, so it is exempt from the
libstdc++ floor — its glibc needs are still checked. Speech-to-text therefore
needs a host with libstdc++ from GCC 11+ (Ubuntu 21.10 / 22.04 LTS or newer); the
app itself still launches on stock 20.04.
## Adding or upgrading a native dependency
- Prefer packages that ship prebuilt binaries compiled against an old toolchain
(manylinux / `glibc 2.17`-class), like `@parcel/watcher`.
- For a module we compile from source, if the gate flags it, either pin the
offending symbols the way node-pty does, or build it in an old-glibc container.
- To check locally on a Linux host, list what a binary requires (skipping the
weak `0x02`-flagged needs the loader tolerates):
```bash
objdump -p path/to/module.node | sed -n '/Version References/,/^$/p'
```
No strong `GLIBC_` node may exceed `2.31`, and no `GLIBCXX_`/`CXXABI_` node may
exceed `3.4.28`/`1.3.12` — what stock Ubuntu 20.04 ships.
+1 -1
View File
@@ -18,7 +18,7 @@
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "1",
"infoPlist": {
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
+11 -8
View File
@@ -9,13 +9,14 @@ import {
Alert
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
import { loadHosts } from '../../../src/transport/host-store'
import { useHostClient } from '../../../src/transport/client-context'
import type { RpcSuccess } from '../../../src/transport/types'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { styles } from './accounts-screen-styles'
import { useNow } from '../../../src/hooks/use-now'
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
import {
type AccountsSnapshot,
@@ -40,14 +41,16 @@ export default function AccountsScreen() {
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
const [clockEnabled, setClockEnabled] = useState(false)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
}, [])
useFocusEffect(
useCallback(() => {
setClockEnabled(true)
return () => setClockEnabled(false)
}, [])
)
// Why: snapshot pushes only arrive when the desktop's rate-limit poll completes.
const now = useNow(60_000, clockEnabled)
useEffect(() => {
if (!hostId) {
+63 -74
View File
@@ -38,6 +38,7 @@ import {
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import { startHostWorktreeRefresh } from '../../../src/worktree/host-worktree-refresh'
import {
useLastConnectedAt,
useReconnectAttempt
@@ -61,7 +62,7 @@ import { buildWorktreeNavigationActions } from '../../../src/agent-history/workt
import { floatingWorkspaceSessionPath } from '../../../src/session/floating-workspace'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
@@ -70,7 +71,6 @@ import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { useHostStatusGates } from '../../../src/transport/host-status-gates'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
@@ -141,7 +141,8 @@ export function HostScreen({
const lastConnectedAt = useLastConnectedAt(hostId)
const clientRef = useRef<RpcClient | null>(null)
const fetchWorktreesInFlightRef = useRef(false)
const fetchRepoMetadataInFlightRef = useRef(false)
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)
@@ -176,11 +177,7 @@ export function HostScreen({
const [showGroupPicker, setShowGroupPicker] = useState(false)
const [showFilterModal, setShowFilterModal] = useState(false)
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const { hostCapabilities, floatingWorkspaceEnabled, compatVerdict } = useHostStatusGates({
hostId,
client,
connState
})
const { hostCapabilities, floatingWorkspaceEnabled } = useHostProtocolGates()
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
@@ -356,48 +353,54 @@ export function HostScreen({
}, [hostId])
const fetchRepoMetadata = useCallback(
async (options: { force?: boolean } = {}) => {
async (options: { force?: boolean; queueIfInFlight?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId) {
return
}
if (fetchRepoMetadataInFlightRef.current) {
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 = true
fetchRepoMetadataInFlightRef.current.add(client)
const requestClient = client,
requestHostId = hostId
try {
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] : []
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)
])
)
)
)
setRepoIdsByName(new Map(repoResult.repos.map((repo) => [repo.displayName, repo.id])))
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 throttled refresh can retry.
// Repo metadata is decorative; the next refresh can retry.
} finally {
fetchRepoMetadataInFlightRef.current = false
fetchRepoMetadataInFlightRef.current.delete(requestClient)
}
},
[client, connState, hostId]
@@ -494,39 +497,29 @@ export function HostScreen({
}, [])
)
useFocusEffect(
useCallback(() => {
// The embedded sidebar isn't a routed screen (focus never fires); it polls via the mount effect below.
if (embedded || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
// Pull desktop's shared view settings on focus so desktop changes show up without a manual refresh.
void syncViewSettingsFromDesktop()
// Why: React Navigation keeps prior screens mounted; only poll while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
}, 3000)
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
)
// Why: the embedded sidebar is never the focused route, so useFocusEffect never polls; mirror it from a mount effect.
useEffect(() => {
if (!embedded || connState !== 'connected') {
const startWorktreeRefresh = useCallback(() => {
if (!client || connState !== 'connected') {
return
}
void fetchWorktrees()
void fetchRepoMetadata()
void syncViewSettingsFromDesktop()
const interval = setInterval(() => {
void fetchWorktrees()
void fetchRepoMetadata()
}, 3000)
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, 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({
@@ -745,15 +738,17 @@ export function HostScreen({
const toggleCollapsed = useCallback(
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (next.has(key)) {
next.delete(key)
} else {
if (!next.delete(key)) {
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
[persistViewSettings]
)
const toggleWorktreeLineage = useCallback(
(item: Worktree) => toggleCollapsed(getMobileWorkspaceLineageGroupKey(item.worktreeId)),
[toggleCollapsed]
)
const { sections, rawSections, uniqueRepos, uniqueRepoColors } = useWorkspaceSections({
displayWorktrees,
sortMode,
@@ -780,10 +775,6 @@ export function HostScreen({
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
@@ -1200,9 +1191,7 @@ export function HostScreen({
hideRepo={groupMode === 'repo'}
onPress={openWorktreeSession}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
onToggleLineage={(row) =>
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
}
onToggleLineage={toggleWorktreeLineage}
/>
)}
/>
+119 -90
View File
@@ -86,6 +86,10 @@ import {
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
serializeLastVisitedWorktreeRecord
} from '../../../../src/worktree/last-visited-worktree-repo'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -175,13 +179,14 @@ import {
} from '../../../../src/session/mobile-terminal-tab-agent'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
@@ -208,6 +213,7 @@ import { useMobileNativeChatReadability } from '../../../../src/session/use-mobi
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
@@ -225,6 +231,8 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle'
import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
@@ -895,7 +903,9 @@ export default function SessionScreen() {
const tabLayoutsRef = useRef<Map<string, { x: number; width: number }>>(new Map())
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
const diffCommentsRef = useRef<DiffComment[]>([])
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
@@ -1377,7 +1387,10 @@ export default function SessionScreen() {
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: viewportRef.current ?? undefined,
viewport: nativeChatTerminalStream.mobileNativeChatSubscribeViewport(
covered,
viewportRef.current
),
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
(result) => {
@@ -1716,6 +1729,8 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
sessionTabsRef.current = nextTabs
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
@@ -1833,8 +1848,7 @@ export default function SessionScreen() {
if (!client) {
return
}
setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => {
const response = await client.sendRequest('markdown.readTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id
@@ -1847,19 +1861,16 @@ export default function SessionScreen() {
editable?: boolean
readOnlyReason?: string
}
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
})
)
return
return {
status: 'ready',
content: result.content,
localContent: result.content,
baseVersion: result.version,
isDirty: false,
editable: result.editable === true,
stale: result.isDirty,
readOnlyReason: result.readOnlyReason
}
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
@@ -1877,24 +1888,12 @@ export default function SessionScreen() {
truncated: boolean
byteLength: number
}
setMarkdownDocs((prev) =>
new Map(prev).set(
tab.id,
buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
)
)
} catch {
setMarkdownDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: "Couldn't load markdown"
})
)
}
return buildMarkdownDiskFallbackDoc({
content: fileResult.content,
truncated: fileResult.truncated,
tabIsDirty: tab.isDirty
})
})
},
[client, worktreeId]
)
@@ -1904,31 +1903,13 @@ export default function SessionScreen() {
if (!client) {
return
}
setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
try {
const doc = await resolveMobileFileTabDoc(client, {
await fileDocLifecycleRef.current.load(tab, setFileDocs, () =>
resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
setFileDocs((prev) => new Map(prev).set(tab.id, doc))
} catch (err) {
const message = err instanceof Error ? err.message : ''
const previewMessage =
message === 'binary_file'
? 'Binary preview unavailable'
: message === 'file_too_large'
? 'File too large for mobile preview'
: tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
? "Couldn't load diff preview"
: "Couldn't load file preview"
setFileDocs((prev) =>
new Map(prev).set(tab.id, {
status: 'error',
message: previewMessage
})
)
}
)
},
[client, worktreeId]
)
@@ -2243,6 +2224,9 @@ export default function SessionScreen() {
})
} finally {
markdownSaveInFlightRef.current.delete(tab.id)
if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) {
markdownSaveSeqRef.current.delete(tab.id)
}
}
},
[client, markdownDocs, showToast, worktreeId]
@@ -2455,6 +2439,7 @@ export default function SessionScreen() {
terminalFrameHeightRef,
viewportRef,
viewportMeasuredRef,
nativeChatCoveredRef: showNativeChatRef,
clientRef,
deviceTokenRef,
initializedHandlesRef,
@@ -2514,10 +2499,10 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
void AsyncStorage.setItem(
'orca:last-visited-worktree',
JSON.stringify({ hostId, worktreeId })
)
const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId })
if (serialized) {
void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized)
}
}
}, [hostId, worktreeId])
@@ -2551,6 +2536,10 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2570,6 +2559,10 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
markdownDocLifecycleRef.current.reset()
fileDocLifecycleRef.current.reset()
markdownSaveSeqRef.current.clear()
markdownSaveInFlightRef.current.clear()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
@@ -2706,14 +2699,25 @@ export default function SessionScreen() {
if (connState !== 'connected') {
return
}
void fetchSessionTabs()
void fetchTerminals()
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden.
const interval = setInterval(() => {
const refreshOnForeground = () => {
if (AppState.currentState !== 'active') {
return
}
void fetchSessionTabs()
void fetchTerminals()
}, 2000)
return () => clearInterval(interval)
}
const appStateSubscription = AppState.addEventListener('change', (state) => {
if (state === 'active') {
refreshOnForeground()
}
})
// Why: live subscription keeps stream ownership, but the fallback list poll should stop while this route is hidden or backgrounded.
const interval = setInterval(refreshOnForeground, 2000)
refreshOnForeground()
return () => {
clearInterval(interval)
appStateSubscription.remove()
}
}, [connState, fetchSessionTabs, fetchTerminals])
)
@@ -3624,14 +3628,20 @@ export default function SessionScreen() {
showToast
})
const { attachImage, isAttaching } = useMobileImageAttachment({
// Terminal input pastes an attached image straight into the visible terminal;
// native chat instead holds it as a composer chip and rides it along on submit.
const { attachImage, isAttaching, nativeChatImages } = useMobileSessionImageAttachments({
client,
activeHandle,
activeHandleRef,
canSend,
connState,
deviceTokenRef,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSend,
showToast,
onSuccess: triggerSelection,
onError: triggerError
@@ -3886,11 +3896,12 @@ export default function SessionScreen() {
try {
const worktree = `id:${worktreeId}`
const mutationOwnership = await captureMobileFileMutationOwnership(client, worktree)
for (let attempt = 1; attempt <= 100; attempt += 1) {
const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md`
const createResponse = await client.sendRequest(
'files.createFile',
{ worktree, relativePath },
{ worktree, relativePath, ...mutationOwnership },
{ timeoutMs: 15_000 }
)
if (!createResponse.ok) {
@@ -4085,7 +4096,23 @@ export default function SessionScreen() {
initializedHandlesRef.current.delete(terminalHandle)
clearTerminalLiveInputDefault(terminalHandle)
}
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
if (tab.type === 'file') {
fileDocLifecycleRef.current.close(tab.id, setFileDocs)
}
if (tab.type === 'markdown') {
markdownDocLifecycleRef.current.close(tab.id, (update) => {
setMarkdownDocs((current) => {
const next = update(current)
markdownDocsRef.current = next
return next
})
})
markdownSaveSeqRef.current.delete(tab.id)
markdownSaveInFlightRef.current.delete(tab.id)
}
const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
sessionTabsRef.current = remainingTabs
setSessionTabs(remainingTabs)
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
@@ -4364,6 +4391,7 @@ export default function SessionScreen() {
hostedChecksSupported: prIsGithubRepo
})
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
const createTabBusy = creating || creatingBrowser || creatingMarkdown
return (
<View ref={setMobileSessionRootRef} style={styles.container}>
@@ -4514,14 +4542,24 @@ export default function SessionScreen() {
>
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
{quickCommandsSupported === true ? (
<QuickCommandsTabButton
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
{/* Why: stable placement matters, while old hosts must stay gated because they strip agentPrompt. */}
<QuickCommandsTabButton
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
onPress={() => {
if (quickCommandsSupported === true) {
setShowQuickCommands(true)
return
}
onPress={() => setShowQuickCommands(true)}
/>
) : null}
showToast(
quickCommandsSupported === false
? 'Desktop update required for quick commands'
: 'Checking desktop capabilities — try again in a moment',
1600
)
}}
/>
</View>
)}
</SafeAreaView>
@@ -4556,24 +4594,16 @@ export default function SessionScreen() {
<Pressable
style={[
styles.createButton,
(creating ||
creatingBrowser ||
creatingMarkdown ||
connState !== 'connected') &&
styles.createButtonDisabled
(createTabBusy || connState !== 'connected') && styles.createButtonDisabled
]}
disabled={
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
}
disabled={createTabBusy || connState !== 'connected'}
onPress={() => {
setCreateError('')
setShowCreateTabDrawer(true)
}}
>
<Text style={styles.createButtonText}>
{creating || creatingBrowser || creatingMarkdown
? 'Creating...'
: 'Create Tab'}
{createTabBusy ? 'Creating...' : 'Create Tab'}
</Text>
</Pressable>
</View>
@@ -4690,8 +4720,7 @@ export default function SessionScreen() {
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onAttachImage={() => void attachImage('library')}
isAttaching={isAttaching}
images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
+87 -89
View File
@@ -137,9 +137,15 @@ import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import {
createMobileItemPrFileContentScope,
createMobileProjectPrFileContentScope,
useMobilePrFileContentCache
} from '../../../src/tasks/use-mobile-pr-file-content-cache'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
GitHubPRFileContents,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
@@ -358,13 +364,6 @@ type GitHubDetailCheck = {
url?: string | null
}
type GitHubPRFileContents = {
original: string
modified: string
originalIsBinary: boolean
modifiedIsBinary: boolean
}
type DetailPayload =
| {
provider: 'github'
@@ -2263,8 +2262,6 @@ export default function MobileTasksScreen() {
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
const [itemReplyDrafts, setItemReplyDrafts] = useState<Record<string, string>>({})
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -2602,6 +2599,23 @@ export default function MobileTasksScreen() {
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
[findProjectRowRepo, projectRowItem]
)
const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload)
const projectPrFileContentScope = createMobileProjectPrFileContentScope(
projectRowItem,
projectRowHostedRepo,
projectRowDetail,
projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null
)
const activePrFileContentScope = projectRowItem
? projectPrFileContentScope
: itemPrFileContentScope
const {
clear: clearPrFileContents,
contents: prFileContents,
load: loadPrFileContent,
loadingPath: prFileLoadingPath
} = useMobilePrFileContentCache(activePrFileContentScope)
const itemReviewerCandidates = useMemo(() => {
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
return []
@@ -4141,8 +4155,7 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
return
@@ -4157,11 +4170,10 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
}, [actionItem])
}, [actionItem, clearPrFileContents])
useEffect(() => {
if (!detailPayload) {
@@ -4458,8 +4470,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts({})
return
@@ -4474,8 +4485,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
setPrFileContents({})
setPrFileLoadingPath(null)
clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts(
Object.fromEntries(
@@ -4579,6 +4589,7 @@ export default function MobileTasksScreen() {
}
}, [
activeGitHubProjectHost,
clearPrFileContents,
client,
githubProjectTable,
projectRowDetailRefreshSeq,
@@ -6549,9 +6560,6 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
const repo = findProjectRowRepo(row)
if (
!client ||
@@ -6560,49 +6568,45 @@ export default function MobileTasksScreen() {
!row.content.number ||
projectRowDetail?.provider !== 'github' ||
!projectRowDetail.headSha ||
!projectRowDetail.baseSha
!projectRowDetail.baseSha ||
!projectPrFileContentScope
) {
setProjectRowDetailError('Unable to load file contents for this pull request.')
return
}
setPrFileLoadingPath(file.path)
setProjectRowDetailError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setProjectRowDetailError(
err instanceof Error ? err.message : 'Failed to load file contents'
)
} finally {
setPrFileLoadingPath(null)
}
await loadPrFileContent(
projectPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${repo.id}`,
prNumber: row.content.number,
prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: projectRowDetail.headSha,
baseSha: projectRowDetail.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setProjectRowDetailError
)
},
[
activeGitHubProjectHost,
client,
expandedPrFilePath,
findProjectRowRepo,
prFileContents,
loadPrFileContent,
projectPrFileContentScope,
projectRowDetail
]
)
@@ -7526,49 +7530,43 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
if (prFileContents[file.path]) {
return
}
if (
!client ||
item.source.type !== 'pr' ||
detailPayload?.provider !== 'github' ||
!detailPayload.headSha ||
!detailPayload.baseSha
!detailPayload.baseSha ||
!itemPrFileContentScope
) {
setError('Unable to load file contents for this pull request.')
return
}
setPrFileLoadingPath(file.path)
setError('')
try {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
setPrFileContents((current) => ({
...current,
[file.path]: response.result as GitHubPRFileContents
}))
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load file contents')
} finally {
setPrFileLoadingPath(null)
}
await loadPrFileContent(
itemPrFileContentScope,
file,
async () => {
const response = await client.sendRequest(
'github.prFileContents',
{
repo: `id:${item.source.repoId}`,
prNumber: item.source.number,
path: file.path,
oldPath: file.oldPath,
status: file.status ?? 'modified',
headSha: detailPayload.headSha,
baseSha: detailPayload.baseSha
},
{ timeoutMs: 30_000 }
)
if (!isSuccess(response)) {
throw new Error(response.error.message)
}
return response.result
},
setError
)
},
[client, detailPayload, expandedPrFilePath, prFileContents]
[client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent]
)
const addGitHubFileReviewComment = useCallback(
@@ -12322,7 +12320,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
contents={prFileContents[file.path]!}
commentDrafts={prFileCommentDrafts}
disabled={projectMutating}
onCommentDraftChange={(draftKey, next) =>
@@ -13268,7 +13266,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
<GitHubPrFileDiff
filePath={file.path}
contents={prFileContents[file.path]}
contents={prFileContents[file.path]!}
commentDrafts={prFileCommentDrafts}
disabled={mutatingStatus}
onCommentDraftChange={(draftKey, next) =>
+18 -15
View File
@@ -10,6 +10,7 @@ import {
loadHostSidebarWidth,
saveHostSidebarWidth
} from '../../src/storage/preferences'
import { HostProtocolGate } from '../../src/components/HostProtocolGate'
import { HostScreen } from './[hostId]/index'
// Keep at least this much room for the detail pane when resizing the sidebar.
@@ -138,23 +139,25 @@ export default function HostGroupLayout() {
// changes so a fold/rotation doesn't remount the navigator and reset the
// navigation stack — only the sidebar pane toggles in and out.
return (
<View style={styles.row}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* Dedicated drag handle straddling the right border — see resizer note. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
<HostProtocolGate hostId={hostId}>
<View style={styles.row}>
{showSidebar && sidebarOpen ? (
<View style={[styles.sidebar, { width: sidebarWidth }]}>
<HostScreen
embedded
hostId={hostId}
action={action}
onHideSidebar={canCollapseSidebar ? hideSidebar : undefined}
/>
{/* Dedicated drag handle straddling the right border — see resizer note. */}
<View style={styles.resizeHandle} {...resizer.panHandlers} />
</View>
) : null}
<View style={styles.detail}>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</View>
) : null}
<View style={styles.detail}>
<HostStack animation={showSidebar ? 'none' : 'default'} />
</View>
</View>
</HostProtocolGate>
)
}
+20 -16
View File
@@ -27,7 +27,12 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import {
LAST_VISITED_WORKTREE_STORAGE_KEY,
readLastVisitedWorktreeRecord
} from '../src/worktree/last-visited-worktree-repo'
import type { RpcClient } from '../src/transport/rpc-client'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
useAllHostClients,
useCloseHost,
@@ -140,11 +145,11 @@ function clientKey(client: RpcClient): number {
function fetchStats(
client: RpcClient,
hostId: string,
setStats: (s: StatsSummary) => void,
disposed: () => boolean
) {
client
.sendRequest('stats.summary')
sendSingleFlightRequest(client, hostId, 'stats.summary')
.then((response) => {
if (disposed()) {
return
@@ -182,9 +187,8 @@ function fetchWorktreeInfo(
})
}
client
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
.sendRequest('worktree.ps', { limit: 10000 })
// Why: worktree.ps defaults to 200 and silently truncates; request all so counts are accurate.
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: 10000 })
.then((response) => {
if (disposed()) {
return
@@ -225,8 +229,7 @@ function fetchAccountsSnapshot(
) => void,
disposed: () => boolean
) {
client
.sendRequest('accounts.list')
sendSingleFlightRequest(client, hostId, 'accounts.list')
.then((response) => {
if (disposed()) {
return
@@ -248,9 +251,9 @@ function fetchTaskProviders(
disposed: () => boolean
) {
Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
sendSingleFlightRequest(client, hostId, 'settings.get'),
sendSingleFlightRequest(client, hostId, 'preflight.check'),
sendSingleFlightRequest(client, hostId, 'linear.status')
])
.then(([settingsResponse, preflightResponse, linearResponse]) => {
if (disposed()) {
@@ -395,17 +398,18 @@ export default function HomeScreen() {
router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
if (stale || !raw) {
return
}
try {
setLastVisited(JSON.parse(raw))
} catch {}
const record = readLastVisitedWorktreeRecord(raw)
if (record) {
setLastVisited(record)
}
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, setStats, () => stale)
fetchStats(entry.client, entry.hostId, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
@@ -512,7 +516,7 @@ export default function HomeScreen() {
}
if (!statsFetched) {
statsFetched = true
fetchStats(entry.client, setStats, () => false)
fetchStats(entry.client, entry.hostId, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
+5 -5
View File
@@ -23,7 +23,7 @@ export default function NativeChatSettingsScreen() {
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Native chat</Text>
<Text style={styles.heading}>Chat UI</Text>
</View>
<ScrollView
@@ -33,17 +33,17 @@ export default function NativeChatSettingsScreen() {
<Text style={styles.groupHeading}>DEFAULT VIEW</Text>
<Text style={styles.groupDescription}>
Choose how supported agent sessions (Claude, Codex, and other chat-capable agents) open on
this device. Terminal shows the raw CLI; native chat shows a chat interface like the
desktop app. You can still switch any individual session from its long-press menu.
this device. Terminal shows the raw CLI; Chat UI shows a chat interface like the desktop
app. You can still switch any individual session from its long-press menu.
</Text>
<View style={[styles.section, styles.sectionTopGap]}>
<View style={styles.row}>
<View style={styles.rowContent}>
<Text style={styles.rowLabel}>Open sessions in native chat</Text>
<Text style={styles.rowLabel}>Open sessions in Chat UI</Text>
<Text style={styles.rowSublabel}>{chatDefault ? 'On' : 'Off'}</Text>
</View>
<Switch
accessibilityLabel="Open sessions in native chat"
accessibilityLabel="Open sessions in Chat UI"
value={chatDefault}
onValueChange={(next) => setDefaultView(next ? 'chat' : 'terminal')}
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
+1 -1
View File
@@ -120,7 +120,7 @@ export default function SettingsScreen() {
onPress={() => router.push('/native-chat-settings')}
>
<MessageSquare size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Native chat</Text>
<Text style={styles.rowLabel}>Chat UI</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
+35 -33
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ActivityIndicator,
Pressable,
@@ -9,7 +9,7 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
@@ -18,6 +18,7 @@ import { useAllHostClients } from '../src/transport/client-context'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
import { useDictationSetupPoller } from '../src/dictation/use-dictation-setup-poller'
import {
deleteDictationModel,
downloadDictationModel,
@@ -58,44 +59,45 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [routeFocused, setRouteFocused] = useState(false)
const refresh = useCallback(async () => {
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load voice settings')
return undefined
} finally {
setLoading(false)
}
}, [client])
// Initial load once a connected client is available.
useEffect(() => {
if (!client) {
return
}
setLoading(true)
setError(null)
void refresh().finally(() => setLoading(false))
}, [client, refresh])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: routeFocused && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
// Poll only while a model is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
if (routeFocused && client && setup === null) {
setLoading(true)
}
return undefined
}, [setup, client, refresh])
}, [routeFocused, client, setup])
const handleToggleEnabled = useCallback(
async (enabled: boolean) => {
@@ -109,10 +111,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { enabled }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleSelectMode = useCallback(
@@ -126,10 +128,10 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setSetup(await setDictationConfig(client, { dictationMode }))
} catch (err) {
setError(err instanceof Error ? err.message : 'Could not update')
void refresh()
void refreshSetup()
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
@@ -160,14 +162,14 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
setError(err instanceof Error ? err.message : 'Download failed')
} finally {
setBusyAction(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleDelete = useCallback(
+23 -27
View File
@@ -16,6 +16,7 @@
require "base64"
require "json"
require_relative "ios_release_version"
default_platform(:ios)
@@ -66,25 +67,6 @@ def current_mobile_version(config)
config.fetch("expo").fetch("version")
end
def truthy_option?(value)
%w[1 true yes on].include?(value.to_s.strip.downcase)
end
def bump_patch_version(version)
match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
UI.user_error!("Cannot bump non-semver mobile version '#{version}'") unless match
"#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
end
def resolve_requested_version(options, config)
requested = options[:version].to_s.strip
return requested unless requested.empty?
current_version = current_mobile_version(config)
truthy_option?(options[:bump_patch]) ? bump_patch_version(current_version) : current_version
end
# Returns true when `version`'s App Store train is closed to new build uploads.
# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup
# failed). On a nil app or any API error, degrade to "open": we then proceed as
@@ -113,13 +95,7 @@ platform :ios do
lane :prepare_release_version do |options|
api_key = app_store_connect_api_key_from_env
config = load_mobile_app_config
version = resolve_requested_version(options, config)
# Fail fast (seconds) if the resolved version's App Store train is already
# closed: Apple would otherwise reject the upload ~20 min later with 90186.
# Bumping the version is left to the human (the bump_patch input or a
# "Prepare mobile X" commit) so the marketing version stays a deliberate,
# release-notes-bearing decision rather than something CI invents.
app =
begin
Spaceship::ConnectAPI::App.find(BUNDLE_ID)
@@ -127,11 +103,31 @@ platform :ios do
UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.")
nil
end
version =
begin
IosReleaseVersion.resolve(
requested: options[:version],
bump_patch: options[:bump_patch],
current_version: current_mobile_version(config),
train_closed: ->(candidate) { version_train_closed?(app, candidate) },
)
rescue ArgumentError => error
UI.user_error!(error.message)
end
# Explicit and checked-in versions still fail fast when closed. Patch bumps
# skip closed trains above because workflow-only releases can outpace Git.
if version_train_closed?(app, version)
retry_guidance =
if IosReleaseVersion.truthy?(options[:bump_patch])
"Use a higher release_version or land a \"Prepare mobile <version>\" commit."
else
"Re-dispatch with bump_patch_version: true (or a higher release_version), " \
"or land a \"Prepare mobile <version>\" commit."
end
UI.user_error!(
"iOS version #{version} is already submitted/released on the App Store and cannot accept " \
"new builds. Re-dispatch with bump_patch_version: true (or a higher release_version), " \
"or land a \"Prepare mobile <version>\" commit.",
"new builds. #{retry_guidance}",
)
end
+24
View File
@@ -0,0 +1,24 @@
module IosReleaseVersion
module_function
def truthy?(value)
%w[1 true yes on].include?(value.to_s.strip.downcase)
end
def bump_patch(version)
match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless match
"#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
end
def resolve(requested:, bump_patch:, current_version:, train_closed:)
exact_version = requested.to_s.strip
return exact_version unless exact_version.empty?
return current_version unless truthy?(bump_patch)
candidate = bump_patch(current_version)
candidate = bump_patch(candidate) while train_closed.call(candidate)
candidate
end
end
@@ -0,0 +1,48 @@
require "minitest/autorun"
require_relative "ios_release_version"
class IosReleaseVersionTest < Minitest::Test
def test_exact_version_wins_without_checking_trains
checked_versions = []
version = IosReleaseVersion.resolve(
requested: " 0.0.40 ",
bump_patch: true,
current_version: "0.0.32",
train_closed: ->(candidate) { checked_versions << candidate },
)
assert_equal("0.0.40", version)
assert_empty(checked_versions)
end
def test_uses_current_version_without_a_patch_bump
version = IosReleaseVersion.resolve(
requested: "",
bump_patch: false,
current_version: "0.0.32",
train_closed: ->(_) { flunk("should not check trains") },
)
assert_equal("0.0.32", version)
end
def test_skips_closed_patch_versions_from_a_stale_repo_version
closed_versions = %w[0.0.33 0.0.34]
version = IosReleaseVersion.resolve(
requested: "",
bump_patch: true,
current_version: "0.0.32",
train_closed: ->(candidate) { closed_versions.include?(candidate) },
)
assert_equal("0.0.35", version)
end
def test_rejects_non_semver_versions
error = assert_raises(ArgumentError) { IosReleaseVersion.bump_patch("0.0") }
assert_equal("Cannot bump non-semver mobile version '0.0'", error.message)
end
end
+50 -83
View File
@@ -1494,6 +1494,12 @@ packages:
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.10.1':
resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@@ -1942,56 +1948,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -2064,56 +2062,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2575,42 +2565,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -3167,11 +3151,11 @@ packages:
resolution: {integrity: sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==}
engines: {node: '>= 5.10.0'}
brace-expansion@1.1.15:
resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==}
brace-expansion@1.1.16:
resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==}
brace-expansion@5.0.6:
resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==}
brace-expansion@5.0.7:
resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==}
engines: {node: 18 || 20 || >=22}
braces@3.0.3:
@@ -4175,8 +4159,8 @@ packages:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flatted@3.4.2:
resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==}
flatted@3.4.3:
resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==}
flow-enums-runtime@0.0.6:
resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==}
@@ -4910,28 +4894,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -5910,8 +5890,8 @@ packages:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.4:
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
shell-quote@1.10.0:
resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
engines: {node: '>= 0.4'}
side-channel-list@1.0.1:
@@ -6585,6 +6565,18 @@ packages:
utf-8-validate:
optional: true
ws@7.5.13:
resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==}
engines: {node: '>=8.3.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -8098,6 +8090,11 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@eslint-community/eslint-utils@4.10.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
eslint-visitor-keys: 3.4.3
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)':
dependencies:
eslint: 9.39.4
@@ -9674,7 +9671,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -9694,7 +9691,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -9711,15 +9708,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9729,10 +9717,6 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9762,21 +9746,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -10236,12 +10205,12 @@ snapshots:
dependencies:
big-integer: 1.6.52
brace-expansion@1.1.15:
brace-expansion@1.1.16:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@5.0.6:
brace-expansion@5.0.7:
dependencies:
balanced-match: 4.0.4
@@ -10839,11 +10808,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -10867,7 +10836,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -10890,7 +10859,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -10901,7 +10870,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -10998,7 +10967,7 @@ snapshots:
eslint@9.39.4:
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
'@eslint-community/eslint-utils': 4.10.1(eslint@9.39.4)
'@eslint-community/regexpp': 4.12.2
'@eslint/config-array': 0.21.2
'@eslint/config-helpers': 0.4.2
@@ -11519,10 +11488,10 @@ snapshots:
flat-cache@4.0.1:
dependencies:
flatted: 3.4.2
flatted: 3.4.3
keyv: 4.5.4
flatted@3.4.2: {}
flatted@3.4.3: {}
flow-enums-runtime@0.0.6: {}
@@ -12933,7 +12902,7 @@ snapshots:
serialize-error: 2.1.0
source-map: 0.5.7
throat: 5.0.0
ws: 7.5.11
ws: 7.5.13
yargs: 17.7.3
transitivePeerDependencies:
- bufferutil
@@ -12967,11 +12936,11 @@ snapshots:
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.6
brace-expansion: 5.0.7
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.15
brace-expansion: 1.1.16
minimist@1.2.8: {}
@@ -13342,7 +13311,7 @@ snapshots:
react-devtools-core@6.1.5:
dependencies:
shell-quote: 1.8.4
shell-quote: 1.10.0
ws: 7.5.11
transitivePeerDependencies:
- bufferutil
@@ -13769,7 +13738,7 @@ snapshots:
shebang-regex@3.0.0: {}
shell-quote@1.8.4: {}
shell-quote@1.10.0: {}
side-channel-list@1.0.1:
dependencies:
@@ -14071,10 +14040,6 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
@@ -14405,6 +14370,8 @@ snapshots:
ws@7.5.11: {}
ws@7.5.13: {}
ws@8.21.0: {}
xcode@3.0.1:
@@ -0,0 +1,103 @@
import { Buffer } from 'node:buffer'
export const PROCESS_LINE_MAX_BYTES = 64 * 1024
export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024
const TRUNCATED_LINE_SUFFIX = '… [line truncated]'
export function appendProcessOutputTail(
current,
line,
maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS
) {
if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) {
throw new RangeError('Process output tail limit must be a non-negative safe integer')
}
if (maxCodeUnits === 0) {
return ''
}
const appended = `${current}${line}\n`
return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits)
}
export function attachBoundedProcessLineReader(
stream,
onLine,
maxLineBytes = PROCESS_LINE_MAX_BYTES
) {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
throw new RangeError('Process line limit must be a non-negative safe integer')
}
const retained = Buffer.allocUnsafe(maxLineBytes)
let retainedBytes = 0
let truncated = false
let swallowLineFeed = false
let closed = false
const append = (bytes, start, end) => {
if (start >= end) {
return
}
const available = maxLineBytes - retainedBytes
const copied = Math.min(available, end - start)
if (copied > 0) {
bytes.copy(retained, retainedBytes, start, start + copied)
retainedBytes += copied
}
truncated ||= copied < end - start
}
const emit = () => {
const line = retained.subarray(0, retainedBytes).toString('utf8')
onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line)
retainedBytes = 0
truncated = false
}
const onData = (chunk) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
let start = 0
if (swallowLineFeed) {
swallowLineFeed = false
if (bytes[0] === 0x0a) {
start = 1
}
}
for (let index = start; index < bytes.length; index += 1) {
const value = bytes[index]
if (value !== 0x0a && value !== 0x0d) {
continue
}
append(bytes, start, index)
emit()
if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) {
index += 1
} else if (value === 0x0d && index + 1 === bytes.length) {
swallowLineFeed = true
}
start = index + 1
}
append(bytes, start, bytes.length)
}
const detach = () => {
if (closed) {
return
}
closed = true
stream.off('data', onData)
stream.off('end', onEnd)
}
const onEnd = () => {
if (retainedBytes > 0 || truncated) {
emit()
}
detach()
}
stream.on('data', onData)
stream.on('end', onEnd)
return detach
}
+36
View File
@@ -0,0 +1,36 @@
export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) {
const declaredLength = response.headers.get('content-length')
if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) {
await response.body?.cancel().catch(() => undefined)
return false
}
if (!response.body) {
return false
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let observedBytes = 0
let suffix = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
return `${suffix}${decoder.decode()}`.includes(needle)
}
observedBytes += value.byteLength
if (observedBytes > maxBytes) {
await reader.cancel().catch(() => undefined)
return false
}
const candidate = suffix + decoder.decode(value, { stream: true })
if (candidate.includes(needle)) {
await reader.cancel().catch(() => undefined)
return true
}
suffix = candidate.slice(-Math.max(0, needle.length - 1))
}
} finally {
reader.releaseLock()
}
}
@@ -3,7 +3,10 @@ import { mkdirSync, mkdtempSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
function primaryLanIp(lanIpCandidates) {
return lanIpCandidates()[0] || '127.0.0.1'
@@ -66,15 +69,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
let stderr = ''
let resolved = false
let exited = false
let rl = null
let rlErr = null
let closeStdout = () => {}
let closeStderr = () => {}
const stop = () => {
if (!exited) {
child.kill('SIGTERM')
}
rl?.close()
rlErr?.close()
closeStdout()
closeStderr()
child.stdout?.destroy()
child.stderr?.destroy()
}
@@ -119,15 +122,17 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
reject(error)
}
rl = readline.createInterface({ input: child.stdout })
rl.on('line', (line) => {
output += line + '\n'
closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
handleRuntimeLine(line, finishResolve)
})
rlErr = readline.createInterface({ input: child.stderr })
rlErr.on('line', (line) => {
stderr += line + '\n'
closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => {
if (!resolved) {
stderr = appendProcessOutputTail(stderr, line)
}
})
child.on('error', (error) => {
+43 -14
View File
@@ -22,16 +22,21 @@ import os from 'node:os'
import { promisify } from 'node:util'
import path from 'node:path'
import process from 'node:process'
import readline from 'node:readline'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
import {
appendProcessOutputTail,
attachBoundedProcessLineReader
} from './bounded-process-line-reader.mjs'
import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs'
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
const execFileAsync = promisify(execFile)
const DEFAULT_METRO_PORT = 8081
const METRO_PORT_SEARCH_LIMIT = 100
const METRO_STATUS_MAX_BYTES = 64 * 1024
// Parse CLI arguments
const args = process.argv.slice(2)
@@ -113,6 +118,22 @@ function logInfo(message) {
log(`[info] ${message}`, 'yellow')
}
function createBackpressuredLineWriter(source, target, color) {
let waitingForDrain = false
return (line) => {
const accepted = target.write(color + line + colors.reset + '\n')
if (accepted || waitingForDrain) {
return
}
waitingForDrain = true
source.pause()
target.once('drain', () => {
waitingForDrain = false
source.resume()
})
}
}
function assertIosSimulatorPlatform() {
if (process.platform !== 'darwin') {
throw new Error('iOS Simulator automation requires macOS and Xcode.')
@@ -382,8 +403,10 @@ async function startMetro(worktree) {
let url = null
let resolved = false
let exited = false
let rl = null
let rlErr = null
let closeStdout = () => {}
let closeStderr = () => {}
const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim)
const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red)
const metroResult = () => ({
process: metro,
@@ -391,8 +414,8 @@ async function startMetro(worktree) {
output,
isExited: () => exited,
closeOutput: () => {
rl?.close()
rlErr?.close()
closeStdout()
closeStderr()
metro.stdin?.destroy()
metro.stdout?.destroy()
metro.stderr?.destroy()
@@ -400,10 +423,11 @@ async function startMetro(worktree) {
})
// Parse Metro output for the development URL
rl = readline.createInterface({ input: metro.stdout })
rl.on('line', (line) => {
output += line + '\n'
process.stdout.write(colors.dim + line + colors.reset + '\n')
closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStdoutLine(line)
// Look for "Waiting on" message from Metro
// When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL
@@ -446,10 +470,11 @@ async function startMetro(worktree) {
})
// Also check stderr
rlErr = readline.createInterface({ input: metro.stderr })
rlErr.on('line', (line) => {
output += line + '\n'
process.stderr.write(colors.red + line + colors.reset + '\n')
closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => {
if (!resolved) {
output = appendProcessOutputTail(output, line)
}
writeStderrLine(line)
})
metro.on('error', (error) => {
@@ -547,7 +572,11 @@ async function verifyMetro(url) {
try {
const response = await fetch(statusUrl, { signal: controller.signal })
return (await response.text()).includes('packager-status:running')
return await responseBodyIncludesWithinLimit(
response,
'packager-status:running',
METRO_STATUS_MAX_BYTES
)
} catch {
return false
} finally {
+16 -61
View File
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -42,6 +39,10 @@ import {
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
} from './mobile-browser-view-mode-state'
import {
createMobileBrowserFrameDataUri,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -108,14 +109,7 @@ const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
const browserFrameCache = new MobileBrowserFrameCache()
type BrowserPageParams = {
worktree: string
@@ -143,7 +137,7 @@ export function MobileBrowserPane({
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
const cachedInitialFrame = browserFrameCache.peek(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
@@ -229,7 +223,7 @@ export function MobileBrowserPane({
const subscription = AppState.addEventListener('change', (nextState) => {
const active = nextState === 'active'
if (!active) {
clearCachedBrowserFramesForWorktree(worktreeId)
browserFrameCache.clearWorktree(worktreeId)
}
setAppActive(active)
})
@@ -280,8 +274,14 @@ export function MobileBrowserPane({
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
const nextFrameUri = createBrowserFrameDataUri(frame)
cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
const nextFrameUri = createMobileBrowserFrameDataUri(frame)
if (!nextFrameUri) {
busyRef.current = false
setBusy(false)
setError('Browser frame is too large to display safely.')
return
}
browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
@@ -389,7 +389,7 @@ export function MobileBrowserPane({
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
lastStreamCacheKeyRef.current = cacheKey
if (!sameStream || !frameUriRef.current) {
const cachedFrame = getCachedBrowserFrame(cacheKey)
const cachedFrame = browserFrameCache.get(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
@@ -1303,10 +1303,6 @@ function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
@@ -1315,47 +1311,6 @@ function makeBrowserFrameCacheKey(
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
BrowserScreencastOpcode,
type BrowserScreencastFrame
} from '../transport/browser-screencast-protocol'
import {
createMobileBrowserFrameDataUri,
MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES,
MobileBrowserFrameCache
} from './mobile-browser-frame-cache'
function entry(uri: string) {
return { uri, metadata: {} }
}
function frame(imageBytes: number): BrowserScreencastFrame {
return {
opcode: BrowserScreencastOpcode.Frame,
seq: 1,
format: 'jpeg',
metadata: {},
image: new Uint8Array(imageBytes)
}
}
describe('MobileBrowserFrameCache', () => {
it('preserves LRU behavior below the count and retained-character limits', () => {
const cache = new MobileBrowserFrameCache(2, 100)
cache.set('first', entry('frame-1'))
cache.set('second', entry('frame-2'))
expect(cache.get('first')).toEqual(entry('frame-1'))
cache.set('third', entry('frame-3'))
expect(cache.peek('second')).toBeNull()
expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third'])
})
it('accepts the exact aggregate budget and evicts oldest at one character over', () => {
const cache = new MobileBrowserFrameCache(4, 20)
expect(cache.set('a', entry('x'.repeat(9)))).toBe(true)
expect(cache.set('b', entry('x'.repeat(9)))).toBe(true)
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 })
expect(cache.set('c', entry(''))).toBe(true)
expect(cache.peek('a')).toBeNull()
expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 })
})
it('does not retain one entry larger than the full budget', () => {
const cache = new MobileBrowserFrameCache(4, 10)
expect(cache.set('key', entry('x'.repeat(8)))).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedCharacters: 0,
keysOldestFirst: []
})
})
})
describe('createMobileBrowserFrameDataUri', () => {
it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => {
expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024)
expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==')
expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull()
})
})
@@ -0,0 +1,134 @@
import { Buffer } from 'buffer'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4
export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024
export type MobileBrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
export type MobileBrowserFrameCacheEvidence = {
entryCount: number
retainedCharacters: number
keysOldestFirst: string[]
}
type RetainedFrame = {
entry: MobileBrowserFrameCacheEntry
retainedCharacters: number
}
export class MobileBrowserFrameCache {
private readonly entries = new Map<string, RetainedFrame>()
private retainedCharacters = 0
constructor(
private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES,
private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS
) {
if (
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isSafeInteger(maxRetainedCharacters) ||
maxRetainedCharacters < 1
) {
throw new Error('Mobile browser frame cache limits must be positive integers')
}
}
get(key: string | null): MobileBrowserFrameCacheEntry | null {
if (!key) {
return null
}
const retained = this.entries.get(key)
if (!retained) {
return null
}
this.entries.delete(key)
this.entries.set(key, retained)
return retained.entry
}
peek(key: string | null): MobileBrowserFrameCacheEntry | null {
return key ? (this.entries.get(key)?.entry ?? null) : null
}
set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean {
if (!key) {
return false
}
const retainedCharacters = key.length + entry.uri.length
const previous = this.entries.get(key)
if (previous) {
this.retainedCharacters -= previous.retainedCharacters
this.entries.delete(key)
}
if (retainedCharacters > this.maxRetainedCharacters) {
return false
}
this.entries.set(key, { entry, retainedCharacters })
this.retainedCharacters += retainedCharacters
this.evictOverflow()
return this.entries.has(key)
}
clearWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of this.entries.keys()) {
if (key.startsWith(prefix)) {
this.delete(key)
}
}
}
clear(): void {
this.entries.clear()
this.retainedCharacters = 0
}
evidence(): MobileBrowserFrameCacheEvidence {
return {
entryCount: this.entries.size,
retainedCharacters: this.retainedCharacters,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const retained = this.entries.get(key)
if (!retained) {
return
}
this.retainedCharacters -= retained.retainedCharacters
this.entries.delete(key)
}
private evictOverflow(): void {
while (
this.entries.size > this.maxEntries ||
this.retainedCharacters > this.maxRetainedCharacters
) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
export function createMobileBrowserFrameDataUri(
frame: BrowserScreencastFrame,
maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES
): string | null {
if (frame.image.byteLength > maxImageBytes) {
return null
}
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS,
BROWSER_VIEW_MODE_STATE_LIMIT,
clearMobileBrowserViewModeState,
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
@@ -22,4 +24,28 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
})
it('retains the exact LRU count and evicts the oldest page at one over', () => {
for (let index = 0; index < BROWSER_VIEW_MODE_STATE_LIMIT; index += 1) {
saveMobileBrowserViewMode('worktree', `page-${index}`, 'mobile')
}
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('mobile')
saveMobileBrowserViewMode('worktree', 'one-over', 'mobile')
expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree', 'one-over')).toBe('mobile')
})
it('accepts the exact page-key character limit and rejects one over', () => {
const worktreeId = 'w'
const exactPageId = 'p'.repeat(BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS - 2)
const oversizedPageId = `${exactPageId}p`
saveMobileBrowserViewMode(worktreeId, exactPageId, 'mobile')
saveMobileBrowserViewMode(worktreeId, oversizedPageId, 'mobile')
expect(getInitialMobileBrowserViewMode(worktreeId, exactPageId)).toBe('mobile')
expect(getInitialMobileBrowserViewMode(worktreeId, oversizedPageId)).toBe('web')
})
})
@@ -1,6 +1,7 @@
import type { MobileBrowserViewMode } from './browser-screencast-request'
const BROWSER_VIEW_MODE_STATE_LIMIT = 40
export const BROWSER_VIEW_MODE_STATE_LIMIT = 40
export const BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS = 4_096
const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
export function getInitialMobileBrowserViewMode(
@@ -42,5 +43,9 @@ function makeBrowserViewModePageKey(
worktreeId: string,
browserPageId: string | null
): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}` : null
if (!browserPageId) {
return null
}
const key = `${worktreeId}:${browserPageId}`
return key.length <= BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS ? key : null
}
+80
View File
@@ -0,0 +1,80 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES,
loadHomeSnapshot,
resetHomeSnapshotCacheForTests,
saveHomeSnapshot,
type HomeSnapshot
} from './home-snapshot-cache'
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(),
setItem: vi.fn()
}
}))
function snapshotWithSerializedBytes(serializedBytes: number): HomeSnapshot {
const base = {
worktreeInfo: {},
accountsByHost: {},
savedAt: 1,
padding: ''
}
const baseBytes = JSON.stringify(base).length
return {
...base,
padding: 'x'.repeat(serializedBytes - baseBytes)
} as unknown as HomeSnapshot
}
describe('home snapshot cache', () => {
beforeEach(() => {
vi.useFakeTimers()
resetHomeSnapshotCacheForTests()
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
resetHomeSnapshotCacheForTests()
vi.useRealTimers()
})
it('persists and reloads normal snapshots unchanged', async () => {
const snapshot = snapshotWithSerializedBytes(128)
saveHomeSnapshot(snapshot)
await vi.advanceTimersByTimeAsync(250)
const raw = vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]
expect(raw).toBe(JSON.stringify(snapshot))
resetHomeSnapshotCacheForTests()
vi.mocked(AsyncStorage.getItem).mockResolvedValue(raw ?? null)
await expect(loadHomeSnapshot()).resolves.toEqual(snapshot)
})
it('accepts the exact serialized-byte cap and rejects one byte over', async () => {
const exact = snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
saveHomeSnapshot(exact)
await vi.advanceTimersByTimeAsync(250)
expect(AsyncStorage.setItem).toHaveBeenCalledOnce()
expect(vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]).toHaveLength(
HOME_SNAPSHOT_MAX_SERIALIZED_BYTES
)
vi.mocked(AsyncStorage.setItem).mockClear()
saveHomeSnapshot(snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES + 1))
await vi.advanceTimersByTimeAsync(250)
expect(AsyncStorage.setItem).not.toHaveBeenCalled()
})
it('rejects an oversized durable payload before JSON parsing', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(
`"${'x'.repeat(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)}"`
)
await expect(loadHomeSnapshot()).resolves.toBeNull()
})
})
+50 -1
View File
@@ -5,8 +5,10 @@
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
const STORAGE_KEY = 'orca:home-snapshot:v1'
export const HOME_SNAPSHOT_MAX_SERIALIZED_BYTES = 2 * 1024 * 1024
type WorktreeSummary = {
worktreeId: string
@@ -42,6 +44,12 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
if (!raw) {
return null
}
if (
raw.length > HOME_SNAPSHOT_MAX_SERIALIZED_BYTES ||
utf8ByteLengthExceeds(raw, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
) {
return null
}
const parsed = JSON.parse(raw) as HomeSnapshot
if (
typeof parsed !== 'object' ||
@@ -61,12 +69,53 @@ export async function loadHomeSnapshot(): Promise<HomeSnapshot | null> {
// Why: throttle writes so a flurry of streamed account-snapshot updates
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
let serialized: string
try {
serialized = stringifyMobileOutboundJson(snapshot, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
} catch {
return
}
memoryCache = snapshot
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
void AsyncStorage.setItem(STORAGE_KEY, serialized).catch(() => {})
}, 250)
}
function utf8ByteLengthExceeds(value: string, limit: number): boolean {
let bytes = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 3
}
} else {
bytes += 3
}
if (bytes > limit) {
return true
}
}
return false
}
/** Test-only: clear the process-lifetime snapshot and delayed write. */
export function resetHomeSnapshotCacheForTests(): void {
memoryCache = null
if (writeTimer) {
clearTimeout(writeTimer)
writeTimer = null
}
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { MobileRpcListCache } from './mobile-rpc-list-cache'
describe('MobileRpcListCache', () => {
it('preserves values below the limits and expires them at the existing age boundary', () => {
const cache = new MobileRpcListCache(100, 2, 3, 100)
const values = [{ id: 'repo' }]
expect(cache.set('host', values, 1_000)).toBe(true)
expect(cache.get('host', 1_100)).toBe(values)
expect(cache.get('host', 1_101)).toBeNull()
})
it('accepts the exact item cap and rejects one over', () => {
const cache = new MobileRpcListCache(100, 2, 3, 1_000)
const exact = [1, 2, 3]
expect(cache.set('host', exact)).toBe(true)
expect(cache.get('host')).toBe(exact)
expect(cache.set('host', [...exact, 4])).toBe(false)
expect(cache.get('host')).toBeNull()
})
it('accepts the exact aggregate byte budget and evicts oldest at one over', () => {
const probe = new MobileRpcListCache(100, 3, 3, 1_000)
probe.set('first', ['a'])
const firstBytes = probe.evidence().retainedBytes
probe.clear()
const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2)
expect(cache.set('first', ['a'])).toBe(true)
expect(cache.set('other', ['a'])).toBe(true)
expect(cache.evidence().retainedBytes).toBe(firstBytes * 2)
expect(cache.set('x', [])).toBe(true)
expect(cache.get('first')).toBeNull()
expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2)
})
it('rejects a single payload larger than the full byte budget', () => {
const cache = new MobileRpcListCache(100, 2, 3, 20)
expect(cache.set('host', ['a'.repeat(100)])).toBe(false)
expect(cache.evidence()).toEqual({
entryCount: 0,
retainedBytes: 0,
keysOldestFirst: []
})
})
})
+126
View File
@@ -0,0 +1,126 @@
import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
export type MobileRpcListCacheEvidence = {
entryCount: number
retainedBytes: number
keysOldestFirst: string[]
}
type CacheEntry = {
values: unknown[]
at: number
retainedBytes: number
}
export class MobileRpcListCache {
private readonly entries = new Map<string, CacheEntry>()
private retainedBytes = 0
constructor(
private readonly maxAgeMs: number,
private readonly maxEntries: number,
private readonly maxItemsPerEntry: number,
private readonly maxRetainedBytes: number
) {
if (
!Number.isFinite(maxAgeMs) ||
maxAgeMs < 0 ||
!Number.isInteger(maxEntries) ||
maxEntries < 1 ||
!Number.isInteger(maxItemsPerEntry) ||
maxItemsPerEntry < 1 ||
!Number.isSafeInteger(maxRetainedBytes) ||
maxRetainedBytes < 1
) {
throw new Error('Mobile RPC list cache limits must be positive')
}
}
set(key: string, values: unknown[], now = Date.now()): boolean {
this.delete(key)
if (values.length > this.maxItemsPerEntry) {
return false
}
let serialized: string
try {
serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes)
} catch {
return false
}
const retainedBytes = utf8ByteLength(serialized)
if (retainedBytes > this.maxRetainedBytes) {
return false
}
this.entries.set(key, { values, at: now, retainedBytes })
this.retainedBytes += retainedBytes
this.evictOverflow()
return this.entries.has(key)
}
get(key: string, now = Date.now()): unknown[] | null {
const entry = this.entries.get(key)
if (!entry) {
return null
}
if (now - entry.at > this.maxAgeMs) {
this.delete(key)
return null
}
return entry.values
}
clear(): void {
this.entries.clear()
this.retainedBytes = 0
}
evidence(): MobileRpcListCacheEvidence {
return {
entryCount: this.entries.size,
retainedBytes: this.retainedBytes,
keysOldestFirst: [...this.entries.keys()]
}
}
private delete(key: string): void {
const entry = this.entries.get(key)
if (!entry) {
return
}
this.retainedBytes -= entry.retainedBytes
this.entries.delete(key)
}
private evictOverflow(): void {
while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) {
const oldestKey = this.entries.keys().next().value
if (typeof oldestKey !== 'string') {
return
}
this.delete(oldestKey)
}
}
}
function utf8ByteLength(value: string): number {
let bytes = 0
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code <= 0x7f) {
bytes += 1
} else if (code <= 0x7ff) {
bytes += 2
} else if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1)
if (next >= 0xdc00 && next <= 0xdfff) {
bytes += 4
index += 1
} else {
bytes += 3
}
} else {
bytes += 3
}
}
return bytes
}
+20 -2
View File
@@ -1,8 +1,17 @@
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getCachedRepos, setCachedRepos } from './repo-cache'
import {
getCachedRepos,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
resetRepoCacheForTests,
setCachedRepos
} from './repo-cache'
describe('repo cache', () => {
beforeEach(() => {
resetRepoCacheForTests()
})
it('returns recent host-scoped repos', () => {
const repos = [{ id: 'repo-1' }]
@@ -23,4 +32,13 @@ describe('repo cache', () => {
vi.useRealTimers()
}
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedRepos('host', exact)
expect(getCachedRepos('host')).toBe(exact)
setCachedRepos('host', [...exact, null])
expect(getCachedRepos('host')).toBeNull()
})
})
+18 -24
View File
@@ -2,35 +2,29 @@
// host-scoped cache lets workspace creation open from the last known list while
// a fresh repo.list refresh happens in the background.
type CachedRepos = {
repos: unknown[]
at: number
}
const cache = new Map<string, CachedRepos>()
import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 60_000
const MAX_ENTRIES = 20
export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20
export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_REPO_CACHE_MAX_ENTRIES,
MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_REPO_CACHE_MAX_RETAINED_BYTES
)
export function setCachedRepos(hostId: string, repos: unknown[]): void {
cache.delete(hostId)
cache.set(hostId, { repos, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
cache.set(hostId, repos)
}
export function getCachedRepos(hostId: string): unknown[] | null {
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.repos
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetRepoCacheForTests(): void {
cache.clear()
}
+20 -2
View File
@@ -1,10 +1,19 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
import { beforeEach, describe, expect, it } from 'vitest'
import {
getCachedWorktrees,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
resetWorktreeCacheForTests,
setCachedWorktrees
} from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
beforeEach(() => {
resetWorktreeCacheForTests()
})
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
@@ -40,4 +49,13 @@ describe('worktree-cache write-through', () => {
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
it('retains the exact per-host item cap and rejects one over', () => {
const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null)
setCachedWorktrees('host', exact)
expect(getCachedWorktrees('host')).toBe(exact)
setCachedWorktrees('host', [...exact, null])
expect(getCachedWorktrees('host')).toBeNull()
})
})
+18 -27
View File
@@ -2,38 +2,29 @@
// so the host detail page can render instantly on navigation instead of
// waiting for a fresh RPC connection + fetch cycle.
type CachedWorktrees = {
worktrees: unknown[]
at: number
}
const cache = new Map<string, CachedWorktrees>()
import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 30_000
const MAX_ENTRIES = 20
export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20
export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000
export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
const cache = new MobileRpcListCache(
MAX_AGE_MS,
MOBILE_WORKTREE_CACHE_MAX_ENTRIES,
MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES
)
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
// Why: Map.set on an existing key does not move it to the end of iteration
// order. Delete first so the re-inserted key becomes the newest entry,
// giving us true LRU eviction when the cap is hit.
cache.delete(hostId)
cache.set(hostId, { worktrees, at: Date.now() })
if (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value
if (oldest) {
cache.delete(oldest)
}
}
cache.set(hostId, worktrees)
}
export function getCachedWorktrees(hostId: string): unknown[] | null {
const entry = cache.get(hostId)
if (!entry) {
return null
}
if (Date.now() - entry.at > MAX_AGE_MS) {
cache.delete(hostId)
return null
}
return entry.worktrees
return cache.get(hostId)
}
/** Test-only: clear process-lifetime cache state between cases. */
export function resetWorktreeCacheForTests(): void {
cache.clear()
}
+13 -393
View File
@@ -1,41 +1,6 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'
import {
View,
Pressable,
StyleSheet,
Platform,
useWindowDimensions,
ScrollView,
Keyboard,
BackHandler,
Modal
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
withSpring,
withTiming,
runOnJS,
interpolate,
Extrapolation
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { type ReactNode, useState } from 'react'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
// Why: negative translateY (pulling up) is damped with a rubber-band factor
// so the drawer resists upward dragging — a subtle polish touch that signals
// the drawer cannot expand further.
const RUBBER_BAND_FACTOR = 0.25
const SHOW_DURATION = 180
export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
const TOP_SCROLL_EPSILON = 1
import { MountedBottomDrawer } from './mounted-bottom-drawer'
type Props = {
visible: boolean
@@ -44,6 +9,13 @@ type Props = {
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
// Why: smart-source (and similar) need a stable outer frame so a docked
// TextInput can sit above the keyboard while results reflow in flex space
// above it — content-sized sheets make that field ride every list change.
fillAvailable?: boolean
// Why: pin an outer content-sized sheet under an inner fill picker without
// letting it take touches, draw a second backdrop, or keyboard-lift.
interactive?: boolean
zIndex?: number
}
@@ -54,6 +26,8 @@ export function BottomDrawer({
children,
dragContentToDismiss = true,
contentScrollable = true,
fillAvailable = false,
interactive = true,
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
@@ -81,365 +55,11 @@ export function BottomDrawer({
}}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
interactive={interactive}
zIndex={zIndex}
>
{children}
</MountedBottomDrawer>
)
}
type MountedBottomDrawerProps = Props & {
onHidden: () => void
}
function MountedBottomDrawer({
visible,
onClose,
onHidden,
children,
dragContentToDismiss = true,
contentScrollable = true,
zIndex = 1000
}: MountedBottomDrawerProps) {
const translateY = useSharedValue(0)
const progress = useSharedValue(0)
const keyboardOffset = useSharedValue(0)
const scrollOffsetY = useSharedValue(0)
const contentDragStartY = useSharedValue(0)
const contentDragCanDismiss = useSharedValue(false)
const { height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
useEffect(() => {
if (visible) {
translateY.value = 0
scrollOffsetY.value = 0
progress.value = withTiming(1, { duration: SHOW_DURATION })
} else {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onHidden)()
}
})
}
}, [onHidden, visible])
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
// and give us the exact height to shift the drawer by.
useEffect(() => {
if (!visible) {
return
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (e) => {
const height = e.endCoordinates.height - insets.bottom
keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 })
})
const onHide = Keyboard.addListener(hideEvent, (e) => {
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
})
return () => {
onShow.remove()
onHide.remove()
keyboardOffset.value = 0
}
}, [visible, insets.bottom])
const dismiss = useCallback(() => {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onClose)()
}
})
}, [onClose, progress])
useEffect(() => {
if (!visible) {
return
}
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
dismiss()
return true
})
return () => sub.remove()
}, [visible, dismiss])
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
})
const scrollGesture = Gesture.Native()
const handlePanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY
} else {
translateY.value = e.translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - e.translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const contentPanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onBegin(() => {
contentDragStartY.value = 0
contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
})
.onUpdate((e) => {
// Why: action-sheet content can be taller than the drawer; downward drags
// should scroll back to the top before they start dismissing the sheet.
if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
contentDragCanDismiss.value = false
contentDragStartY.value = 0
if (translateY.value !== 0) {
translateY.value = withSpring(0, SPRING_CONFIG)
}
return
}
if (!contentDragCanDismiss.value) {
contentDragCanDismiss.value = true
contentDragStartY.value = e.translationY
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > 0) {
translateY.value = translationY
} else {
translateY.value = translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
return
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const drawerStyle = useAnimatedStyle(() => ({
transform: [
{
translateY:
interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
translateY.value -
keyboardOffset.value
}
]
}))
const backdropStyle = useAnimatedStyle(() => {
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
return { opacity: progress.value * dragFade }
})
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
// — even when mounted deep inside a ScrollView, where a plain absolute overlay
// anchors to the scrolled content and clips the sheet. Show/hide is driven by
// `progress` (animationType "none") so the reanimated exit animation runs before
// the parent unmounts us.
const overlay = (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// Why: inside a BottomDrawerModalHost the host owns the single native Modal;
// rendering our own would stack modals and reintroduce the iOS present/dismiss
// race the host exists to avoid. The host handles the Android back button.
if (insideModalHost) {
return overlay
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
{overlay}
</Modal>
)
}
const styles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000
},
root: {
flex: 1
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)'
},
anchor: {
flex: 1,
justifyContent: 'flex-end'
},
anchorWide: {
alignItems: 'center'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
opacity: 0.4
},
handleHitArea: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
staticContent: {
minHeight: 0
},
bottomExtension: {
position: 'absolute',
bottom: -500,
left: 0,
right: 0,
height: 500,
backgroundColor: colors.bgBase
}
})
+15 -24
View File
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
@@ -11,15 +10,19 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
import {
CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS,
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../terminal/custom-accessory-key-store'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
export type CustomKey = {
id: string
label: string
bytes: string
enter: boolean
}
export {
loadCustomKeys,
saveCustomKeys,
type CustomKey
} from '../terminal/custom-accessory-key-store'
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
@@ -64,19 +67,6 @@ type Props = {
onManageShortcuts?: () => void
}
export async function loadCustomKeys(): Promise<CustomKey[]> {
try {
const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
return raw ? (JSON.parse(raw) as CustomKey[]) : []
} catch {
return []
}
}
export async function saveCustomKeys(keys: CustomKey[]): Promise<void> {
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
}
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState<Step>('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
@@ -104,8 +94,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
async (key: Omit<CustomKey, 'id'>) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
const updated = [...existing, newKey]
await saveCustomKeys(updated)
const updated = await saveCustomKeys([...existing, newKey])
onKeysChanged(updated)
onClose()
},
@@ -372,6 +361,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS}
/>
<Text style={styles.fieldLabel}>Command</Text>
<TextInput
@@ -382,6 +372,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
maxLength={CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS - 1}
/>
<View style={styles.switchRow}>
<Text style={styles.switchLabel}>Press Enter</Text>
@@ -0,0 +1,200 @@
import { createElement } 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 { HostProtocolGate, useHostProtocolGates } from './HostProtocolGate'
const nativeTestState = vi.hoisted(() => ({
openUrl: vi.fn(),
platform: { OS: 'ios' as 'ios' | 'android' }
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Linking: { openURL: nativeTestState.openUrl },
Platform: nativeTestState.platform,
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('expo-router', () => ({
router: { replace: vi.fn() }
}))
// Why: mock only client acquisition; the gate must exercise the real
// useHostStatusGates → evaluateCompat → ProtocolBlockScreen wiring.
const hostClient = vi.hoisted(() => ({
current: { client: null as RpcClient | null, state: 'disconnected' as string }
}))
vi.mock('../transport/client-context', () => ({
useHostClient: () => hostClient.current
}))
function clientWithStatus(result: Record<string, unknown>): RpcClient {
return { sendRequest: vi.fn().mockResolvedValue({ ok: true, result }) } as unknown as RpcClient
}
function GateConsumer() {
const { hostCapabilities } = useHostProtocolGates()
return createElement('GateStatus', null, hostCapabilities.join(','))
}
function gateElement() {
return createElement(
HostProtocolGate,
{ hostId: 'host-1' },
createElement('HostContent', null, createElement(GateConsumer))
)
}
async function renderGate(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
await act(async () => {
renderer = create(gateElement())
await Promise.resolve()
})
return renderer as unknown as ReactTestRenderer
}
function renderedText(renderer: ReactTestRenderer): string {
return JSON.stringify(renderer.toJSON())
}
describe('HostProtocolGate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
nativeTestState.openUrl.mockClear()
nativeTestState.platform.OS = 'ios'
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('replaces the host UI with the block screen when mobile is too old', async () => {
// Why: blocked warns to console; keep test output clean without hiding other errors.
vi.spyOn(console, 'warn').mockImplementation(() => {})
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).toContain('Open App Store')
expect(output).not.toContain('HostContent')
})
it('routes Android mobile updates to GitHub Releases', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
nativeTestState.platform.OS = 'android'
hostClient.current = {
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca Mobile')
expect(output).toContain('Update Orca Mobile from GitHub Releases')
expect(output).toContain('Open GitHub Releases')
expect(output).not.toContain('mobile app store')
expect(output).not.toContain('HostContent')
act(() => renderer?.root.findAllByType('Pressable')[0]?.props.onPress())
expect(nativeTestState.openUrl).toHaveBeenCalledWith(
'https://github.com/stablyai/orca/releases'
)
})
it('replaces the host UI with the block screen when desktop is too old', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
hostClient.current = {
client: clientWithStatus({ protocolVersion: 0, minCompatibleMobileVersion: 0 }),
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Update Orca on your computer')
expect(output).toContain('Open GitHub Releases')
expect(output).not.toContain('HostContent')
})
it('renders the host UI when the verdict is ok', async () => {
const client = clientWithStatus({
protocolVersion: 5,
minCompatibleMobileVersion: 0,
capabilities: ['browser.screencast.v1']
})
hostClient.current = {
client,
state: 'connected'
}
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('HostContent')
expect(output).toContain('browser.screencast.v1')
expect(output).not.toContain('Update Orca')
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('renders the host UI while the host connection is still pending', async () => {
hostClient.current = { client: null, state: 'connecting' }
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
it('does not mount host routes before a connected host passes the compatibility probe', async () => {
const client = {
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
} as unknown as RpcClient
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
const output = renderedText(renderer)
expect(output).toContain('Checking host compatibility')
expect(output).not.toContain('HostContent')
expect(client.sendRequest).toHaveBeenCalledOnce()
})
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
const client = {
sendRequest: vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { protocolVersion: 5, minCompatibleMobileVersion: 0 }
})
.mockReturnValueOnce(new Promise(() => {}))
} as unknown as RpcClient
hostClient.current = { client, state: 'connected' }
renderer = await renderGate()
await act(async () => {
hostClient.current = { client, state: 'disconnected' }
renderer?.update(gateElement())
})
await act(async () => {
hostClient.current = { client, state: 'connected' }
renderer?.update(gateElement())
await Promise.resolve()
})
expect(renderedText(renderer)).toContain('HostContent')
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('fails open when a connected host cannot answer the status probe', async () => {
hostClient.current = {
client: {
sendRequest: vi.fn().mockResolvedValue({ ok: false, error: { message: 'unavailable' } })
} as unknown as RpcClient,
state: 'connected'
}
renderer = await renderGate()
expect(renderedText(renderer)).toContain('HostContent')
})
})
@@ -0,0 +1,59 @@
import { createContext, useContext, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostStatusGates, type HostStatusGates } from '../transport/host-status-gates'
import { colors } from '../theme/mobile-theme'
import { ProtocolBlockScreen } from './ProtocolBlockScreen'
type Props = {
hostId: string | undefined
children: ReactNode
}
const HostStatusGatesContext = createContext<HostStatusGates | null>(null)
export function useHostProtocolGates(): HostStatusGates {
const gates = useContext(HostStatusGatesContext)
if (!gates) {
throw new Error('useHostProtocolGates must be used inside <HostProtocolGate>')
}
return gates
}
// Why: single choke point above every /h/[hostId] route so a blocked verdict replaces the
// whole host UI (sidebar + detail stack) while the host list and other hosts stay usable.
export function HostProtocolGate({ hostId, children }: Props) {
const { client, state } = useHostClient(hostId)
const gates = useHostStatusGates({ hostId, client, connState: state })
const { compatVerdict, statusPending } = gates
const resolvedHostIdRef = useRef<string | null>(null)
const hostKey = hostId ?? null
if (state === 'connected' && client && !statusPending) {
resolvedHostIdRef.current = hostKey
}
if (statusPending && resolvedHostIdRef.current !== hostKey) {
// Why: child routes may call newer RPCs on mount, so wait until compatibility is known.
return (
<View style={styles.pending}>
<ActivityIndicator
color={colors.textSecondary}
accessibilityLabel="Checking host compatibility"
/>
</View>
)
}
if (compatVerdict.kind === 'blocked') {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
return <HostStatusGatesContext.Provider value={gates}>{children}</HostStatusGatesContext.Provider>
}
const styles = StyleSheet.create({
pending: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgBase
}
})
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, StyleSheet, Switch, Text, View } from 'react-native'
import { Check, Download } from 'lucide-react-native'
import { BottomDrawer } from './BottomDrawer'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { triggerError, triggerSuccess } from '../platform/haptics'
import { useDictationSetupPoller } from '../dictation/use-dictation-setup-poller'
import {
downloadDictationModel,
fetchDictationSetup,
@@ -37,40 +38,34 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
const [setup, setSetup] = useState<MobileSpeechSetup | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<string | null>(null)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const refresh = useCallback(async () => {
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return
return false
}
try {
setSetup(await fetchDictationSetup(client))
const next = await fetchDictationSetup(client)
setSetup(next)
setError(null)
return next.models.some(isModelInFlight)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load')
return undefined
}
}, [client])
const polling = setup?.models.some(isModelInFlight) ?? false
const refreshSetup = useDictationSetupPoller({
visible: visible && client !== null,
polling,
refresh,
intervalMs: POLL_INTERVAL_MS
})
useEffect(() => {
if (visible) {
setError(null)
void refresh()
}
}, [visible, refresh])
// Poll only while something is downloading/extracting; stop otherwise.
useEffect(() => {
const inFlight = setup?.models.some(isModelInFlight) ?? false
if (visible && inFlight && client) {
pollRef.current = setInterval(() => void refresh(), POLL_INTERVAL_MS)
return () => {
if (pollRef.current) {
clearInterval(pollRef.current)
pollRef.current = null
}
}
}
return undefined
}, [visible, setup, client, refresh])
}, [visible])
const handleDownload = useCallback(
async (model: MobileSpeechModel) => {
@@ -81,7 +76,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setError(null)
try {
await downloadDictationModel(client, model.id)
await refresh()
await refreshSetup()
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Download failed')
@@ -89,7 +84,7 @@ export function MobileDictationSetupSheet({ visible, client, onClose, onReady }:
setBusy(null)
}
},
[client, refresh]
[client, refreshSetup]
)
const handleUseModel = useCallback(
+7 -34
View File
@@ -14,8 +14,9 @@ import { ChevronDown, ChevronUp } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { BottomDrawer } from './BottomDrawer'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
@@ -111,12 +112,6 @@ type CreateOptions = {
approvedSetupContentHash?: string
}
type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.
const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
function repoColor(name: string): string {
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
let hash = 0
@@ -196,8 +191,8 @@ function NewWorktreeModalContent({
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
useNewWorktreeDrawerNavigation(visible)
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
@@ -232,29 +227,6 @@ function NewWorktreeModalContent({
[existingWorktrees, selectedRepo]
)
useEffect(() => {
return () => {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
}
}, [])
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): void {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
setDrawerView('transition')
drawerTransitionTimerRef.current = setTimeout(() => {
drawerTransitionTimerRef.current = null
setDrawerView(nextView)
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
}
// The Smart source picker owns the workspace name AND the linked-source
// selection: typing names the workspace and drives source search, and picking
// a source resolves the base/branch/push metadata (matching desktop). The
// creature-name fallback is only computed lazily at submit for a blank name.
const composer = useMobileComposerSource({
client,
selectedRepoId: selectedRepo?.id ?? null,
@@ -830,7 +802,7 @@ function NewWorktreeModalContent({
}
}}
>
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
@@ -876,8 +848,9 @@ function NewWorktreeModalContent({
composer={composer}
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={sshGate.requiresConnection}
interactive={formSheetInteractive}
onBeforeOpen={() => setError('')}
onOpenDrawer={() => transitionDrawer('source')}
onOpenDrawer={openSourceDrawer}
/>
{composer.forkPushWarning ? (
+2 -1
View File
@@ -3,7 +3,8 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { BottomDrawer } from './BottomDrawer'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
type Props<T extends { id: string; label: string }> = {
visible: boolean
+11 -16
View File
@@ -12,14 +12,13 @@ type Props = {
export function ProtocolBlockScreen({ verdict }: Props) {
const isMobileTooOld = verdict.reason === 'mobile-too-old'
// Why: Android APKs ship through GitHub Releases until a Play Store listing exists.
const mobileUpdateTarget =
Platform.OS === 'ios'
? { label: 'Open App Store', url: IOS_APP_STORE_URL, storeName: 'the App Store' }
: { label: null, url: null, storeName: 'your mobile app store' }
: { label: 'Open GitHub Releases', url: RELEASES_URL, storeName: 'GitHub Releases' }
const primaryAction = isMobileTooOld
? mobileUpdateTarget.url && mobileUpdateTarget.label
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
: null
? { label: mobileUpdateTarget.label, url: mobileUpdateTarget.url }
: { label: 'Open GitHub Releases', url: RELEASES_URL }
const title = isMobileTooOld ? 'Update Orca Mobile' : 'Update Orca on your computer'
@@ -34,18 +33,14 @@ export function ProtocolBlockScreen({ verdict }: Props) {
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.body}>{body}</Text>
{/* Why: desktop updates come from GitHub; mobile update links depend
on the native store available for this platform. */}
{primaryAction ? (
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [styles.primaryButton, pressed && styles.pressed]}
onPress={() => {
void Linking.openURL(primaryAction.url)
}}
>
<Text style={styles.primaryButtonText}>{primaryAction.label}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressed]}
onPress={() => {
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
InteractionManager,
Pressable,
StyleSheet,
Text,
TextInput,
View
@@ -25,11 +25,16 @@ import {
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { colors } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { smartWorkspaceSourceDrawerStyles as styles } from './smart-workspace-source-drawer-styles'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
// Why: match MobileSearchField — native autoFocus alone often fails to raise
// the soft keyboard when the drawer is mid-present animation.
const SOURCE_INPUT_FOCUS_DELAY_MS = 120
type Props = {
visible: boolean
client: RpcClient | null
@@ -58,6 +63,7 @@ export function SmartWorkspaceSourceDrawer({
const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability])
const [mode, setMode] = useState<SmartNameMode>(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
const inputRef = useRef<TextInput>(null)
// Why: read latest availability inside the open effect without making it a
// reactive dep (the object is recreated each render), so re-seeding happens
// only on open, not on every availability recompute.
@@ -71,6 +77,26 @@ export function SmartWorkspaceSourceDrawer({
}
}, [visible])
// Why: focus after open interactions settle so the keyboard appears and the
// caret lands in the docked field (same value as the form via composer.name).
useEffect(() => {
if (!visible) {
return
}
let timeout: ReturnType<typeof setTimeout> | undefined
const task = InteractionManager.runAfterInteractions(() => {
timeout = setTimeout(() => {
inputRef.current?.focus()
}, SOURCE_INPUT_FOCUS_DELAY_MS)
})
return () => {
task.cancel()
if (timeout) {
clearTimeout(timeout)
}
}
}, [visible])
// Snap the chosen mode back into the available set if availability changes.
const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text')
@@ -100,10 +126,6 @@ export function SmartWorkspaceSourceDrawer({
repos
})
function closeSoon(): void {
setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS)
}
function handleSelectRow(row: SourceRow): void {
switch (row.kind) {
case 'use-name':
@@ -154,272 +176,146 @@ export function SmartWorkspaceSourceDrawer({
const showEmpty =
!loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
const modeTabs = SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
)
return (
<BottomDrawer
visible={visible}
onClose={onClose}
dragContentToDismiss={false}
contentScrollable={false}
fillAvailable
// Why: sit above the pinned create form so the outer content-sized sheet
// stays underneath and is revealed at its original height on dismiss.
zIndex={1100}
>
<View style={styles.header}>
<Text style={styles.title}>Name or 'Create From'</Text>
<Pressable onPress={closeSoon} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
<TextInput
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<View style={styles.tabRow}>
{SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
).map((option) => {
const selected = option.id === effectiveMode
const tint = selected ? colors.textPrimary : colors.textSecondary
return (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
{/* Why: column with results flex:1 + dock flex-shrink:0 at the end.
Fill sheet height + marginBottom place this column on the keyboard
top; dock must stay a non-flex sibling so FlatList cannot clip it. */}
<View style={styles.root}>
<View style={styles.header}>
<Text style={styles.title}>Name or &apos;Create From&apos;</Text>
<Pressable onPress={onClose} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
) : null}
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
</Text>
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
<View style={styles.results}>
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/
{crossRepoPrompt.link.slug.repo}.
</Text>
</Pressable>
</View>
</View>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable
style={styles.crossRepoSwitch}
onPress={() => void handleAcceptCrossRepo()}
>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Text>
</Pressable>
</View>
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="none"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : rows.length === 0 && effectiveMode === 'text' ? (
<Text style={styles.empty}>Type a workspace name in the field below.</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
</View>
<View style={styles.dock}>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : null}
<View style={styles.tabRow}>
{modeTabs.map((option) => {
const selected = option.id === effectiveMode
const tint = selected ? colors.textPrimary : colors.textSecondary
return (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
<TextInput
ref={inputRef}
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Still request native auto-focus; the delayed ref focus is the reliable path.
autoFocus
returnKeyType="done"
onSubmitEditing={onClose}
blurOnSubmit={false}
/>
</View>
</View>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
done: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.accentBlue
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.sm
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginBottom: spacing.sm
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
chipSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
chipText: {
fontSize: 12,
color: colors.textSecondary
},
chipTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
crossRepo: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.sm
},
crossRepoText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
},
crossRepoDismiss: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
crossRepoDismissText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoSwitch: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
crossRepoSwitchText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
list: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
maxHeight: 420,
flexGrow: 0
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
}
})
@@ -1,4 +1,4 @@
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import { Linking, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import {
CircleDot,
ExternalLink,
@@ -16,6 +16,10 @@ type Props = {
composer: MobileComposerSource
label: string
disabled?: boolean
// Why: only the active form view may focus this field. While the source drawer
// is open/closing this stays non-focusable so the drawer's dismiss (which
// restores native focus back here) can't re-fire onFocus and reopen the drawer.
interactive: boolean
onBeforeOpen?: () => void
onOpenDrawer: () => void
}
@@ -40,6 +44,7 @@ export function SmartWorkspaceSourceField({
composer,
label,
disabled,
interactive,
onBeforeOpen,
onOpenDrawer
}: Props) {
@@ -77,18 +82,24 @@ export function SmartWorkspaceSourceField({
</Pressable>
</View>
) : (
<Pressable
// Why: real TextInput (not a Pressable fake) so the typed value is the
// same composer.name the source drawer docks — focus handoff keeps the
// string continuous even though native focus moves to the docked field.
<TextInput
style={[styles.input, disabled && styles.disabled]}
disabled={disabled}
onPress={openDrawer}
>
<Text
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
numberOfLines={1}
>
{composer.name || 'Type a name or search a source'}
</Text>
</Pressable>
value={composer.name}
onChangeText={composer.setName}
onFocus={openDrawer}
editable={!disabled && interactive}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
// Why: form field is a portal into the picker; return should not
// submit the create form while the drawer is about to open.
blurOnSubmit={false}
showSoftInputOnFocus={false}
/>
)}
</View>
)
@@ -114,17 +125,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle
},
disabled: {
opacity: 0.55
},
inputText: {
borderColor: colors.borderSubtle,
fontSize: typography.bodySize,
color: colors.textPrimary
},
inputPlaceholder: {
color: colors.textMuted
disabled: {
opacity: 0.55
},
pill: {
flexDirection: 'row',
@@ -117,7 +117,7 @@ export function TerminalShortcutSettings({
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
.then(() => saveCustomKeys(next))
.then(() => saveCustomKeys(next).then(() => undefined))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { colors, spacing } from '../theme/mobile-theme'
@@ -18,7 +19,7 @@ type Props = {
// One inline agent row: state dot → identity → last message/prompt → time ago.
// Mirrors desktop DashboardAgentRow's compact in-card layout.
export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
function WorktreeAgentRowComponent({ agent, depth, now, unvisited }: Props) {
const dotState = agentDotState(agent, now)
const label = agentDisplayLabel(agent, now)
const ts = formatTimeAgo(agent.stateStartedAt, now)
@@ -37,6 +38,8 @@ export function WorktreeAgentRow({ agent, depth, now, unvisited }: Props) {
)
}
export const WorktreeAgentRow = memo(WorktreeAgentRowComponent)
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
@@ -0,0 +1,212 @@
import {
createElement,
Fragment,
useCallback,
useState,
type Dispatch,
type SetStateAction
} from 'react'
import { Text } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { WorktreeAgentRow } from './WorktreeAgentRow'
import { WorktreeListRow, type WorktreeListRowItem } from './WorktreeListRow'
const { agentSpinnerRender, agentStateDotRender } = vi.hoisted(() => ({
agentSpinnerRender: vi.fn(),
agentStateDotRender: vi.fn()
}))
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Bell: 'Bell',
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight',
GitBranch: 'GitBranch',
GitPullRequest: 'GitPullRequest'
}))
vi.mock('../platform/haptics', () => ({ triggerMediumImpact: vi.fn() }))
vi.mock('./AgentSpinner', () => ({
AgentSpinner: (props: unknown) => {
agentSpinnerRender(props)
return null
}
}))
vi.mock('./AgentStateDot', () => ({
AgentStateDot: (props: unknown) => {
agentStateDotRender(props)
return null
}
}))
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: () => null }))
vi.mock('./MobileRepoIcon', () => ({ MobileRepoIcon: () => null }))
vi.mock('./WorktreeAgentList', () => ({ WorktreeAgentList: () => null }))
vi.mock('./WorktreeMetaGlyphs', () => ({
prStateColor: () => '#000000',
WorktreeMetaGlyphs: () => null
}))
type TestItem = WorktreeListRowItem & {
status: 'working' | 'active' | 'permission' | 'done' | 'inactive'
lastOutputAt: number
}
const stableRepoIcon = { type: 'emoji', emoji: 'o' } as const
let updateSibling: Dispatch<SetStateAction<number>> = () => undefined
function ListRowHarness({ item, now }: { item: TestItem; now: number }) {
const [sibling, setSibling] = useState(0)
updateSibling = setSibling
const onPress = useCallback(() => undefined, [])
const onLongPress = useCallback(() => undefined, [])
const onToggleLineage = useCallback(() => undefined, [])
// Sibling state changes re-render the harness without changing the row's props,
// exercising the row's React.memo bailout.
return createElement(
Fragment,
null,
createElement(Text, null, sibling),
createElement(WorktreeListRow, {
item,
isReadOnly: false,
now,
repoColor: '#000000',
repoIcon: stableRepoIcon,
hideRepo: false,
status: item.status,
onPress,
onLongPress,
onToggleLineage
})
)
}
function agent(overrides: Partial<RuntimeWorktreeAgentRow> = {}): RuntimeWorktreeAgentRow {
return {
paneKey: 'agent-1',
parentPaneKey: null,
state: 'working',
agentType: null,
prompt: 'Fix the list',
taskTitle: null,
displayName: null,
lastAssistantMessage: null,
toolName: null,
toolInput: null,
interrupted: false,
stateStartedAt: 1_000,
updatedAt: 1_000,
...overrides
}
}
const baseItem: TestItem = {
worktreeId: 'worktree-1',
repo: 'orca',
branch: 'feature/mobile-list',
displayName: 'mobile-list',
liveTerminalCount: 1,
preview: 'Waiting',
unread: false,
linkedPR: null,
agents: [agent()],
status: 'active',
lastOutputAt: 1_000
}
describe('memoized worktree rows', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
agentSpinnerRender.mockClear()
agentStateDotRender.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('skips an unrelated parent render but updates for live item fields and time', async () => {
await act(async () => {
renderer = create(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
})
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
await act(async () => updateSibling((value) => value + 1))
expect(agentSpinnerRender).toHaveBeenCalledTimes(1)
let expectedRenders = 1
const liveUpdates: TestItem[] = [
{ ...baseItem, preview: 'Running tests' },
{ ...baseItem, unread: true },
{ ...baseItem, lastOutputAt: 2_000 },
{ ...baseItem, agents: [agent({ state: 'waiting', updatedAt: 2_000 })] },
{ ...baseItem, status: 'working' }
]
for (const liveUpdate of liveUpdates) {
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: liveUpdate, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 2_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
}
await act(async () =>
renderer!.update(createElement(ListRowHarness, { item: baseItem, now: 32_000 }))
)
expect(agentSpinnerRender).toHaveBeenCalledTimes(++expectedRenders)
})
it('memoizes agent rows without hiding agent updates', async () => {
const firstAgent = agent()
await act(async () => {
renderer = create(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: firstAgent,
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(1)
await act(async () => {
renderer!.update(
createElement(WorktreeAgentRow, {
agent: agent({ state: 'done', updatedAt: 2_000 }),
depth: 0,
now: 2_000,
unvisited: false
})
)
})
expect(agentStateDotRender).toHaveBeenCalledTimes(2)
})
})
+4 -1
View File
@@ -1,3 +1,4 @@
import { memo } from 'react'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
@@ -57,7 +58,7 @@ type Props<T extends WorktreeListRowItem> = {
onToggleLineage?: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
function WorktreeListRowComponent<T extends WorktreeListRowItem>({
item,
isReadOnly,
now,
@@ -195,6 +196,8 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
)
}
export const WorktreeListRow = memo(WorktreeListRowComponent) as typeof WorktreeListRowComponent
const styles = StyleSheet.create({
worktreeRow: {
flexDirection: 'row',
@@ -0,0 +1 @@
export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
describe('resolveBottomDrawerFillHeight', () => {
it('fills the space under the safe top when the keyboard is closed', () => {
expect(
resolveBottomDrawerFillHeight({
screenHeight: 844,
topInset: 54,
keyboardInset: 0,
topGap: 16
})
).toBe(844 - 54 - 16)
})
it('shrinks by the keyboard inset so the sheet top stays under the status bar', () => {
expect(
resolveBottomDrawerFillHeight({
screenHeight: 844,
topInset: 54,
keyboardInset: 292,
topGap: 16
})
).toBe(844 - 54 - 16 - 292)
})
it('never expands past the space above the keyboard on tiny viewports', () => {
expect(
resolveBottomDrawerFillHeight({
screenHeight: 400,
topInset: 50,
keyboardInset: 300,
topGap: 16
})
).toBe(34)
})
it('pairs with marginBottom=keyboardInset so the sheet sits on the keyboard top', () => {
// screen 844, top 54, gap 16, keyboard 292 → height 482; marginBottom 292
// bottom edge at 844-292=552; top edge at 552-482=70 (= 54+16)
const keyboardInset = 292
const height = resolveBottomDrawerFillHeight({
screenHeight: 844,
topInset: 54,
keyboardInset,
topGap: 16
})
const topEdge = 844 - keyboardInset - height
expect(height).toBe(482)
expect(topEdge).toBe(54 + 16)
})
it('keeps the top edge under the status bar when the keyboard is large', () => {
const screenHeight = 400
const topInset = 50
const topGap = 16
const keyboardInset = 300
const height = resolveBottomDrawerFillHeight({
screenHeight,
topInset,
keyboardInset,
topGap
})
const topEdge = screenHeight - keyboardInset - height
expect(topEdge).toBe(topInset + topGap)
})
})
@@ -0,0 +1,19 @@
// Why: fill-mode sheets need a stable outer height so docked chrome (e.g. the
// smart-source TextInput) does not ride result-list reflow. Height shrinks by
// the keyboard inset; the sheet is also lifted with marginBottom equal to that
// inset so the bottom edge sits on the keyboard top (height shrink alone still
// leaves the dock in the keyboard footprint).
export function resolveBottomDrawerFillHeight(input: {
screenHeight: number
topInset: number
keyboardInset: number
topGap?: number
}): number {
const topGap = input.topGap ?? 16
const keyboardInset = Math.max(0, input.keyboardInset)
// Never exceed the space under the status-bar gap and above the keyboard —
// a hard minHeight here would grow the sheet upward under the status bar
// while marginBottom still equals the full keyboard inset.
return Math.max(0, input.screenHeight - input.topInset - topGap - keyboardInset)
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
describe('resolveBottomDrawerKeyboardInset', () => {
it('uses the full keyboard frame for fill sheets on iOS and Android', () => {
expect(
resolveBottomDrawerKeyboardInset({
keyboardHeight: 336,
bottomInset: 34,
fillAvailable: true,
platform: 'ios'
})
).toBe(336)
expect(
resolveBottomDrawerKeyboardInset({
keyboardHeight: 300,
bottomInset: 48,
fillAvailable: true,
platform: 'android'
})
).toBe(300)
})
it('subtracts the home-indicator inset only for iOS content-sized sheets', () => {
expect(
resolveBottomDrawerKeyboardInset({
keyboardHeight: 336,
bottomInset: 34,
fillAvailable: false,
platform: 'ios'
})
).toBe(302)
})
it('uses the full IME height for Android content-sized sheets', () => {
// Why: Android keyboard height does not include the nav bar (session terminal lift).
expect(
resolveBottomDrawerKeyboardInset({
keyboardHeight: 300,
bottomInset: 48,
fillAvailable: false,
platform: 'android'
})
).toBe(300)
})
it('never returns a negative inset', () => {
expect(
resolveBottomDrawerKeyboardInset({
keyboardHeight: 20,
bottomInset: 34,
fillAvailable: false,
platform: 'ios'
})
).toBe(0)
})
})
@@ -0,0 +1,26 @@
// Why: iOS keyboard frame height includes the home-indicator region; Android
// IME height does not include the system nav bar. That split is already used
// by the session terminal keyboard lift — keep fill/content-sized drawers on
// the same contract so OEM/Android and iPhone behave consistently.
//
// Fill sheets dock chrome to the *true keyboard top* via marginBottom + height
// shrink. They always use the raw frame height (subtracting safe-bottom on iOS
// parks the TextInput under the keys). Content-sized sheets keep the legacy
// translate path: iOS subtracts safe-bottom (padding already covers it),
// Android uses the full IME height.
export function resolveBottomDrawerKeyboardInset(input: {
keyboardHeight: number
bottomInset: number
fillAvailable: boolean
platform: 'ios' | 'android' | 'windows' | 'macos' | 'web'
}): number {
const keyboardHeight = Math.max(0, input.keyboardHeight)
if (input.fillAvailable) {
return keyboardHeight
}
if (input.platform === 'ios') {
return Math.max(0, keyboardHeight - Math.max(0, input.bottomInset))
}
return keyboardHeight
}
@@ -0,0 +1,74 @@
import { Platform, StyleSheet } from 'react-native'
import { colors, spacing } from '../theme/mobile-theme'
export const bottomDrawerStyles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
zIndex: 1000
},
root: {
flex: 1
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0,0,0,0.5)'
},
backdropPressable: {
...StyleSheet.absoluteFillObject
},
anchor: {
flex: 1,
justifyContent: 'flex-end'
},
anchorWide: {
alignItems: 'center'
},
drawer: {
backgroundColor: colors.bgBase,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingHorizontal: spacing.md,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.2,
shadowRadius: 10
},
android: { elevation: 8 }
})
},
drawerFill: {
// Why: flex children (results + dock) need a column height budget; without
// this, fill height alone still leaves staticContent height content-sized.
overflow: 'hidden',
flexDirection: 'column'
},
handle: {
alignSelf: 'center',
width: 36,
height: 4,
borderRadius: 2,
backgroundColor: colors.textMuted,
opacity: 0.4
},
handleHitArea: {
alignItems: 'center',
paddingTop: spacing.sm,
paddingBottom: spacing.md
},
staticContent: {
minHeight: 0
},
staticContentFill: {
flex: 1
},
bottomExtension: {
position: 'absolute',
bottom: -500,
left: 0,
right: 0,
height: 500,
backgroundColor: colors.bgBase
}
})
@@ -0,0 +1,400 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'
import {
View,
Pressable,
useWindowDimensions,
ScrollView,
Keyboard,
BackHandler,
Modal,
Platform
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
useSharedValue,
useAnimatedStyle,
useAnimatedScrollHandler,
withSpring,
withTiming,
runOnJS,
interpolate,
Extrapolation
} from 'react-native-reanimated'
import { spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
const SPRING_CONFIG = { damping: 28, stiffness: 400 }
// Why: negative translateY (pulling up) is damped with a rubber-band factor
// so the drawer resists upward dragging — a subtle polish touch that signals
// the drawer cannot expand further.
const RUBBER_BAND_FACTOR = 0.25
const SHOW_DURATION = 180
const TOP_SCROLL_EPSILON = 1
export type MountedBottomDrawerProps = {
visible: boolean
onClose: () => void
onHidden: () => void
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
fillAvailable?: boolean
// Why: outer sheets pinned under an inner fill picker stay laid out (size
// preserved) but must not take touches, stack backdrops, or keyboard-lift.
interactive?: boolean
zIndex?: number
}
export function MountedBottomDrawer({
visible,
onClose,
onHidden,
children,
dragContentToDismiss = true,
contentScrollable = true,
fillAvailable = false,
interactive = true,
zIndex = 1000
}: MountedBottomDrawerProps) {
const translateY = useSharedValue(0)
const progress = useSharedValue(0)
const keyboardOffset = useSharedValue(0)
const scrollOffsetY = useSharedValue(0)
const contentDragStartY = useSharedValue(0)
const contentDragCanDismiss = useSharedValue(false)
// Why: fill mode needs the keyboard inset in React layout (not only the
// reanimated translate) so height shrinks as the sheet lifts and the top
// edge stays under the status bar.
const [keyboardInset, setKeyboardInset] = useState(0)
const { height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
// Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
const fillHeight = fillAvailable
? resolveBottomDrawerFillHeight({
screenHeight,
topInset: insets.top,
keyboardInset,
topGap: spacing.lg
})
: undefined
useEffect(() => {
if (visible) {
translateY.value = 0
scrollOffsetY.value = 0
progress.value = withTiming(1, { duration: SHOW_DURATION })
} else {
Keyboard.dismiss()
setKeyboardInset(0)
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onHidden)()
}
})
}
}, [onHidden, visible])
// Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
// inside Modal (iOS ignores KAV; Android needs adjustNothing for
// useAnimatedKeyboard). Keyboard event listeners work on both platforms
// and give us the exact height to shift the drawer by.
useEffect(() => {
// Pinned-under sheets stay visible for size but must not ride the keyboard —
// only the top interactive sheet owns inset/lift.
if (!visible || !interactive) {
keyboardOffset.value = 0
setKeyboardInset(0)
return
}
function applyKeyboardHeight(keyboardHeight: number, duration = 0): void {
const inset = resolveBottomDrawerKeyboardInset({
keyboardHeight,
bottomInset: insets.bottom,
fillAvailable,
platform: Platform.OS
})
setKeyboardInset(inset)
if (duration > 0) {
keyboardOffset.value = withTiming(inset, { duration })
} else {
keyboardOffset.value = inset
}
}
// Why: fill sheets dock to the true keyboard top; autoFocus can raise the
// keyboard before listeners attach. Seed only in fill mode so content-sized
// outer sheets do not inherit a stale metrics height after an inner dismiss.
if (fillAvailable) {
const existing = Keyboard.metrics()
if (existing != null && existing.height > 0) {
applyKeyboardHeight(existing.height)
}
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (e) => {
applyKeyboardHeight(e.endCoordinates.height, e.duration || 250)
})
const onHide = Keyboard.addListener(hideEvent, (e) => {
setKeyboardInset(0)
keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
})
return () => {
onShow.remove()
onHide.remove()
keyboardOffset.value = 0
setKeyboardInset(0)
}
}, [visible, interactive, insets.bottom, fillAvailable])
const dismiss = useCallback(() => {
Keyboard.dismiss()
progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
if (finished) {
runOnJS(onClose)()
}
})
}, [onClose, progress])
useEffect(() => {
if (!visible || !interactive) {
return
}
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
dismiss()
return true
})
return () => sub.remove()
}, [visible, interactive, dismiss])
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
})
const scrollGesture = Gesture.Native()
const handlePanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onUpdate((e) => {
if (e.translationY > 0) {
translateY.value = e.translationY
} else {
translateY.value = e.translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - e.translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const contentPanGesture = Gesture.Pan()
.activeOffsetY([-8, 8])
.simultaneousWithExternalGesture(scrollGesture)
.onBegin(() => {
contentDragStartY.value = 0
contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
})
.onUpdate((e) => {
// Why: action-sheet content can be taller than the drawer; downward drags
// should scroll back to the top before they start dismissing the sheet.
if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
contentDragCanDismiss.value = false
contentDragStartY.value = 0
if (translateY.value !== 0) {
translateY.value = withSpring(0, SPRING_CONFIG)
}
return
}
if (!contentDragCanDismiss.value) {
contentDragCanDismiss.value = true
contentDragStartY.value = e.translationY
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > 0) {
translateY.value = translationY
} else {
translateY.value = translationY * RUBBER_BAND_FACTOR
}
})
.onEnd((e) => {
if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
return
}
const translationY = e.translationY - contentDragStartY.value
if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
const velocity = Math.max(e.velocityY, 800)
const remaining = screenHeight - translationY
const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
translateY.value = withTiming(screenHeight, { duration })
progress.value = withTiming(0, { duration }, () => {
runOnJS(onClose)()
})
} else {
translateY.value = withSpring(0, SPRING_CONFIG)
}
})
const drawerStyle = useAnimatedStyle(() => {
// Why: fill mode already shrinks height by the keyboard inset and lifts via
// marginBottom (layout). Also subtracting keyboardOffset here would double-
// count and park the dock under the keys (input hidden).
const keyboardShift = fillAvailable ? 0 : keyboardOffset.value
return {
transform: [
{
translateY:
interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
translateY.value -
keyboardShift
}
]
}
})
const backdropStyle = useAnimatedStyle(() => {
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
return { opacity: progress.value * dragFade }
})
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
// — even when mounted deep inside a ScrollView, where a plain absolute overlay
// anchors to the scrolled content and clips the sheet. Show/hide is driven by
// `progress` (animationType "none") so the reanimated exit animation runs before
// the parent unmounts us.
const handle = (
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
)
const body = !contentScrollable ? (
<>
{handle}
<View style={[styles.staticContent, fillAvailable && styles.staticContentFill]}>
{children}
</View>
</>
) : dragContentToDismiss ? (
<>
{handle}
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
{handle}
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)
const overlay = (
<Animated.View
pointerEvents={visible && interactive ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal={interactive}
aria-modal={interactive}
>
<GestureHandlerRootView style={styles.root}>
<Animated.View
// Why: pinned-under outer sheets keep progress=1 so size stays; hide
// their backdrop so only the top interactive drawer dims the canvas.
style={[styles.backdrop, interactive ? backdropStyle : { opacity: 0 }]}
>
{interactive ? <Pressable style={styles.backdropPressable} onPress={dismiss} /> : null}
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
fillAvailable ? styles.drawerFill : null,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
// Why: fill sheets shrink height AND lift with marginBottom so the
// bottom edge sits on the keyboard top (height alone still leaves
// the dock in the keyboard’s footprint). Non-fill sheets keep
// the legacy translateY keyboard shift instead.
height: fillHeight,
marginBottom: fillAvailable ? keyboardInset : 0,
paddingBottom:
fillAvailable && keyboardInset > 0 ? spacing.sm : insets.bottom + spacing.lg
},
drawerStyle
]}
>
{body}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// Why: inside a BottomDrawerModalHost the host owns the single native Modal;
// rendering our own would stack modals and reintroduce the iOS present/dismiss
// race the host exists to avoid. The host handles the Android back button.
if (insideModalHost) {
return overlay
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
{overlay}
</Modal>
)
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
describe('resolveNewWorktreeFormSheetVisible', () => {
it('keeps the form under the source picker and its close transition', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'source',
formPinnedUnderSource: true
})
).toBe(true)
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'transition',
formPinnedUnderSource: true
})
).toBe(true)
})
it('hides the form for sequential repo/agent transitions', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'transition',
formPinnedUnderSource: false
})
).toBe(false)
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'repo',
formPinnedUnderSource: false
})
).toBe(false)
})
})
@@ -0,0 +1,16 @@
// Why: pin the create form under the fill-height name picker (and during that
// picker's close transition) so dismiss reveals the original content height.
export function resolveNewWorktreeFormSheetVisible(input: {
modalVisible: boolean
drawerView: string
formPinnedUnderSource: boolean
}): boolean {
if (!input.modalVisible) {
return false
}
if (input.drawerView === 'form' || input.drawerView === 'source') {
return true
}
return input.drawerView === 'transition' && input.formPinnedUnderSource
}
@@ -167,7 +167,10 @@ function TableBlock({
block: Extract<MarkdownBlock, { kind: 'table' }>
base: number
}) {
const columnCount = Math.max(block.headers.length, ...block.rows.map((r) => r.length), 1)
let columnCount = Math.max(block.headers.length, 1)
for (const row of block.rows) {
columnCount = Math.max(columnCount, row.length)
}
const columns = Array.from({ length: columnCount }, (_, c) => c)
return (
<ScrollView
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
describe('parseMarkdownBlocks', () => {
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
@@ -100,6 +100,42 @@ describe('parseMarkdownBlocks tables', () => {
])
})
it('accepts the exact table column limit and falls back one column later', () => {
const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) =>
String(index)
).join('|')
const delimiter = Array.from(
{ length: COMMENT_MARKDOWN_LIMITS.tableColumns },
() => '---'
).join('|')
expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({
kind: 'table',
headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns }
})
expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({
kind: 'paragraph'
})
})
it('falls back to a bounded preview before splitting oversized input', () => {
const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits)
const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1)
const [block] = parseMarkdownBlocks(content)
expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }])
expect(block).toMatchObject({ kind: 'paragraph' })
expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan(
COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100
)
})
it('checks normalized break tags before allocating line arrays', () => {
const content = '<br>'.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1)
expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' })
})
it('reads per-column alignment from the delimiter row', () => {
const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n')
const block = parseMarkdownBlocks(md)[0]
@@ -40,6 +40,20 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
const SUMMARY = /<summary\b[^>]*>([\s\S]*?)<\/summary>/i
export const COMMENT_MARKDOWN_LIMITS = {
sourceCodeUnits: 1024 * 1024,
lines: 10_000,
detailsDepth: 64,
tableColumns: 256,
tableRows: 2000,
tableCells: 50_000,
fallbackCodeUnits: 64 * 1024
} as const
const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]'
class CommentMarkdownLimitError extends Error {}
// Removes residual HTML tags from rendered text so stray <b>/<kbd>/<sub> etc. don't
// show literally. Conservative: only matches `<tag ...>` / `</tag>` shapes, so a bare
// "a < b" in prose is left alone.
@@ -48,9 +62,56 @@ export function stripHtmlTags(text: string): string {
}
export function parseMarkdownBlocks(content: string): MarkdownBlock[] {
if (!isCommentMarkdownWithinPreparseLimits(content)) {
return commentMarkdownLimitFallback(content)
}
// Drop HTML comments and normalize <br> before block parsing.
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/gi, '\n')
return parseSegment(cleaned)
if (!isCommentMarkdownWithinPreparseLimits(cleaned)) {
return commentMarkdownLimitFallback(content)
}
try {
return parseSegment(cleaned)
} catch (error) {
if (error instanceof CommentMarkdownLimitError) {
return commentMarkdownLimitFallback(content)
}
throw error
}
}
function isCommentMarkdownWithinPreparseLimits(content: string): boolean {
if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) {
return false
}
let lines = 1
let detailsDepth = 0
const detailsPattern = /<\/?details\b/gi
for (let index = 0; index < content.length; index += 1) {
if (content.charCodeAt(index) !== 10) {
continue
}
lines += 1
if (lines > COMMENT_MARKDOWN_LIMITS.lines) {
return false
}
}
for (const match of content.matchAll(detailsPattern)) {
if (match[0][1] === '/') {
detailsDepth = Math.max(0, detailsDepth - 1)
continue
}
detailsDepth += 1
if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) {
return false
}
}
return true
}
function commentMarkdownLimitFallback(content: string): MarkdownBlock[] {
const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits)
return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }]
}
// Splits a segment at top-level <details>/<blockquote> regions (preserving order),
@@ -120,8 +181,17 @@ function parseLines(content: string): MarkdownBlock[] {
const align = parseAlignRow(lines[i + 1])
i += 2
const rows: string[][] = []
let tableCells = headers.length
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
rows.push(splitTableRow(lines[i]))
if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) {
throw new CommentMarkdownLimitError('Table row limit exceeded')
}
const row = splitTableRow(lines[i])
tableCells += row.length
if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) {
throw new CommentMarkdownLimitError('Table cell limit exceeded')
}
rows.push(row)
i += 1
}
blocks.push({ kind: 'table', headers, rows, align })
@@ -207,12 +277,18 @@ function splitTableRow(line: string): string[] {
continue
}
if (ch === '|') {
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
cell = ''
continue
}
cell += ch
}
if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
throw new CommentMarkdownLimitError('Table column limit exceeded')
}
cells.push(cell.trim())
return cells
}
@@ -0,0 +1,179 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const smartWorkspaceSourceDrawerStyles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm,
flexShrink: 0
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
done: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.accentBlue
},
results: {
flex: 1,
minHeight: 0
},
list: {
flex: 1,
backgroundColor: colors.bgPanel,
borderTopLeftRadius: radii.card,
borderTopRightRadius: radii.card,
overflow: 'hidden'
},
listContent: {
flexGrow: 1,
paddingBottom: spacing.sm
},
// Why: pin the dock to the sheet bottom so a flex-greedy FlatList cannot
// push the TextInput out of the fill frame (and under the keyboard).
dock: {
flexShrink: 0,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgBase,
paddingTop: spacing.sm,
paddingBottom: spacing.sm,
gap: spacing.sm,
zIndex: 2
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
chipSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
chipText: {
fontSize: 12,
color: colors.textSecondary
},
chipTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
crossRepo: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.sm
},
crossRepoText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
},
crossRepoDismiss: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
crossRepoDismissText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoSwitch: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
crossRepoSwitchText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
}
})
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.
const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
export function useNewWorktreeDrawerNavigation(modalVisible: boolean): {
drawerView: NewWorktreeDrawerView
formSheetVisible: boolean
formSheetInteractive: boolean
transitionDrawer: (nextView: Exclude<NewWorktreeDrawerView, 'transition'>) => void
openSourceDrawer: () => void
} {
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const formPinnedUnderSourceRef = useRef(false)
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Why: cancel any queued transition and reset when the modal closes, so a
// timer can't land after close and leave a stale drawer/pin for the next open.
useEffect(() => {
if (modalVisible) {
return
}
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
drawerTransitionTimerRef.current = null
}
formPinnedUnderSourceRef.current = false
setDrawerView('form')
}, [modalVisible])
useEffect(() => {
return () => {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
}
}, [])
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): void {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
setDrawerView('transition')
drawerTransitionTimerRef.current = setTimeout(() => {
drawerTransitionTimerRef.current = null
if (nextView === 'form') {
formPinnedUnderSourceRef.current = false
}
setDrawerView(nextView)
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
}
function openSourceDrawer(): void {
// Why: same-beat open; pin form under fill picker so outer content height
// is preserved when the name dialog dismisses.
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
drawerTransitionTimerRef.current = null
formPinnedUnderSourceRef.current = true
setDrawerView('source')
}
return {
drawerView,
formSheetVisible: resolveNewWorktreeFormSheetVisible({
modalVisible,
drawerView,
formPinnedUnderSource: formPinnedUnderSourceRef.current
}),
formSheetInteractive: drawerView === 'form',
transitionDrawer,
openSourceDrawer
}
}
@@ -14,7 +14,7 @@ export const troubleshootCommonIssues: TroubleshootSection[] = [
icon: <WifiOff size={16} color={colors.textSecondary} />,
title: 'Different WiFi Networks',
steps: [
'Both devices must be on the same local network (unless connected through Tailscale).',
'Both devices must be on the same LAN (unless connected through Tailscale).',
'Ethernet and WiFi must share the same subnet.',
'Try reconnecting WiFi on both devices.'
]
@@ -0,0 +1,218 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
const POLL_INTERVAL_MS = 1500
async function flushPromises(): Promise<void> {
// Why: the refresh mock wraps its result in `.finally()` and the resume path chains
// runRefresh → requestRefresh → runRefresh, so the follow-up refresh is several microtask
// hops deep — drain generously rather than a fixed two ticks.
for (let i = 0; i < 8; i += 1) {
await Promise.resolve()
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
} {
let resolve!: (value: T) => void
return {
promise: new Promise<T>((next) => {
resolve = next
}),
resolve
}
}
describe('DictationSetupPollController', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does not refresh while hidden, unfocused, or backgrounded', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setForeground(true)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).not.toHaveBeenCalled()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledOnce()
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
expect(refresh).toHaveBeenCalledTimes(2)
poller.dispose()
})
it('keeps slow refreshes single-flight and waits a full delay after each response', async () => {
const requests = [deferred<boolean>(), deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
requests[1].resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS - 1)
expect(refresh).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(refresh).toHaveBeenCalledTimes(3)
expect(maxActive).toBe(1)
requests[2].resolve(false)
await flushPromises()
poller.dispose()
})
it('coalesces an immediate resume refresh behind a slow request', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
let active = 0
let maxActive = 0
const refresh = vi.fn(() => {
const request = requests[refresh.mock.calls.length - 1]
active += 1
maxActive = Math.max(maxActive, active)
return request.promise.finally(() => {
active -= 1
})
})
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
poller.setForeground(false)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(2)
expect(maxActive).toBe(1)
requests[1].resolve(false)
await flushPromises()
poller.dispose()
})
it('shares one completion while repeated manual refreshes are pending', async () => {
const requests = [deferred<boolean>(), deferred<boolean>()]
const refresh = vi.fn(() => requests[refresh.mock.calls.length - 1].promise)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
const completions = Array.from({ length: 1_000 }, () => poller.refreshNow())
expect(new Set(completions).size).toBe(1)
expect(refresh).toHaveBeenCalledOnce()
requests[0].resolve(true)
await flushPromises()
expect(refresh).toHaveBeenCalledTimes(2)
requests[1].resolve(false)
await expect(completions[0]).resolves.toBeUndefined()
poller.dispose()
})
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
await flushPromises()
poller.setForeground(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 2)
poller.setVisible(true)
expect(refresh).toHaveBeenCalledTimes(3)
poller.dispose()
})
it('stops after setup leaves the download or extraction lifecycle', async () => {
const refresh = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS)
expect(refresh).toHaveBeenCalledTimes(2)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
it('does not resurrect polling when an in-flight refresh resolves true after setPolling(false)', async () => {
const request = deferred<boolean>()
const refresh = vi.fn(() => request.promise)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
poller.setPolling(true)
poller.setVisible(true)
poller.setForeground(true)
expect(refresh).toHaveBeenCalledOnce()
// Explicit stop lands while the read is still on the wire.
poller.setPolling(false)
// The stale read then resolves "keep polling" — the fence must drop it, not restart the poll.
request.resolve(true)
await flushPromises()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL_MS * 4)
expect(refresh).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
poller.dispose()
})
})
@@ -0,0 +1,157 @@
type PollState = {
visible: boolean
foreground: boolean
polling: boolean
}
type RefreshResult = boolean | undefined
export class DictationSetupPollController {
private state: PollState = { visible: false, foreground: false, polling: false }
private timer: ReturnType<typeof setTimeout> | null = null
private inFlight = false
private immediateRefreshPending = false
private refreshCompletion: { promise: Promise<void>; resolve: () => void } | null = null
private disposed = false
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
// fenced out instead of clobbering that intent (e.g. a late `true` resurrecting a just-stopped poll).
private pollingRevision = 0
constructor(
private readonly refresh: () => Promise<RefreshResult>,
private readonly intervalMs: number
) {}
setVisible(visible: boolean): void {
this.update({ visible })
}
setForeground(foreground: boolean): void {
this.update({ foreground })
}
setPolling(polling: boolean): void {
this.pollingRevision += 1
this.update({ polling })
}
refreshNow(): Promise<void> {
if (this.disposed || !this.isEligible()) {
return Promise.resolve()
}
if (!this.refreshCompletion) {
let resolve!: () => void
const promise = new Promise<void>((nextResolve) => {
resolve = nextResolve
})
this.refreshCompletion = { promise, resolve }
}
this.requestRefresh(true)
return this.refreshCompletion.promise
}
dispose(): void {
this.disposed = true
this.immediateRefreshPending = false
this.clearTimer()
this.resolveRefreshCompletion()
}
private update(next: Partial<PollState>): void {
if (this.disposed) {
return
}
const wasEligible = this.isEligible()
const wasPolling = this.state.polling
this.state = { ...this.state, ...next }
if (!this.isEligible()) {
this.immediateRefreshPending = false
this.clearTimer()
return
}
if (!wasEligible) {
this.requestRefresh(true)
return
}
if (!this.state.polling) {
this.clearTimer()
return
}
if (!wasPolling) {
this.scheduleRefresh()
}
}
private isEligible(): boolean {
return this.state.visible && this.state.foreground
}
private requestRefresh(immediate: boolean): void {
if (this.inFlight) {
this.immediateRefreshPending ||= immediate
return
}
this.clearTimer()
this.inFlight = true
void this.runRefresh()
}
private async runRefresh(): Promise<void> {
// Snapshot the lifecycle intent this read is answering; an explicit setPolling during the read makes
// its result stale.
const revisionAtStart = this.pollingRevision
let shouldContinue: RefreshResult
try {
shouldContinue = await this.refresh()
} catch {
// A transient read failure preserves the current lifecycle for a later retry.
shouldContinue = undefined
} finally {
this.inFlight = false
}
// Fence: only let the read drive polling if no explicit setPolling superseded it mid-flight, so a
// late `true` can't resurrect a poll the caller just stopped (nor a late `false` cancel a restart).
if (shouldContinue !== undefined && this.pollingRevision === revisionAtStart) {
this.state.polling = shouldContinue
}
if (this.disposed || !this.isEligible()) {
this.resolveRefreshCompletion()
return
}
if (this.immediateRefreshPending) {
this.immediateRefreshPending = false
this.requestRefresh(true)
return
}
this.resolveRefreshCompletion()
if (this.state.polling) {
this.scheduleRefresh()
}
}
private scheduleRefresh(): void {
if (this.timer !== null || this.inFlight || !this.isEligible() || !this.state.polling) {
return
}
this.timer = setTimeout(() => {
this.timer = null
this.requestRefresh(false)
}, this.intervalMs)
}
private clearTimer(): void {
if (this.timer !== null) {
clearTimeout(this.timer)
this.timer = null
}
}
private resolveRefreshCompletion(): void {
const completion = this.refreshCompletion
this.refreshCompletion = null
completion?.resolve()
}
}
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { AppState } from 'react-native'
import { DictationSetupPollController } from './dictation-setup-poll-controller'
type PollerOptions = {
visible: boolean
polling: boolean
refresh: () => Promise<boolean | undefined>
intervalMs: number
}
export function useDictationSetupPoller({
visible,
polling,
refresh,
intervalMs
}: PollerOptions): () => Promise<void> {
const refreshRef = useRef(refresh)
refreshRef.current = refresh
const poller = useMemo(
() => new DictationSetupPollController(() => refreshRef.current(), intervalMs),
[intervalMs]
)
useEffect(() => () => poller.dispose(), [poller])
useEffect(() => {
void poller.refreshNow()
}, [poller, refresh])
useEffect(() => {
poller.setPolling(polling)
}, [poller, polling])
useEffect(() => {
poller.setVisible(visible)
if (!visible) {
poller.setForeground(false)
return undefined
}
poller.setForeground(AppState.currentState === 'active')
const subscription = AppState.addEventListener('change', (state) => {
poller.setForeground(state === 'active')
})
return () => {
subscription.remove()
poller.setVisible(false)
poller.setForeground(false)
}
}, [poller, visible])
return useCallback(() => poller.refreshNow(), [poller])
}
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
@@ -206,6 +207,18 @@ describe('MobileFileExplorerPanel', () => {
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
it('rejects an extreme directory listing instead of silently truncating it', async () => {
const client = createMockClient({
'': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`))
})
mockTransport.client = client
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile')
expect(renderedText(renderer)).toContain('10,000 items')
})
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
@@ -366,6 +379,43 @@ describe('MobileFileExplorerPanel', () => {
expect(renderedText(renderer)).toContain('Showing first 5000')
})
it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
if (method === 'files.readDir') {
return {
id: 'response-id',
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-id' }
}
}
return {
id: 'response-id',
ok: true,
result: {
files: [
{
relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1),
basename: 'oversized',
kind: 'text'
}
],
totalCount: 1,
truncated: false
},
_meta: { runtimeId: 'runtime-id' }
}
})
}
mockTransport.client = legacyClient
const renderer = await renderExplorer()
expect(renderedText(renderer)).toContain('legacy file list is too large')
expect(renderedText(renderer)).toContain('Update Orca Desktop')
})
it('reports the files.list failure when the fallback itself fails', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise<RpcResponse> => {
+84 -46
View File
@@ -16,14 +16,15 @@ import {
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
type FileExplorerRow,
type MobileDirEntry
type DirectoryState,
type FileExplorerRow
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -36,6 +37,12 @@ import {
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
import {
MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -54,13 +61,48 @@ export function MobileFileExplorerPanel(props: {
const directoryLoadRevisionsRef = useRef<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
const directoryCacheRef = useRef<DirectoryCache>({})
const directoryCacheAccessRef = useRef(0)
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const expandedRef = useRef(expanded)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const commitDirectoryState = useCallback(
(relativePath: string, state: DirectoryState): boolean => {
const retained = retainMobileDirectoryState(
directoryCacheRef.current,
relativePath,
{ ...state, lastAccess: ++directoryCacheAccessRef.current },
expandedRef.current
)
if (!retained.admitted) {
return false
}
directoryCacheRef.current = retained.cache
setDirectoryCache(retained.cache)
if (retained.evictedPaths.length > 0) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths)
for (const pendingPath of pendingDirectoryRetriesRef.current) {
if (
retained.evictedPaths.some(
(evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`)
)
) {
pendingDirectoryRetriesRef.current.delete(pendingPath)
}
}
const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths)
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
}
return true
},
[]
)
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
@@ -77,13 +119,10 @@ export function MobileFileExplorerPanel(props: {
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
}
return
}
@@ -98,13 +137,16 @@ export function MobileFileExplorerPanel(props: {
}
setError(null)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
loading: true
}
}))
const admittedLoading = commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
loading: true
})
if (!admittedLoading) {
forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath])
setLoading(false)
setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
return
}
try {
const response = await client.sendRequest('files.readDir', {
@@ -133,7 +175,9 @@ export function MobileFileExplorerPanel(props: {
return
}
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
const legacyCache = directoryCacheFromFileList(legacyResult.files)
directoryCacheRef.current = legacyCache
setDirectoryCache(legacyCache)
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
@@ -150,14 +194,13 @@ export function MobileFileExplorerPanel(props: {
) {
return
}
const entries = (response as RpcSuccess).result as MobileDirEntry[]
const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result)
if (rootLoad) {
setLegacyListTruncated(false)
}
setDirectoryCache((prev) => ({
...prev,
[relativePath]: { entries }
}))
if (!commitDirectoryState(relativePath, { entries })) {
throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
}
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
@@ -170,13 +213,10 @@ export function MobileFileExplorerPanel(props: {
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
setDirectoryCache((prev) => ({
...prev,
[relativePath]: {
entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
error: message
}
}))
commitDirectoryState(relativePath, {
entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
error: message
})
}
} finally {
if (
@@ -187,7 +227,7 @@ export function MobileFileExplorerPanel(props: {
}
}
},
[client, connState, worktreeId]
[client, commitDirectoryState, connState, worktreeId]
)
useEffect(() => {
@@ -195,17 +235,15 @@ export function MobileFileExplorerPanel(props: {
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
directoryCacheAccessRef.current = 0
setDirectoryCache({})
setExpanded(new Set())
expandedRef.current = new Set()
setExpanded(expandedRef.current)
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
useEffect(() => {
directoryCacheRef.current = directoryCache
}, [directoryCache])
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
@@ -228,21 +266,21 @@ export function MobileFileExplorerPanel(props: {
const toggleDirectory = useCallback(
(relativePath: string) => {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(relativePath)) {
next.delete(relativePath)
} else {
next.add(relativePath)
}
return next
})
const wasExpanded = expandedRef.current.has(relativePath)
const nextExpanded = new Set(expandedRef.current)
if (wasExpanded) {
nextExpanded.delete(relativePath)
} else {
nextExpanded.add(relativePath)
}
expandedRef.current = nextExpanded
setExpanded(nextExpanded)
const state = getDirectoryCacheState(directoryCache, relativePath)
if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
},
[directoryCache, expanded, loadDirectory]
[directoryCache, loadDirectory]
)
const retryDirectory = useCallback(
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -41,4 +42,17 @@ describe('directory-load-revisions', () => {
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
it('invalidates evicted branches without staling unrelated loads', () => {
const revisions = createDirectoryLoadRevisions()
const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib')
const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs')
forgetDirectoryLoadBranches(revisions, ['src'])
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false)
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true)
})
})
@@ -19,6 +19,21 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions):
revisions.revisionsByPath.clear()
}
export function forgetDirectoryLoadBranches(
revisions: DirectoryLoadRevisions,
relativePaths: readonly string[]
): void {
for (const loadedPath of revisions.revisionsByPath.keys()) {
if (
relativePaths.some(
(relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`)
)
) {
revisions.revisionsByPath.delete(loadedPath)
}
}
}
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
+152 -10
View File
@@ -1,7 +1,34 @@
import { describe, expect, it } from 'vitest'
import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
import {
directoryCacheFromFileList,
isMobileMethodUnavailableError,
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES,
LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES,
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES,
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE,
LEGACY_MOBILE_FILE_LIST_MAX_FILES,
LEGACY_MOBILE_FILE_PATH_MAX_BYTES,
LEGACY_MOBILE_FILE_PATH_MAX_DEPTH,
type LegacyMobileFileEntry
} from './file-list-fallback'
import { getDirectoryCacheState } from './file-tree'
const RETAINED_NODE_ESTIMATE_BYTES = 64
function file(relativePath: string): LegacyMobileFileEntry {
return { relativePath, basename: relativePath, kind: 'text' }
}
function directoryChainPath(prefix: string, directories: number): string {
const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd'))
return `${names.join('/')}/file`
}
function fixedLengthUniqueName(index: number, length: number): string {
const suffix = `-${index.toString(36)}`
return `${'x'.repeat(length - suffix.length)}${suffix}`
}
describe('isMobileMethodUnavailableError', () => {
it('detects old-desktop allowlist and missing-method failures', () => {
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
@@ -20,9 +47,9 @@ describe('isMobileMethodUnavailableError', () => {
describe('directoryCacheFromFileList', () => {
it('synthesizes every ancestor directory from flat paths', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' }
file('src/lib/util.ts'),
file('src/app.ts'),
file('README.md')
])
expect(cache['']?.entries).toEqual(
expect.arrayContaining([
@@ -39,11 +66,27 @@ describe('directoryCacheFromFileList', () => {
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
it('preserves empty-segment filtering and first-seen entry order', () => {
const cache = directoryCacheFromFileList([
{ relativePath: 'src', basename: 'src', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
file('//src///lib//util.ts//'),
file('/README.md'),
file('src/app.ts'),
file('///')
])
expect(cache['']?.entries).toEqual([
{ name: 'src', isDirectory: true },
{ name: 'README.md', isDirectory: false }
])
expect(cache['src']?.entries).toEqual([
{ name: 'lib', isDirectory: true },
{ name: 'app.ts', isDirectory: false }
])
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
it('keeps a name a directory when it appears as both file and dir prefix', () => {
const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')])
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
})
@@ -53,13 +96,112 @@ describe('directoryCacheFromFileList', () => {
})
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
const cache = directoryCacheFromFileList([
{ relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
])
const cache = directoryCacheFromFileList([file('__proto__/pollute.js')])
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
{ name: 'pollute.js', isDirectory: false }
])
})
it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => {
const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))])
expect(cache['']?.entries).toEqual([])
})
it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => {
const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2)
expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([
{ name: exactPath, isDirectory: false }
])
expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact path depth and rejects the next segment', () => {
const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/')
const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/'))
expect(
getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath)
).toEqual({
entries: [{ name: 'd', isDirectory: false }]
})
expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects a response above the desktop files.list record cap', () => {
const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file(''))
expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact directory cap and rejects one additional directory', () => {
const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255)
const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255
const files = Array.from({ length: fullChains }, (_, index) =>
file(directoryChainPath(`root-${index}`, 255))
)
if (remainingDirectories > 0) {
files.push(file(directoryChainPath('tail', remainingDirectories)))
}
expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength(
LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES
)
expect(() =>
directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))])
).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
})
it('accepts the exact aggregate entry cap and rejects one additional entry', () => {
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) =>
file(`root-${index}/a/b/file`)
)
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file('root-0/a/b/c/file') : entry
)
const cache = directoryCacheFromFileList(exactFiles)
const entryCount = Object.values(cache).reduce(
(total, state) => total + (state?.entries.length ?? 0),
0
)
expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('accepts the exact retained-byte budget and rejects two bytes over', () => {
const namesBytes =
LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES -
RETAINED_NODE_ESTIMATE_BYTES -
LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES
const namesCharacters = namesBytes / 2
const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES)
const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES
const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => {
const length = baseLength + (index < longerNames ? 1 : 0)
return file(fixedLengthUniqueName(index, length))
})
const overflowFiles = exactFiles.map((entry, index) =>
index === 0 ? file(`${entry.relativePath}x`) : entry
)
expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength(
LEGACY_MOBILE_FILE_LIST_MAX_FILES
)
expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
)
})
it('rejects malformed legacy response shapes with a clear error', () => {
expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list')
expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow(
'invalid legacy file list'
)
})
})
+168 -36
View File
@@ -1,8 +1,22 @@
// Fallback for desktops that predate files.readDir in the mobile RPC
// allowlist: synthesize the lazy directory cache from the flat, capped
// files.list result so the Files tab stays browsable against old desktops.
import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/utf8-byte-limits'
import type { DirectoryCache, MobileDirEntry } from './file-tree'
// Why: each flat legacy path can amplify into many retained ancestor records.
export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000
export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024
export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256
export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384
export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000
export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE =
'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.'
const DIRECTORY_RETAINED_BYTES = 64
const ENTRY_RETAINED_BYTES = 64
export type LegacyMobileFileEntry = {
relativePath: string
basename: string
@@ -15,6 +29,13 @@ export type LegacyFilesListResult = {
truncated: boolean
}
type LegacyFileCacheBuildState = {
childrenByDir: Map<string, Map<string, MobileDirEntry>>
directories: number
entries: number
retainedBytes: number
}
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
// 'forbidden' = method exists but is not mobile-allowlisted on the old
// desktop; 'method_not_found' = desktop predates the method entirely.
@@ -29,45 +50,156 @@ export function isMobileMethodUnavailableError(
)
}
export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
const childrenByDir = new Map<string, Map<string, boolean>>()
const ensureDir = (path: string): Map<string, boolean> => {
let children = childrenByDir.get(path)
if (!children) {
children = new Map()
childrenByDir.set(path, children)
}
return children
export function directoryCacheFromFileList(files: unknown): DirectoryCache {
if (!Array.isArray(files)) {
throw new Error('Desktop returned an invalid legacy file list.')
}
ensureDir('')
if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) {
throwLegacyFileListLimitError()
}
const state: LegacyFileCacheBuildState = {
childrenByDir: new Map(),
directories: 0,
entries: 0,
retainedBytes: 0
}
ensureDirectory(state, '')
for (const file of files) {
const parts = file.relativePath.split('/').filter(Boolean)
let parentPath = ''
parts.forEach((name, index) => {
const isDirectory = index < parts.length - 1
const children = ensureDir(parentPath)
children.set(name, children.get(name) === true || isDirectory)
parentPath = parentPath ? `${parentPath}/${name}` : name
if (isDirectory) {
ensureDir(parentPath)
const relativePath = getLegacyRelativePath(file)
if (relativePath === null) {
throw new Error('Desktop returned an invalid legacy file list.')
}
addFilePath(state, relativePath, measureBoundedPathDepth(relativePath))
}
return createDirectoryCache(state.childrenByDir)
}
function getLegacyRelativePath(value: unknown): string | null {
if (value === null || typeof value !== 'object') {
return null
}
const relativePath = (value as { relativePath?: unknown }).relativePath
return typeof relativePath === 'string' ? relativePath : null
}
function measureBoundedPathDepth(relativePath: string): number {
let bytes = 0
let depth = 0
let insideSegment = false
for (let index = 0; index < relativePath.length; index += 1) {
const codePoint = relativePath.codePointAt(index) ?? 0
bytes += getUtf8ByteLengthForCodePoint(codePoint)
if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) {
throwLegacyFileListLimitError()
}
if (codePoint === 47) {
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
insideSegment = false
} else {
insideSegment = true
}
if (codePoint > 0xffff) {
index += 1
}
}
if (insideSegment) {
depth += 1
assertPathDepth(depth)
}
return depth
}
function assertPathDepth(depth: number): void {
if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) {
throwLegacyFileListLimitError()
}
}
function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void {
let parentPath = ''
let segmentStart = 0
let segmentIndex = 0
for (let cursor = 0; cursor <= relativePath.length; cursor += 1) {
if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) {
continue
}
if (cursor > segmentStart) {
segmentIndex += 1
const name = relativePath.slice(segmentStart, cursor)
const isDirectory = segmentIndex < depth
addDirectoryEntry(state, parentPath, name, isDirectory)
if (isDirectory) {
parentPath = parentPath ? `${parentPath}/${name}` : name
ensureDirectory(state, parentPath)
}
}
segmentStart = cursor + 1
}
}
function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void {
if (state.childrenByDir.has(path)) {
return
}
const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES
if (
state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
state.childrenByDir.set(path, new Map())
state.directories += 1
state.retainedBytes += retainedBytes
}
function addDirectoryEntry(
state: LegacyFileCacheBuildState,
parentPath: string,
name: string,
isDirectory: boolean
): void {
const children = state.childrenByDir.get(parentPath)
if (!children) {
throw new Error('Legacy file cache builder lost its parent directory.')
}
const existing = children.get(name)
if (existing) {
existing.isDirectory ||= isDirectory
return
}
const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES
if (
state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES ||
state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
) {
throwLegacyFileListLimitError()
}
children.set(name, { name, isDirectory })
state.entries += 1
state.retainedBytes += retainedBytes
}
function createDirectoryCache(
childrenByDir: ReadonlyMap<string, ReadonlyMap<string, MobileDirEntry>>
): DirectoryCache {
const cache: DirectoryCache = {}
for (const [path, children] of childrenByDir) {
// Why: assignment to a '__proto__' path would invoke its legacy setter.
Object.defineProperty(cache, path, {
configurable: true,
enumerable: true,
value: { entries: Array.from(children.values()) },
writable: true
})
}
// Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
// object's prototype instead of storing the directory; fromEntries always
// creates own keys.
return Object.fromEntries(
Array.from(childrenByDir, ([path, children]) => [
path,
{
entries: Array.from(
children,
([name, isDirectory]): MobileDirEntry => ({
name,
isDirectory
})
)
}
])
)
return cache
}
function throwLegacyFileListLimitError(): never {
throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
}
+1
View File
@@ -22,6 +22,7 @@ export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
lastAccess?: number
}
export type DirectoryCache = Record<string, DirectoryState | undefined>
@@ -1,44 +1,57 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
function pngBase64(width = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(1, 20)
return bytes.toString('base64')
}
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
const modifiedContent = pngBase64(2)
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
modifiedContent: 'bmV3',
modifiedContent,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('renders an added image diff (no original) from the modified bytes', () => {
const modifiedContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
modifiedContent: 'bmV3',
modifiedContent,
isImage: true,
mimeType: 'image/png'
})
).toBe('data:image/png;base64,bmV3')
).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
const originalContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
originalContent,
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
mimeType: 'image/jpeg'
mimeType: 'image/png'
})
).toBe('data:image/jpeg;base64,b2xk')
).toBe(`data:image/png;base64,${originalContent}`)
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import type { DirectoryCache, DirectoryState } from './file-tree'
import {
MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
parseBoundedMobileDirectoryEntries,
removeEvictedExpandedPaths,
retainMobileDirectoryState
} from './mobile-directory-cache-retention'
function state(name: string, lastAccess: number): DirectoryState {
return { entries: [{ name, isDirectory: false }], lastAccess }
}
describe('mobile directory cache retention', () => {
it('keeps the cache bounded across an unlimited sequence of visited directories', () => {
let cache: DirectoryCache = { '': state('root', 0) }
for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) {
const result = retainMobileDirectoryState(
cache,
`dir-${index}`,
state(`file-${index}`, index),
new Set()
)
expect(result.admitted).toBe(true)
cache = result.cache
}
expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES)
expect(cache['']).toBeDefined()
expect(cache['dir-1']).toBeUndefined()
expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined()
})
it('evicts a collapsed branch before an older expanded branch', () => {
const cache: DirectoryCache = {
'': state('root', 0),
expanded: state('expanded', 1),
collapsed: state('collapsed', 2)
}
const result = retainMobileDirectoryState(
cache,
'new',
state('new', 3),
new Set(['expanded']),
{ directories: 3, entries: 100, retainedBytes: 10_000 }
)
expect(result.cache.expanded).toBeDefined()
expect(result.cache.collapsed).toBeUndefined()
expect(result.evictedPaths).toEqual(['collapsed'])
})
it('evicts old content when aggregate entry retention reaches its cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('old', 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 2,
retainedBytes: 10_000
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('evicts old content when aggregate retained bytes reach their cap', () => {
const cache: DirectoryCache = {
'': state('root', 0),
old: state('x'.repeat(100), 1)
}
const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
directories: 10,
entries: 100,
retainedBytes: 300
})
expect(result.cache.old).toBeUndefined()
expect(result.cache.new).toBeDefined()
})
it('collapses expanded descendants whose cached branch was evicted', () => {
expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([
'docs'
])
})
it('rejects malformed listings instead of retaining untrusted response shapes', () => {
expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow(
'invalid folder listing'
)
})
})
@@ -0,0 +1,156 @@
import {
assertMobileFileDirectoryWithinLimit,
estimateMobileDirectoryEntryBytes,
MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE,
MOBILE_FILE_DIRECTORY_MAX_ENTRIES
} from '../../../src/shared/mobile-file-directory-limit'
import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree'
// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling.
export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128
export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000
export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE =
'Too many folders are open to load this folder safely. Close another folder and retry.'
type CacheLimits = {
directories: number
entries: number
retainedBytes: number
}
type RetentionResult = {
cache: DirectoryCache
evictedPaths: string[]
admitted: boolean
}
const DEFAULT_LIMITS: CacheLimits = {
directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES,
retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES
}
export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] {
if (!Array.isArray(value)) {
throw new Error('Desktop returned an invalid folder listing.')
}
if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) {
throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE)
}
for (const entry of value) {
if (!isMobileDirectoryEntry(entry)) {
throw new Error('Desktop returned an invalid folder listing.')
}
}
const entries = value as MobileDirEntry[]
assertMobileFileDirectoryWithinLimit(entries)
return entries
}
export function retainMobileDirectoryState(
cache: DirectoryCache,
relativePath: string,
state: DirectoryState,
expandedPaths: ReadonlySet<string>,
limits: CacheLimits = DEFAULT_LIMITS
): RetentionResult {
const next: DirectoryCache = { ...cache, [relativePath]: state }
const evictedPaths: string[] = []
const essentialPaths = directoryAncestors(relativePath)
while (cacheExceedsLimits(next, limits)) {
const victim = selectEvictionPath(next, expandedPaths, essentialPaths)
if (victim === null) {
return { cache, evictedPaths: [], admitted: false }
}
for (const path of Object.keys(next)) {
if (path === victim || path.startsWith(`${victim}/`)) {
delete next[path]
evictedPaths.push(path)
}
}
}
return { cache: next, evictedPaths, admitted: true }
}
export function removeEvictedExpandedPaths(
expandedPaths: ReadonlySet<string>,
evictedPaths: readonly string[]
): Set<string> {
if (evictedPaths.length === 0) {
return new Set(expandedPaths)
}
return new Set(
[...expandedPaths].filter(
(expanded) =>
!evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`))
)
)
}
function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry {
if (!value || typeof value !== 'object') {
return false
}
const entry = value as Record<string, unknown>
return (
typeof entry.name === 'string' &&
typeof entry.isDirectory === 'boolean' &&
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
)
}
function directoryAncestors(relativePath: string): Set<string> {
const ancestors = new Set(['', relativePath])
let cursor = relativePath
while (cursor.includes('/')) {
cursor = cursor.slice(0, cursor.lastIndexOf('/'))
ancestors.add(cursor)
}
return ancestors
}
function selectEvictionPath(
cache: DirectoryCache,
expandedPaths: ReadonlySet<string>,
essentialPaths: ReadonlySet<string>
): string | null {
const candidates = Object.keys(cache)
.filter((path) => !essentialPaths.has(path))
.sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right))
return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null
}
function accessOrder(cache: DirectoryCache, path: string): number {
return cache[path]?.lastAccess ?? 0
}
function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean {
const paths = Object.keys(cache)
if (paths.length > limits.directories) {
return true
}
let entries = 0
let retainedBytes = 0
for (const path of paths) {
const state = cache[path]
if (!state) {
continue
}
entries += state.entries.length
retainedBytes += estimateDirectoryStateBytes(path, state)
if (entries > limits.entries || retainedBytes > limits.retainedBytes) {
return true
}
}
return false
}
function estimateDirectoryStateBytes(path: string, state: DirectoryState): number {
let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64
for (const entry of state.entries) {
bytes += estimateMobileDirectoryEntryBytes(entry)
}
return bytes
}
@@ -0,0 +1,116 @@
import { describe, expect, it, vi } from 'vitest'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import {
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
} from '../../../src/shared/protocol-version'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import {
buildMobileFileMutationOwnership,
captureMobileFileMutationOwnership
} from './mobile-file-mutation-ownership'
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function clientWithResponses(responses: RpcResponse[]): {
client: Pick<RpcClient, 'sendRequest'>
sendRequest: ReturnType<typeof vi.fn>
} {
const sendRequest = vi.fn(async () => {
const response = responses.shift()
if (!response) {
throw new Error('Unexpected RPC request')
}
return response
})
return { client: { sendRequest }, sendRequest }
}
function sshState(targetId: string, connectionGeneration: number | undefined): SshConnectionState {
return {
targetId,
status: 'connected',
error: null,
reconnectAttempt: 0,
connectionGeneration
}
}
describe('mobile file mutation ownership', () => {
it.each([undefined, 'local', 'runtime:environment-1'])(
'binds %s worktrees to the runtime-local file host',
(hostId) => {
expect(buildMobileFileMutationOwnership(hostId)).toEqual({
expectedExecutionHostId: 'local'
})
}
)
it('binds SSH worktrees to the target and live connection generation', () => {
expect(
buildMobileFileMutationOwnership('ssh:target%20one', sshState('target one', 17))
).toEqual({
expectedExecutionHostId: 'ssh:target%20one',
expectedSshTargetId: 'target one',
expectedSshConnectionGeneration: 17
})
})
it.each([
['a malformed owner', 'not-an-execution-host', null],
['a missing SSH state', 'ssh:target-1', null],
['a mismatched SSH target', 'ssh:target-1', sshState('target-2', 4)],
['a missing SSH generation', 'ssh:target-1', sshState('target-1', undefined)]
])('rejects %s', (_name, hostId, state) => {
expect(() => buildMobileFileMutationOwnership(hostId, state)).toThrow(
"Couldn't verify the SSH connection"
)
})
it('captures local ownership only after verifying the runtime capability', async () => {
const { client, sendRequest } = clientWithResponses([
success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
success({ worktree: { hostId: 'local' } })
])
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
expectedExecutionHostId: 'local'
})
expect(sendRequest.mock.calls).toEqual([
['status.get', undefined, { timeoutMs: 15_000 }],
['worktree.show', { worktree: 'id:worktree-1' }, { timeoutMs: 15_000 }]
])
})
it('captures SSH generation from the HUB before building mutation params', async () => {
const state = sshState('target-1', 9)
const { client, sendRequest } = clientWithResponses([
success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
success({ worktree: { hostId: 'ssh:target-1' } }),
success({ state })
])
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
expectedExecutionHostId: 'ssh:target-1',
expectedSshTargetId: 'target-1',
expectedSshConnectionGeneration: 9
})
expect(sendRequest.mock.calls[2]).toEqual([
'ssh.getState',
{ targetId: 'target-1' },
{ timeoutMs: 15_000 }
])
})
it('refuses older runtimes before reading or mutating workspace files', async () => {
const { client, sendRequest } = clientWithResponses([success({ capabilities: [] })])
await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).rejects.toThrow(
FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,81 @@
import { parseExecutionHostId } from '../../../src/shared/execution-host'
import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership'
import type { RuntimeStatus } from '../../../src/shared/runtime-types'
import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcFailure, RpcSuccess } from '../transport/types'
const FILE_MUTATION_TIMEOUT_MS = 15_000
const SSH_OWNER_CHANGED_MESSAGE =
"Couldn't verify the SSH connection. Reconnect the host and try again."
export type MobileFileMutationOwnership = SshMutationExpectation & {
expectedExecutionHostId: 'local' | `ssh:${string}`
}
export function buildMobileFileMutationOwnership(
worktreeHostId: string | null | undefined,
sshState: SshConnectionState | null = null
): MobileFileMutationOwnership {
const host = parseExecutionHostId(worktreeHostId)
if (worktreeHostId !== undefined && !host) {
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
}
if (!host || host.kind === 'local' || host.kind === 'runtime') {
return { expectedExecutionHostId: 'local' }
}
if (sshState?.targetId !== host.targetId || sshState.connectionGeneration === undefined) {
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
}
return {
expectedExecutionHostId: host.id,
expectedSshTargetId: host.targetId,
expectedSshConnectionGeneration: sshState.connectionGeneration
}
}
export async function captureMobileFileMutationOwnership(
client: Pick<RpcClient, 'sendRequest'>,
worktree: string
): Promise<MobileFileMutationOwnership> {
const status = await requestResult<Pick<RuntimeStatus, 'capabilities'>>(
client,
'status.get',
undefined
)
assertFileMutationOwnershipCapability(status)
const result = await requestResult<{ worktree?: { hostId?: string | null } }>(
client,
'worktree.show',
{ worktree }
)
if (!result.worktree) {
throw new Error(SSH_OWNER_CHANGED_MESSAGE)
}
const host = parseExecutionHostId(result.worktree.hostId)
const sshState =
host?.kind === 'ssh'
? (
await requestResult<{ state: SshConnectionState | null }>(client, 'ssh.getState', {
targetId: host.targetId
})
).state
: null
return buildMobileFileMutationOwnership(result.worktree.hostId, sshState)
}
async function requestResult<TResult>(
client: Pick<RpcClient, 'sendRequest'>,
method: string,
params: unknown
): Promise<TResult> {
const response = await client.sendRequest(method, params, {
timeoutMs: FILE_MUTATION_TIMEOUT_MS
})
if (!response.ok) {
throw new Error((response as RpcFailure).error.message)
}
return (response as RpcSuccess).result as TResult
}
@@ -28,6 +28,16 @@ function clientWithResponses(responses: RpcResponse[]) {
}
}
function pngBase64(width = 1, height = 1): string {
const bytes = Buffer.alloc(24)
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
bytes.writeUInt32BE(13, 8)
bytes.write('IHDR', 12, 'ascii')
bytes.writeUInt32BE(width, 16)
bytes.writeUInt32BE(height, 20)
return bytes.toString('base64')
}
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
@@ -45,14 +55,13 @@ describe('mobile-file-preview-request', () => {
})
it('loads images through readPreview and never calls files.open', async () => {
const client = clientWith(
ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
)
const content = pngBase64()
const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' }))
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
status: 'ready',
kind: 'image',
dataUri: 'data:image/png;base64,aW1hZ2U='
dataUri: `data:image/png;base64,${content}`
})
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
worktree: 'id:wt-1',
@@ -527,9 +536,9 @@ describe('mobile-file-preview-request', () => {
})
it.each([
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }],
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
])('rejects invalid image preview results: %s', (_label, result) => {
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
@@ -539,6 +548,24 @@ describe('mobile-file-preview-request', () => {
})
})
it('rejects an oversized raster response before React Native receives a data URI', () => {
expect(
normalizeMobileFilePreviewResponse(
'assets/logo.png',
ok({
content: pngBase64(32_769, 1),
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
)
).toEqual({
status: 'error',
message: 'Binary preview unavailable',
reconnect: false
})
})
it('normalizes markdown, html, text, empty, and truncated reads', () => {
expect(
normalizeMobileFilePreviewResponse(

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