mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/mobile-host-card-actions-menu
# Conflicts: # mobile/src/components/MobileHostCard.tsx
This commit is contained in:
@@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build
|
||||
# Contents: Read and write. The secret names below are historical — one App, one
|
||||
# private key, both dev-channel repos — and rotating it stays a single operation.
|
||||
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
|
||||
#
|
||||
# The requested ref is vetted before checkout: it must be a branch or tag of this
|
||||
# repo, or a commit reachable from one. PR refs are refused outright — this
|
||||
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
|
||||
# so "just build that community PR" must not become a way to hand fork code the
|
||||
# release identity. A branch here always belongs to someone with write access,
|
||||
# which is the same trust the dispatch button itself already requires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -45,7 +52,7 @@ on:
|
||||
# than the one the workflow file itself is read from — normally leave the
|
||||
# picker on main and name your branch here, so a stale copy of this
|
||||
# workflow on an old branch is not what runs.
|
||||
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
|
||||
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
@@ -77,6 +84,14 @@ env:
|
||||
jobs:
|
||||
build-adhoc-mac:
|
||||
if: github.repository == 'stablyai/orca'
|
||||
# Why an environment: it gives the signing/notary/App secrets somewhere to
|
||||
# live that a stale copy of this workflow on an old branch cannot reach.
|
||||
# Referencing it is a no-op until repo settings give it teeth; the intended
|
||||
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
|
||||
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
|
||||
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
|
||||
# policy to main so only main's copy of this file can read them.
|
||||
environment: adhoc-mac-build
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
# Why 150: it must exceed the worst case the retry budgets below can produce
|
||||
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
|
||||
@@ -85,6 +100,57 @@ jobs:
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
steps:
|
||||
# Why vet before checkout: everything after this step runs the checked-out
|
||||
# code with release signing credentials in reach. Branches and tags of this
|
||||
# repo are the intended audience; refs/pull/* would smuggle in fork code,
|
||||
# and a raw SHA is only accepted when some branch or tag of this repo can
|
||||
# actually reach it. Resolving to a pinned SHA here also means the commit
|
||||
# that was vetted is the commit that gets checked out — a push to the
|
||||
# branch between the two steps cannot swap it.
|
||||
- name: Vet the requested ref
|
||||
id: vetted
|
||||
shell: bash
|
||||
env:
|
||||
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
|
||||
REPO_URL: https://github.com/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$REQUESTED_REF" in
|
||||
refs/pull/*|pull/*)
|
||||
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# Bare: a work-tree repo refuses to fetch over its own checked-out
|
||||
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
|
||||
# blobs — so this stays cheap next to the build it fronts.
|
||||
scratch="$RUNNER_TEMP/vet-requested-ref"
|
||||
git init -q --bare "$scratch"
|
||||
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||
# Branch first to keep actions/checkout's old tie-break: bare
|
||||
# rev-parse would prefer the tag when a branch shares its name.
|
||||
sha=""
|
||||
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
|
||||
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
|
||||
break
|
||||
fi
|
||||
sha=""
|
||||
done
|
||||
if [[ -z "$sha" ]]; then
|
||||
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
|
||||
exit 1
|
||||
fi
|
||||
# The object resolving locally is not proof a branch or tag reaches
|
||||
# it: a partial clone can lazily fetch a bare SHA on demand, and
|
||||
# GitHub serves PR-only commits by SHA. Reachability is the actual
|
||||
# trust test.
|
||||
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
|
||||
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
|
||||
exit 1
|
||||
fi
|
||||
echo "Vetted $REQUESTED_REF -> $sha"
|
||||
echo "sha=$sha" >>"$GITHUB_OUTPUT"
|
||||
|
||||
- name: Checkout the requested ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -92,7 +158,7 @@ jobs:
|
||||
# build code that has not landed, and the workflow definition itself
|
||||
# always comes from the dispatch ref — naming the branch here instead
|
||||
# applies main's current copy of this file to an arbitrary branch.
|
||||
ref: ${{ inputs.ref || github.ref_name }}
|
||||
ref: ${{ steps.vetted.outputs.sha }}
|
||||
fetch-depth: 0
|
||||
# This job only reads stablyai/orca and never pushes; every write goes
|
||||
# to the adhoc repo through a minted App token passed by env. Not
|
||||
@@ -146,17 +212,26 @@ jobs:
|
||||
env:
|
||||
REF: ${{ inputs.ref || github.ref_name }}
|
||||
LABEL: ${{ inputs.label }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why this check: the version script is read from the branch being built,
|
||||
# Why this check: the version scripts are read from the branch being built,
|
||||
# not from main, so a branch cut before the adhoc channel landed has no
|
||||
# copy of it. Say that plainly instead of failing with a module-not-found.
|
||||
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
|
||||
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
# copy of them. Say that plainly instead of failing with a module-not-found.
|
||||
for script in adhoc-build-version dev-channel-base-version; do
|
||||
if [[ ! -f "config/scripts/$script.mjs" ]]; then
|
||||
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
|
||||
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
|
||||
# Why the main repo's tags: package.json on a branch is as stale as the
|
||||
# main it forked from, and stable patches never merge back into it.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
|
||||
node config/scripts/adhoc-build-version.mjs \
|
||||
>"$RUNNER_TEMP/adhoc-identity.txt"
|
||||
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
|
||||
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
|
||||
|
||||
@@ -20,13 +20,14 @@ on:
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
|
||||
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
|
||||
- 'electron.vite.config.ts'
|
||||
- 'build-plugins/**'
|
||||
- 'config/build-plugins/**'
|
||||
- 'src/main/daemon/**'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
@@ -135,6 +136,11 @@ jobs:
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
# Why: a daemon that lost its endpoint name used to delete the replacement's
|
||||
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
|
||||
# Only two real processes racing the same endpoint reproduce it.
|
||||
- name: Daemon endpoint handover smoke
|
||||
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
# Why: workspace removal overlaps graceful renderer teardown with the
|
||||
# forced main-process sweep; exercise that exact pair against real ConPTY.
|
||||
- name: Windows daemon workspace-close repro
|
||||
|
||||
@@ -179,24 +179,33 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app_token.outputs.token }}
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why read the highest number off existing titles rather than counting
|
||||
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
|
||||
# roll backwards after three days and reissue numbers already in use. The
|
||||
# maximum is always among the retained builds, because pruning drops the
|
||||
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
|
||||
# were the raw tag, so the series simply starts at 01.
|
||||
# Existing titles, which carry the build number this series continues
|
||||
# from. The script picks the number, because it restarts per base version
|
||||
# and only the script knows which base this build resolved to.
|
||||
#
|
||||
# Why drafts count here but not in the freshness check: that check asks
|
||||
# "did this commit ship", where a draft is a no. This one asks "is the
|
||||
# number free", where a stranded draft still holds one.
|
||||
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
|
||||
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
|
||||
build_number=$(( last_number + 1 ))
|
||||
echo "Hourly build number $build_number (previous high: $last_number)"
|
||||
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
|
||||
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
|
||||
--jq '.[].name // empty')"
|
||||
# Why the main repo's tags decide the base version rather than
|
||||
# package.json: main's version only moves on `release:` commits, and
|
||||
# stable patches are cut from release branches that never merge back, so
|
||||
# package.json can sit several patches behind what users are running. A
|
||||
# separate token because GH_TOKEN above is the App's, scoped to the
|
||||
# hourly repo. Empty on failure — the script then falls back to
|
||||
# package.json, which is stale but never wrong enough to fail a build.
|
||||
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
echo "Highest published tag seen: $(head -1 <<<"$published")"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
|
||||
node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
|
||||
@@ -233,8 +233,32 @@ jobs:
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
# Why fetch-depth 0: the harness extracts the newest release tag to skew
|
||||
# current code against it. The default shallow clone has no tags, which is
|
||||
# why this cannot ride along in the sharded `test` job.
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
|
||||
# A path filter that matches nothing exits 1 ("No test files found"), so this
|
||||
# lane cannot report success while running zero tests.
|
||||
- name: Old/new client and server terminal journey
|
||||
run: pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
|
||||
package:
|
||||
name: package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
'!config/reliability-gates.jsonc',
|
||||
'!config/max-lines-baseline.txt',
|
||||
'!config/vitest.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'native/**',
|
||||
'resources/**',
|
||||
'electron.vite.config.ts',
|
||||
|
||||
@@ -100,6 +100,8 @@ docs/**
|
||||
!docs/reference/headless-linux-server.md
|
||||
!docs/reference/linux-glibc-compatibility.md
|
||||
!docs/reference/relay-grace-time-reconfiguration.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
!docs/reference/windows-setup-shell.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
@@ -134,3 +136,6 @@ src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts
|
||||
tests/tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
# Old release trees the cross-version wire harness extracts on demand
|
||||
tests/e2e/.cross-version-checkouts/
|
||||
|
||||
+1
-1
@@ -150,5 +150,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,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 `\`.
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **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
|
||||
@@ -39,6 +40,10 @@ All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
All changes must consider folder workspaces as well as git worktrees. Don't assume every workspace is a git worktree.
|
||||
|
||||
## Remote Wire Compatibility
|
||||
|
||||
Clients and remote Orca servers update independently, so mixed versions are the normal state. Before changing anything a paired client and host exchange — RPC params, stream frames, or the content either side publishes over them — follow [`docs/reference/remote-wire-compatibility.md`](./docs/reference/remote-wire-compatibility.md). A new optional field is safe; a new stream opcode must be capability-negotiated because decoders drop unknown opcodes silently; and changing what the host publishes reaches old clients even with no wire change.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
export const BOOTSTRAP_FATAL_LOG_ENV_VAR = 'ORCA_BOOTSTRAP_FATAL_LOG'
|
||||
export const BOOTSTRAP_FATAL_LOG_FILE_NAME = 'bootstrap-fatal.log'
|
||||
const BOOTSTRAP_FATAL_LOG_MAX_BYTES = 262_144
|
||||
+28
-3
@@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
// not registered on worker threads, so require("electron") throws
|
||||
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
||||
// the worker at startup. These carry hand-written "must stay electron-free"
|
||||
// comments, which is convention, not enforcement — and the port-scan worker in
|
||||
// particular sits one import away from a client module that deliberately does
|
||||
// require electron.
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
@@ -56,13 +73,14 @@ function collectReachableChunks(
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
@@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
@@ -23,7 +24,7 @@
|
||||
"src/relay/relay.ts",
|
||||
"src/relay/wsl-agent-hook-relay.ts",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"build-plugins/**/*.ts",
|
||||
"config/build-plugins/**/*.ts",
|
||||
"electron.vite.config.ts",
|
||||
"vite.web.config.ts",
|
||||
"config/vitest.config.ts",
|
||||
@@ -33,7 +34,11 @@
|
||||
"tests/**/*.{ts,tsx,mjs}",
|
||||
"**/*.test.{ts,tsx}"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}", "config/scripts/**/*.{mjs,cjs,js,ts}", "build-plugins/**/*.ts"],
|
||||
"project": [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"config/scripts/**/*.{mjs,cjs,js,ts}",
|
||||
"config/build-plugins/**/*.ts"
|
||||
],
|
||||
"ignore": ["mobile/**", "out/**", "dist/**", "node_modules/**", "resources/**"],
|
||||
"ignoreDependencies": ["electron", "@types/*"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+803
-16
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
/** Long enough to name a feature, short enough that a picker row stays readable. */
|
||||
export const ADHOC_LABEL_MAX_LENGTH = 32
|
||||
@@ -67,7 +71,7 @@ export function normalizeAdhocLabel(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
|
||||
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
|
||||
* title, shown verbatim in both the GitHub releases list and the build picker.
|
||||
*
|
||||
* Why the label sits where hourly puts its build number: several adhoc builds
|
||||
@@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getAdhocBuildIdentity(now = new Date(), label = '') {
|
||||
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createAdhocBuildVersion(packageJson.version, now)
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
|
||||
const version = createAdhocBuildVersion(base, now)
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
@@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
|
||||
const identity = getAdhocBuildIdentity(
|
||||
new Date(),
|
||||
process.env.ORCA_ADHOC_LABEL ?? '',
|
||||
readPublishedVersionsFromEnv()
|
||||
)
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
|
||||
|
||||
@@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => {
|
||||
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
|
||||
|
||||
it('renders version, label, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
|
||||
})
|
||||
|
||||
it('sanitizes the label before it reaches the title', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
|
||||
'1.4.163 • fix-now • 07-31 13:54 • e698241'
|
||||
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
|
||||
|
||||
// Why: Windows releases the image handle on a just-executed exe (and finishes the
|
||||
// AV scan of the freshly compiled one) after the process exits, so tearing down the
|
||||
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
|
||||
// teardown lock as a launcher failure.
|
||||
function removeFixtureTree(path) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
} catch (error) {
|
||||
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
|
||||
expect(result.stderr).toContain('Windows CLI launcher')
|
||||
expect(result.stderr).toContain('Windows host')
|
||||
} finally {
|
||||
rmSync(outputRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(outputRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
|
||||
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
|
||||
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
|
||||
const source = readFileSync(
|
||||
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
|
||||
'utf8'
|
||||
)
|
||||
const code = source.replace(/^\s*\/\/.*$/gm, '')
|
||||
|
||||
expect(code).not.toContain('EnvironmentVariables')
|
||||
expect(code).not.toContain('startInfo.Environment')
|
||||
expect(code).toContain('Environment.SetEnvironmentVariable')
|
||||
})
|
||||
|
||||
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
|
||||
try {
|
||||
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
|
||||
itWindows('survives an inherited environment block containing PATH and Path', () => {
|
||||
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
|
||||
try {
|
||||
const resourcesPath = join(appRoot, 'resources')
|
||||
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
|
||||
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
|
||||
const outputPath = join(appRoot, 'child-result.json')
|
||||
const harnessSourcePath = join(
|
||||
projectRoot,
|
||||
'config',
|
||||
'scripts',
|
||||
'fixtures',
|
||||
'DuplicatePathProcessLauncher.cs'
|
||||
)
|
||||
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
|
||||
mkdirSync(dirname(launcherPath), { recursive: true })
|
||||
mkdirSync(dirname(cliPath), { recursive: true })
|
||||
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
|
||||
writeFileSync(
|
||||
cliPath,
|
||||
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
|
||||
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
|
||||
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
|
||||
}))\n`,
|
||||
'utf8'
|
||||
)
|
||||
const build = spawnSync(
|
||||
process.execPath,
|
||||
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
|
||||
{ cwd: projectRoot, encoding: 'utf8' }
|
||||
)
|
||||
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
|
||||
|
||||
const compiler = findFrameworkCompiler()
|
||||
expect(compiler).not.toBeNull()
|
||||
const compileHarness = spawnSync(
|
||||
compiler,
|
||||
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
|
||||
{ encoding: 'utf8' }
|
||||
)
|
||||
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
|
||||
|
||||
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
|
||||
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
|
||||
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
|
||||
electronRunAsNode: '1',
|
||||
pathKeys: ['PATH', 'Path']
|
||||
})
|
||||
} finally {
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function findFrameworkCompiler() {
|
||||
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
|
||||
if (!windowsDirectory) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
[
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
|
||||
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
|
||||
].find((candidate) => existsSync(candidate)) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ describe('computer-use e2e workflow', () => {
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'config/build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
])
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
@@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) {
|
||||
return join(userDataDir, 'daemon.sock')
|
||||
}
|
||||
|
||||
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
|
||||
// Connects a single control socket, completes the hello handshake, and calls
|
||||
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
|
||||
// true on success, false on any failure — never throws.
|
||||
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
return new Promise((resolveCheck) => {
|
||||
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
|
||||
return new Promise((resolveRpc, rejectRpc) => {
|
||||
let settled = false
|
||||
let buffer = ''
|
||||
const socket = connect(socketPath)
|
||||
const finish = (ok, reason) => {
|
||||
const finish = (error, response) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
socket.destroy()
|
||||
if (!ok && reason) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${reason}`)
|
||||
if (error) {
|
||||
rejectRpc(error)
|
||||
} else {
|
||||
resolveRpc(response)
|
||||
}
|
||||
resolveCheck(ok)
|
||||
}
|
||||
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
|
||||
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
|
||||
|
||||
socket.on('error', (err) => finish(false, err.message))
|
||||
socket.on('error', (error) => finish(error))
|
||||
socket.on('connect', () => {
|
||||
const token = readFileSync(tokenPath, 'utf8').trim()
|
||||
socket.write(
|
||||
@@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
finish(false, 'invalid response line')
|
||||
finish(new Error('invalid response line'))
|
||||
return
|
||||
}
|
||||
if (msg.type === 'hello') {
|
||||
if (!msg.ok) {
|
||||
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
|
||||
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
|
||||
return
|
||||
}
|
||||
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
|
||||
} else if (msg.id === 'health-1') {
|
||||
socket.write(`${JSON.stringify(request)}\n`)
|
||||
} else if (msg.id === request.id) {
|
||||
finish(
|
||||
msg.ok === true,
|
||||
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
|
||||
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
|
||||
msg
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
})
|
||||
}
|
||||
|
||||
// Best-effort: constrained CI runners can make node-pty spawn flaky.
|
||||
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
|
||||
try {
|
||||
await runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{ id: 'health-1', type: 'ptySpawnHealth' },
|
||||
PTY_HEALTH_TIMEOUT_MS
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
|
||||
const socketPath = makeSocketPath(userDataDir)
|
||||
const tokenPath = join(userDataDir, 'daemon.token')
|
||||
const pidPath = join(userDataDir, 'daemon.pid')
|
||||
const launchNonce = randomUUID()
|
||||
const protocolVersion = readProtocolVersion()
|
||||
|
||||
log(`forking ${entryPath} under plain Node (${process.execPath})`)
|
||||
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
})
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
launchNonce,
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'daemon-boot-smoke'
|
||||
],
|
||||
{
|
||||
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
|
||||
// CI, and this is exactly the runtime where a leaked `require("electron")`
|
||||
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
|
||||
}
|
||||
)
|
||||
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
process.stdout.write(chunk)
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
||||
@@ -185,6 +215,25 @@ async function main() {
|
||||
})
|
||||
})
|
||||
log('daemon signaled ready')
|
||||
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
|
||||
if (
|
||||
pidRecord.pid !== child.pid ||
|
||||
pidRecord.launchNonce !== launchNonce ||
|
||||
pidRecord.entryPath !== entryPath ||
|
||||
pidRecord.appVersion !== 'daemon-boot-smoke'
|
||||
) {
|
||||
throw new Error('daemon readiness did not publish the expected PID ownership record')
|
||||
}
|
||||
log('PID ownership record matches the ready daemon')
|
||||
if (!existsSync(socketPath)) {
|
||||
throw new Error('daemon did not publish its endpoint at the canonical socket path')
|
||||
}
|
||||
log('endpoint published at the canonical socket path')
|
||||
// Production releases startup-only handles after ready; they can pin the child on Windows.
|
||||
// Diagnostics past this point therefore carry the tail captured up to readiness only.
|
||||
child.stderr?.destroy()
|
||||
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
|
||||
child.disconnect()
|
||||
|
||||
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
|
||||
if (ptyHealthy) {
|
||||
@@ -193,18 +242,39 @@ async function main() {
|
||||
|
||||
await new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => {
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
|
||||
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
|
||||
}, SHUTDOWN_TIMEOUT_MS)
|
||||
child.on('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
log(`daemon exited after signal (code=${code}, signal=${signal})`)
|
||||
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
|
||||
resolveExit()
|
||||
})
|
||||
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
|
||||
// Windows has no POSIX signal delivery, so Node maps this to process
|
||||
// termination. Either way the hard assertion is "it stops, no hang".
|
||||
child.kill('SIGTERM')
|
||||
void runDaemonRpc(
|
||||
socketPath,
|
||||
tokenPath,
|
||||
protocolVersion,
|
||||
{
|
||||
id: 'shutdown-1',
|
||||
type: 'shutdown',
|
||||
payload: { killSessions: false }
|
||||
},
|
||||
SHUTDOWN_TIMEOUT_MS
|
||||
).catch((error) => {
|
||||
clearTimeout(timer)
|
||||
rejectExit(error)
|
||||
})
|
||||
})
|
||||
if (existsSync(pidPath)) {
|
||||
throw new Error('daemon left its PID ownership record behind after shutdown')
|
||||
}
|
||||
if (process.platform !== 'win32' && existsSync(socketPath)) {
|
||||
throw new Error('daemon left its endpoint socket behind after shutdown')
|
||||
}
|
||||
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
|
||||
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
|
||||
if (leaked.length > 0) {
|
||||
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
|
||||
}
|
||||
|
||||
log('PASS: daemon booted, served, and shut down under plain Node')
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
|
||||
*
|
||||
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
|
||||
* alive used to delete the *replacement's* socket when it finally exited, because libuv
|
||||
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
|
||||
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
|
||||
* run it, and that a user cannot fix by restarting the app.
|
||||
*
|
||||
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
|
||||
*
|
||||
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { connect } from 'node:net'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
|
||||
const READY_TIMEOUT_MS = 20_000
|
||||
const EXIT_TIMEOUT_MS = 15_000
|
||||
|
||||
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
|
||||
|
||||
function bootDaemon(tag, dir, socketPath) {
|
||||
const tokenPath = join(dir, `${tag}.token`)
|
||||
const pidPath = join(dir, `${tag}.pid`)
|
||||
const child = fork(
|
||||
entryPath,
|
||||
[
|
||||
'--socket',
|
||||
socketPath,
|
||||
'--token',
|
||||
tokenPath,
|
||||
'--pid-record',
|
||||
pidPath,
|
||||
'--launch-nonce',
|
||||
randomUUID(),
|
||||
'--entry-path',
|
||||
entryPath,
|
||||
'--app-version',
|
||||
'endpoint-handover-smoke'
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
|
||||
}
|
||||
)
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
return new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
|
||||
READY_TIMEOUT_MS
|
||||
)
|
||||
child.on('message', (msg) => {
|
||||
if (msg && typeof msg === 'object' && msg.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady({ child, tokenPath, pidPath })
|
||||
}
|
||||
})
|
||||
child.on('exit', (code) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isReachable(socketPath) {
|
||||
return new Promise((resolveReachable) => {
|
||||
const socket = connect({ path: socketPath })
|
||||
socket.on('connect', () => {
|
||||
socket.destroy()
|
||||
resolveReachable(true)
|
||||
})
|
||||
socket.on('error', () => resolveReachable(false))
|
||||
})
|
||||
}
|
||||
|
||||
function killAndWait(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolveExit, rejectExit) => {
|
||||
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolveExit()
|
||||
})
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform === 'win32') {
|
||||
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
|
||||
return
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
|
||||
const socketPath = join(dir, 'daemon.sock')
|
||||
let replaced
|
||||
let replacement
|
||||
try {
|
||||
replaced = await bootDaemon('replaced', dir, socketPath)
|
||||
const replacedInode = statSync(socketPath).ino
|
||||
log('daemon A published the endpoint')
|
||||
|
||||
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
|
||||
unlinkSync(socketPath)
|
||||
replacement = await bootDaemon('replacement', dir, socketPath)
|
||||
const replacementInode = statSync(socketPath).ino
|
||||
if (replacedInode === replacementInode) {
|
||||
throw new Error('daemon B did not publish a distinct endpoint')
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B is not reachable through the canonical endpoint')
|
||||
}
|
||||
log('daemon B took over the endpoint and is reachable')
|
||||
|
||||
// A exits long after losing the endpoint. This is the step that used to break B.
|
||||
await killAndWait(replaced.child)
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
|
||||
throw new Error("daemon A's late exit deleted daemon B's endpoint")
|
||||
}
|
||||
if (!(await isReachable(socketPath))) {
|
||||
throw new Error('daemon B became unreachable after daemon A exited')
|
||||
}
|
||||
if (!existsSync(replacement.pidPath)) {
|
||||
throw new Error("daemon A's late exit removed daemon B's ownership record")
|
||||
}
|
||||
if (existsSync(replaced.pidPath)) {
|
||||
throw new Error('daemon A left its own ownership record behind')
|
||||
}
|
||||
|
||||
log('PASS: the endpoint owner and the session host stayed the same daemon')
|
||||
} finally {
|
||||
for (const daemon of [replaced, replacement]) {
|
||||
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
|
||||
try {
|
||||
daemon.child.kill('SIGKILL')
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
|
||||
|
||||
function parseVersionTriple(value) {
|
||||
const match = SEMVER.exec(String(value ?? '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function compareTriples(a, b) {
|
||||
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
}
|
||||
|
||||
/**
|
||||
* The base `X.Y.Z` an hourly or adhoc build should carry.
|
||||
*
|
||||
* Why not package.json alone: main's version only moves on `release:` commits, and
|
||||
* stable patches are cut from release branches that never merge back. On
|
||||
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
|
||||
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
|
||||
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
|
||||
* already running. Published tags are the only honest answer to "what number is
|
||||
* taken"; package.json is a floor, not a source of truth.
|
||||
*/
|
||||
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
|
||||
const fromPackage = parseVersionTriple(packageVersion)
|
||||
if (!fromPackage) {
|
||||
throw new Error(`Package version is not valid semver: ${packageVersion}`)
|
||||
}
|
||||
|
||||
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
|
||||
// that predate the current scheme, and one of them must not fail every build.
|
||||
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
|
||||
|
||||
let base = fromPackage
|
||||
if (published.length > 0) {
|
||||
const highest = published.reduce((best, entry) =>
|
||||
compareTriples(entry, best) > 0 ? entry : best
|
||||
)
|
||||
// A shipped stable owns its number, so the next dev build belongs on the patch
|
||||
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
|
||||
// the version being worked toward, which is exactly what main is building.
|
||||
const shipped = published.some(
|
||||
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
|
||||
)
|
||||
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
|
||||
if (compareTriples(next, base) > 0) {
|
||||
base = next
|
||||
}
|
||||
}
|
||||
|
||||
return `${base.major}.${base.minor}.${base.patch}`
|
||||
}
|
||||
|
||||
/** Tag list the workflow reads out of the main repo, newline separated. */
|
||||
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
|
||||
return String(value ?? '')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
describe('dev channel base version', () => {
|
||||
it('falls back to package.json when no tags are supplied', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
|
||||
// stables shipped, so hourlies claimed a version their users had already passed.
|
||||
it('climbs past stables that shipped while main stood still', () => {
|
||||
expect(
|
||||
resolveDevChannelBaseVersion('1.4.165-rc.0', [
|
||||
'v1.4.165',
|
||||
'v1.4.166',
|
||||
'v1.4.167',
|
||||
'v1.4.165-rc.0'
|
||||
])
|
||||
).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
|
||||
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
|
||||
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
|
||||
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
|
||||
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
|
||||
})
|
||||
|
||||
// Why max and not most-recent: a hotfix on an old line published today would
|
||||
// otherwise drag every subsequent hourly backwards.
|
||||
it('reads the highest tag, not the last one listed', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats package.json as a floor when it leads the tags', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
|
||||
})
|
||||
|
||||
// Why skipped rather than fatal: the main repo carries legacy tags, and one
|
||||
// unparseable entry must not fail every hourly build.
|
||||
it('ignores tags it cannot parse', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
|
||||
'1.4.168'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a package version that is not semver', () => {
|
||||
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
|
||||
})
|
||||
|
||||
it('carries major and minor rollovers through the bump', () => {
|
||||
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
|
||||
})
|
||||
|
||||
it('splits an env tag list on any whitespace', () => {
|
||||
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
|
||||
expect(readPublishedVersionsFromEnv('')).toEqual([])
|
||||
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
BOOTSTRAP_FATAL_LOG_ENV_VAR,
|
||||
BOOTSTRAP_FATAL_LOG_FILE_NAME,
|
||||
createBootstrapFatalExitBanner
|
||||
} from '../../build-plugins/bootstrap-fatal-exit-banner'
|
||||
} from '../build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { electronViteConfig } from '../../electron.vite.config'
|
||||
import { BOOTSTRAP_FATAL_EXIT_GUARD_KEY } from '../../src/main/startup/bootstrap-fatal-exit-guard'
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => {
|
||||
expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js')
|
||||
})
|
||||
|
||||
it('externalizes packaged dependencies but bundles the daemon xterm graph', () => {
|
||||
it('externalizes packaged dependencies but bundles self-contained main dependencies', () => {
|
||||
const external = electronViteConfig.main?.build?.rollupOptions?.external
|
||||
if (typeof external !== 'function') {
|
||||
throw new Error('Expected main-process external predicate')
|
||||
@@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => {
|
||||
expect(external('node:fs', undefined, false)).toBe(true)
|
||||
expect(external('@xterm/headless', undefined, false)).toBe(false)
|
||||
expect(external('@xterm/addon-serialize', undefined, false)).toBe(false)
|
||||
expect(external('psl', undefined, false)).toBe(false)
|
||||
expect(external('zod', undefined, false)).toBe(false)
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl')
|
||||
expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
internal static class DuplicatePathProcessLauncher
|
||||
{
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
private struct StartupInfo
|
||||
{
|
||||
public int cb;
|
||||
public string lpReserved;
|
||||
public string lpDesktop;
|
||||
public string lpTitle;
|
||||
public int dwX;
|
||||
public int dwY;
|
||||
public int dwXSize;
|
||||
public int dwYSize;
|
||||
public int dwXCountChars;
|
||||
public int dwYCountChars;
|
||||
public int dwFillAttribute;
|
||||
public int dwFlags;
|
||||
public short wShowWindow;
|
||||
public short cbReserved2;
|
||||
public IntPtr lpReserved2;
|
||||
public IntPtr hStdInput;
|
||||
public IntPtr hStdOutput;
|
||||
public IntPtr hStdError;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct ProcessInformation
|
||||
{
|
||||
public IntPtr hProcess;
|
||||
public IntPtr hThread;
|
||||
public int dwProcessId;
|
||||
public int dwThreadId;
|
||||
}
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern bool CreateProcess(
|
||||
string applicationName,
|
||||
StringBuilder commandLine,
|
||||
IntPtr processAttributes,
|
||||
IntPtr threadAttributes,
|
||||
bool inheritHandles,
|
||||
uint creationFlags,
|
||||
IntPtr environment,
|
||||
string currentDirectory,
|
||||
ref StartupInfo startupInfo,
|
||||
out ProcessInformation processInformation
|
||||
);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
|
||||
|
||||
[DllImport("kernel32.dll", SetLastError = true)]
|
||||
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern bool CloseHandle(IntPtr handle);
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
List<string> entries = new List<string>();
|
||||
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
|
||||
{
|
||||
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
entries.Add((string)entry.Key + "=" + (string)entry.Value);
|
||||
}
|
||||
}
|
||||
entries.Add("PATH=C:\\live");
|
||||
entries.Add("Path=C:\\shadowed");
|
||||
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
|
||||
|
||||
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
|
||||
StartupInfo startupInfo = new StartupInfo();
|
||||
startupInfo.cb = Marshal.SizeOf(startupInfo);
|
||||
ProcessInformation processInformation;
|
||||
try
|
||||
{
|
||||
bool started = CreateProcess(
|
||||
args[0],
|
||||
new StringBuilder("\"" + args[0] + "\""),
|
||||
IntPtr.Zero,
|
||||
IntPtr.Zero,
|
||||
false,
|
||||
0x00000400,
|
||||
environment,
|
||||
null,
|
||||
ref startupInfo,
|
||||
out processInformation
|
||||
);
|
||||
if (!started)
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.FreeHGlobal(environment);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
|
||||
uint exitCode;
|
||||
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
|
||||
{
|
||||
throw new Win32Exception(Marshal.GetLastWin32Error());
|
||||
}
|
||||
return (int)exitCode;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CloseHandle(processInformation.hThread);
|
||||
CloseHandle(processInformation.hProcess);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
installHappyDomMutationObserverRetention,
|
||||
retainedMutationCallbackCount
|
||||
} from './happy-dom-mutation-observer-retention'
|
||||
|
||||
function readListenerCallbacks(target: Node): unknown[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === 'mutationListeners'
|
||||
)
|
||||
const listeners = listenersSymbol
|
||||
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
: []
|
||||
if (!Array.isArray(listeners)) {
|
||||
return []
|
||||
}
|
||||
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
|
||||
listener.callback?.deref()
|
||||
)
|
||||
}
|
||||
|
||||
describe('happy-dom MutationObserver retention', () => {
|
||||
it('pins the internal callback that happy-dom only holds weakly', () => {
|
||||
expect(installHappyDomMutationObserverRetention()).toBe(true)
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
const observer = new MutationObserver(() => {})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
const callbacks = readListenerCallbacks(target)
|
||||
expect(callbacks.length).toBe(1)
|
||||
expect(callbacks[0]).toBeTypeOf('function')
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(1)
|
||||
|
||||
observer.disconnect()
|
||||
expect(retainedMutationCallbackCount(observer)).toBe(0)
|
||||
target.remove()
|
||||
})
|
||||
|
||||
it('keeps delivering records after the weak callback would have been collected', async () => {
|
||||
installHappyDomMutationObserverRetention()
|
||||
const target = document.createElement('div')
|
||||
document.body.append(target)
|
||||
let deliveries = 0
|
||||
const observer = new MutationObserver(() => {
|
||||
deliveries += 1
|
||||
})
|
||||
observer.observe(target, { childList: true, subtree: true })
|
||||
|
||||
target.replaceChildren(document.createElement('div'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(1)
|
||||
|
||||
const collectGarbage = (globalThis as { gc?: () => void }).gc
|
||||
if (collectGarbage) {
|
||||
for (let round = 0; round < 5; round += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
collectGarbage()
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
|
||||
target.replaceChildren(document.createElement('span'))
|
||||
await Promise.resolve()
|
||||
expect(deliveries).toBe(2)
|
||||
|
||||
observer.disconnect()
|
||||
target.remove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
|
||||
// under parallel test load silently and permanently stops a still-connected observer. Browsers
|
||||
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
|
||||
// DOM-driven tests never lose mutation records mid-run.
|
||||
|
||||
type HappyDomMutationListener = {
|
||||
callback?: { deref: () => unknown }
|
||||
}
|
||||
|
||||
type PatchableMutationObserver = {
|
||||
observe: (target: Node, options?: MutationObserverInit) => void
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
|
||||
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
|
||||
|
||||
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
|
||||
|
||||
function readMutationListeners(target: Node): HappyDomMutationListener[] {
|
||||
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
|
||||
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
|
||||
)
|
||||
if (!listenersSymbol) {
|
||||
return []
|
||||
}
|
||||
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
|
||||
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
|
||||
}
|
||||
|
||||
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
|
||||
export function retainedMutationCallbackCount(observer: MutationObserver): number {
|
||||
return retainedCallbacks.get(observer)?.size ?? 0
|
||||
}
|
||||
|
||||
export function installHappyDomMutationObserverRetention(): boolean {
|
||||
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
|
||||
.MutationObserver
|
||||
if (!observerClass) {
|
||||
return false
|
||||
}
|
||||
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
|
||||
Record<symbol, unknown>
|
||||
if (prototype[RETENTION_INSTALLED] === true) {
|
||||
return true
|
||||
}
|
||||
const observe = prototype.observe
|
||||
const disconnect = prototype.disconnect
|
||||
|
||||
prototype.observe = function patchedObserve(
|
||||
this: object,
|
||||
target: Node,
|
||||
options?: MutationObserverInit
|
||||
): void {
|
||||
const existing = new Set(readMutationListeners(target))
|
||||
observe.call(this as unknown as PatchableMutationObserver, target, options)
|
||||
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
|
||||
for (const listener of readMutationListeners(target)) {
|
||||
if (existing.has(listener)) {
|
||||
continue
|
||||
}
|
||||
const callback = listener.callback?.deref()
|
||||
if (callback) {
|
||||
pinned.add(callback)
|
||||
}
|
||||
}
|
||||
if (pinned.size > 0) {
|
||||
retainedCallbacks.set(this, pinned)
|
||||
}
|
||||
}
|
||||
|
||||
prototype.disconnect = function patchedDisconnect(this: object): void {
|
||||
disconnect.call(this as unknown as PatchableMutationObserver)
|
||||
retainedCallbacks.delete(this)
|
||||
}
|
||||
|
||||
prototype[RETENTION_INSTALLED] = true
|
||||
return true
|
||||
}
|
||||
|
||||
installHappyDomMutationObserverRetention()
|
||||
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
|
||||
import {
|
||||
readPublishedVersionsFromEnv,
|
||||
resolveDevChannelBaseVersion
|
||||
} from './dev-channel-base-version.mjs'
|
||||
|
||||
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
|
||||
* by semver and every build is uniquely versioned. */
|
||||
@@ -30,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
* The next build number for `baseVersion`, counting from the titles of existing
|
||||
* hourly releases.
|
||||
*
|
||||
* Why the series restarts at 01 on every base version: the number answers "which
|
||||
* build of 1.4.163 is this", so a counter shared across versions makes it
|
||||
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
|
||||
*
|
||||
* Why the maximum rather than a count: the prune step trims to
|
||||
* HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
|
||||
* already in use. Titles that predate this naming simply do not match, which is
|
||||
* how the first build of a version lands on 01.
|
||||
*/
|
||||
export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
|
||||
const prefix = `${baseVersion} • `
|
||||
const highest = releaseNames.reduce((max, entry) => {
|
||||
const name = String(entry ?? '')
|
||||
if (!name.startsWith(prefix)) {
|
||||
return max
|
||||
}
|
||||
const match = /^(\d+) • /.exec(name.slice(prefix.length))
|
||||
return match ? Math.max(max, Number(match[1])) : max
|
||||
}, 0)
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
@@ -45,20 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
|
||||
// Why the number is derived here rather than passed in: it counts builds of the
|
||||
// base version, and the base is only known once the published tags have been
|
||||
// resolved just above. Computing it outside meant numbering against whatever
|
||||
// version the caller guessed.
|
||||
export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const version = createHourlyBuildVersion(packageJson.version, now)
|
||||
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
|
||||
const version = createHourlyBuildVersion(base, now)
|
||||
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
buildNumber,
|
||||
name: formatHourlyReleaseName(version, buildNumber, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
|
||||
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
|
||||
const identity = getHourlyBuildIdentity(new Date(), {
|
||||
publishedVersions: readPublishedVersionsFromEnv(),
|
||||
// Titles are newline separated and contain spaces, so this cannot reuse the
|
||||
// whitespace split the version list gets.
|
||||
releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
|
||||
})
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
|
||||
import {
|
||||
createHourlyBuildVersion,
|
||||
formatHourlyReleaseName,
|
||||
nextHourlyBuildNumber
|
||||
} from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createHourlyBuildVersion', () => {
|
||||
@@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => {
|
||||
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
|
||||
})
|
||||
|
||||
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
|
||||
// read as an hour that does not exist and sort oddly beside 00:xx.
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
|
||||
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
|
||||
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
|
||||
it('renders both noon and midnight as 12', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
|
||||
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
|
||||
})
|
||||
|
||||
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
|
||||
it('joins the meridiem with no whitespace of any kind', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
|
||||
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
@@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextHourlyBuildNumber', () => {
|
||||
const titles = [
|
||||
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
|
||||
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
|
||||
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
|
||||
]
|
||||
|
||||
it('continues the series for the version being built', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
|
||||
})
|
||||
|
||||
// The point of the change: 1.4.164 opens its own series at 01 rather than
|
||||
// inheriting 1.4.163's count, which said nothing about 1.4.164.
|
||||
it('restarts at 1 when the base version moves', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
|
||||
expect(
|
||||
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
|
||||
// would roll backwards and reissue a number already used.
|
||||
it('takes the highest number, not the count', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
|
||||
})
|
||||
|
||||
it('starts at 1 with no history at all', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
|
||||
})
|
||||
|
||||
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
|
||||
// a prefix of 1.4.163's series.
|
||||
it('ignores titles that are not this version', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
},
|
||||
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
|
||||
'auto.components.PullRequestPage.7b8f6bf6d8': {
|
||||
zh: '开放'
|
||||
},
|
||||
// This "Open" is the button that opens the config file, not an issue state.
|
||||
'auto.components.settings.McpConfigFileRow.e720c139cd': {
|
||||
zh: '打开'
|
||||
},
|
||||
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
|
||||
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
'auto.components.onboarding.ThemeStep.ab2a583a97': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
|
||||
'auto.components.sidebar.workspace.status.6437a8c253': {
|
||||
zh: '天蓝',
|
||||
ja: '空色'
|
||||
},
|
||||
'auto.components.sidebar.workspace.status.caabd5ca85': {
|
||||
zh: '锌灰'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
|
||||
// these, so the policy must neither pin the whole value to English nor revert the translation
|
||||
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
|
||||
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
|
||||
|
||||
// Renderings a locale is expected to use. They are translations, never machine-translation
|
||||
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
|
||||
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
|
||||
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
|
||||
const GENERIC_TERM_FAMILIES = [
|
||||
{
|
||||
terms: ['Agent', 'Agents', 'agent', 'agents'],
|
||||
renderings: {
|
||||
ko: ['에이전트'],
|
||||
ja: ['エージェント'],
|
||||
zh: ['代理', '智能体'],
|
||||
es: ['Agente', 'agente', 'Agentes', 'agentes']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Commit', 'Commits', 'commit', 'commits'],
|
||||
renderings: {
|
||||
ko: ['커밋'],
|
||||
ja: ['コミット'],
|
||||
zh: ['提交'],
|
||||
es: [
|
||||
'Confirmación',
|
||||
'confirmación',
|
||||
'Confirmaciones',
|
||||
'confirmaciones',
|
||||
'Confirmar',
|
||||
'confirmar'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Continue'],
|
||||
renderings: {
|
||||
ko: ['계속하다', '계속'],
|
||||
ja: ['続ける', '続行'],
|
||||
zh: ['继续'],
|
||||
es: ['Continuar', 'continuar']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Repo', 'Repos', 'repo', 'repos'],
|
||||
renderings: {
|
||||
ko: ['저장소', '레포'],
|
||||
ja: ['リポジトリ', 'リポ'],
|
||||
zh: ['存储库', '仓库'],
|
||||
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
|
||||
renderings: {
|
||||
ko: ['터미널'],
|
||||
ja: ['ターミナル'],
|
||||
zh: ['终端'],
|
||||
es: ['Terminales', 'terminales']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const LOCALIZABLE_GENERIC_TERMS = new Set(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
|
||||
)
|
||||
|
||||
const RENDERINGS_BY_TERM = new Map(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
|
||||
)
|
||||
|
||||
export function isLocalizableGenericTerm(term) {
|
||||
return LOCALIZABLE_GENERIC_TERMS.has(term)
|
||||
}
|
||||
|
||||
export function isCanonicalGenericRendering(term, locale, form) {
|
||||
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
|
||||
}
|
||||
|
||||
export function canonicalGenericRenderings(locale) {
|
||||
return GENERIC_TERM_FAMILIES.flatMap((family) =>
|
||||
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
|
||||
)
|
||||
}
|
||||
|
||||
function spansOf(value, needle) {
|
||||
const spans = []
|
||||
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
|
||||
spans.push([at, at + needle.length])
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
|
||||
// electrical-connector mistranslation would cut a valid word in half.
|
||||
export function overlapsCanonicalRendering(term, locale, value, start, end) {
|
||||
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
|
||||
return renderings.some((form) =>
|
||||
spansOf(value, form).some(([from, to]) => start < to && from < end)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
|
||||
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
|
||||
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
|
||||
// the boundary: generic words stay translated, real brand names stay Latin.
|
||||
describe('locale generic UI terms', () => {
|
||||
it('keeps a translated generic term when the whole value is that term', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AppearancePane.terminalTitle',
|
||||
enValue: 'Terminal',
|
||||
localeValue: '터미널',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
|
||||
enValue: 'Agent',
|
||||
localeValue: '에이전트',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('继续')
|
||||
})
|
||||
|
||||
it('keeps a translated generic term inside a sentence', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.EditorFontSection.matchTerminal',
|
||||
enValue: 'Match terminal font',
|
||||
localeValue: '터미널 글꼴과 동일',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 글꼴과 동일')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
|
||||
enValue: 'Toggle Agent Dashboard',
|
||||
localeValue: '에이전트 대시보드 전환',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트 대시보드 전환')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'components.agentSessionContinuation.dialogTitle',
|
||||
enValue: 'Continue in New Session',
|
||||
localeValue: '新しいセッションで続ける',
|
||||
locale: 'ja'
|
||||
})
|
||||
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
|
||||
).toBe('新規セッションで続ける')
|
||||
})
|
||||
|
||||
it('still reverts real brand names that machine translation localized', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.stats.StatsPane.7d26110cea',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
enValue: 'linear',
|
||||
localeValue: '线性',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Linear')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.NetworkPane.tailscale',
|
||||
enValue: 'Connect over Tailscale',
|
||||
localeValue: '通过尾鳞连接',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('通过 Tailscale 连接')
|
||||
})
|
||||
|
||||
it('still reverts nonsense renderings of a generic term', () => {
|
||||
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
enValue: 'Blank Terminal',
|
||||
localeValue: '空白端子',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('空白 Terminal')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
enValue: 'Commit',
|
||||
localeValue: 'Comprometerse',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Commit')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
enValue: 'Markdown',
|
||||
localeValue: '가격 인하',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Markdown')
|
||||
})
|
||||
|
||||
it('does not cut a valid word that contains a nonsense rendering', () => {
|
||||
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
|
||||
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
|
||||
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
|
||||
})
|
||||
|
||||
it('still fills in a translation when the catalog holds English', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
enValue: 'Terminal 1',
|
||||
localeValue: 'Terminal 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.889487d8bb',
|
||||
enValue: 'Dismiss',
|
||||
localeValue: '해고하다',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('닫기')
|
||||
})
|
||||
|
||||
it('keeps agent catalog product names in English by key, not by word', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.9e2a9bb87b',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Continue')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.760bc6883d',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
})
|
||||
|
||||
it('does not list a generic term as never-translatable', () => {
|
||||
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
|
||||
expect(pinned).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: 'Play',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
Actions: '操作',
|
||||
action: '操作',
|
||||
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Pick a base branch below': '以下のベースブランチを選択',
|
||||
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
|
||||
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
'Enter passphrase': 'パスフレーズを入力',
|
||||
'Enter password': 'パスワードを入力',
|
||||
'Enter the passphrase for': 'のパスフレーズを入力',
|
||||
|
||||
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
ja: 'クローズ'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.dc1ca081a8': {
|
||||
ko: '진행 중',
|
||||
zh: '进行中',
|
||||
ko: '열림',
|
||||
zh: '开放',
|
||||
ja: 'オープン'
|
||||
},
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '打开 Linear 任务',
|
||||
ja: 'Linear タスクを開く'
|
||||
},
|
||||
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
|
||||
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
|
||||
ko: '새로 만들기',
|
||||
zh: '新建',
|
||||
ja: '新規'
|
||||
ko: '신규',
|
||||
zh: '新功能',
|
||||
ja: '新機能'
|
||||
},
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
|
||||
ko: 'Discord',
|
||||
|
||||
@@ -752,6 +752,21 @@
|
||||
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
|
||||
"ko": "{{value1}}단계 중 {{value0}}단계"
|
||||
},
|
||||
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
|
||||
"ko": "완료"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
|
||||
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
|
||||
"ko": "자동화란 무엇인가요?"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
|
||||
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
|
||||
"ko": "결과 확인"
|
||||
},
|
||||
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
|
||||
"ko": "보내지 않음"
|
||||
},
|
||||
@@ -1868,6 +1883,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.3a231c845b": {
|
||||
"ko": "알 수 없음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
|
||||
"ko": "폴더의 변경 사항 취소"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.812cb992ee": {
|
||||
"ko": "{{value0}}에서 열기"
|
||||
},
|
||||
@@ -1880,6 +1898,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.9bb062a886": {
|
||||
"ko": "commit 되지 않음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
|
||||
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.aaf1451654": {
|
||||
"ko": "초안 {{value0}} 만들기"
|
||||
},
|
||||
@@ -1994,6 +2015,9 @@
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
|
||||
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
|
||||
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
|
||||
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -2015,6 +2039,9 @@
|
||||
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
|
||||
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
|
||||
"ko": "모든 변경 사항 스테이징"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
|
||||
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
|
||||
},
|
||||
@@ -2162,6 +2189,9 @@
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
|
||||
"ko": "{assistantMessage}"
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
|
||||
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
|
||||
"ko": "fix-login-flow"
|
||||
},
|
||||
@@ -2228,6 +2258,9 @@
|
||||
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
|
||||
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
|
||||
},
|
||||
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
|
||||
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
@@ -2255,6 +2288,12 @@
|
||||
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
|
||||
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.recipes": {
|
||||
"ko": "레시피"
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.whatTitle": {
|
||||
"ko": "이 스킬로 함께 하는 작업"
|
||||
},
|
||||
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
|
||||
"ko": "편집기"
|
||||
},
|
||||
@@ -2727,7 +2766,7 @@
|
||||
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.137e88ce8d": {
|
||||
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
|
||||
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
|
||||
"ko": "server, deploy@server:2222, ssh://server"
|
||||
@@ -3710,6 +3749,9 @@
|
||||
"auto.components.settings.ssh.search.f9493b80c0": {
|
||||
"ko": "서버"
|
||||
},
|
||||
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
|
||||
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
|
||||
},
|
||||
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
|
||||
"ko": "Linear 액세스 추가"
|
||||
},
|
||||
@@ -4683,7 +4725,7 @@
|
||||
"ko": "중지 중"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
|
||||
"ko": "확보 가능"
|
||||
@@ -4953,7 +4995,7 @@
|
||||
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
|
||||
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
|
||||
|
||||
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Install the Orca skill so agents know to use the Orca CLI.':
|
||||
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
|
||||
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
|
||||
'changed since you last approved. Re-review before it runs':
|
||||
|
||||
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
|
||||
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
|
||||
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
|
||||
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
|
||||
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
|
||||
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
|
||||
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
|
||||
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
|
||||
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
|
||||
...ZH_PHRASE_FIXES_ROUND5
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairCatalog } from './locale-translation-policy.mjs'
|
||||
|
||||
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
|
||||
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
|
||||
describe('repairCatalog with un-bootstrapped keys', () => {
|
||||
const enCatalog = {
|
||||
auto: {
|
||||
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
|
||||
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
|
||||
}
|
||||
}
|
||||
|
||||
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
|
||||
|
||||
it('skips leaves the locale catalog is missing instead of throwing', () => {
|
||||
for (const locale of ['ko', 'ja', 'zh', 'es']) {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
|
||||
expect(localeCatalog.auto.components, locale).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('still repairs the leaves that are present', () => {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
|
||||
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
|
||||
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
).toBe('파이프라인')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '터미널 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트 스킬',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Agent 스킬')
|
||||
).toBe('에이전트 스킬')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '푸시되지 않은 커밋',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('푸시되지 않은 commits')
|
||||
).toBe('푸시되지 않은 커밋')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '레포',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('레포')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
|
||||
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
|
||||
})
|
||||
|
||||
it('re-glues Korean particles after Latin terms without gluing content words', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
describe('locale-translation-policy es round 5', () => {
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'mensaje de confirmación',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('mensaje de Commit')
|
||||
).toBe('mensaje de confirmación')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'repositorio',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('repositorio')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
|
||||
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: '遊ぶ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('再生')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.TaskPage.8396825a14',
|
||||
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
).toBe('まずプロジェクトを追加')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'エージェントのスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Agent のスキル')
|
||||
).toBe('エージェントのスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'コミット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('コミット')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
|
||||
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'リポ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('リポ')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
|
||||
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import {
|
||||
isCanonicalGenericRendering,
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
|
||||
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
|
||||
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
|
||||
|
||||
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
|
||||
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
|
||||
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
|
||||
export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'Aider',
|
||||
'Amp',
|
||||
'Android',
|
||||
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Codebuff',
|
||||
'Codex',
|
||||
'Command Code',
|
||||
'Continue',
|
||||
'Cursor',
|
||||
'Droid',
|
||||
'Devin',
|
||||
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Pi',
|
||||
'PostHog',
|
||||
'Qwen Code',
|
||||
'Repo',
|
||||
'Repos',
|
||||
'Rovo Dev',
|
||||
'Commit',
|
||||
'Commits',
|
||||
'Markdown',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'VS Code',
|
||||
'Warp',
|
||||
'Zed',
|
||||
'agent',
|
||||
'agents',
|
||||
'android',
|
||||
'codex',
|
||||
'commit',
|
||||
'commits',
|
||||
'gemini',
|
||||
'claude',
|
||||
'markdown',
|
||||
'repo',
|
||||
'repos',
|
||||
'terminal',
|
||||
'terminals',
|
||||
'gh',
|
||||
'idle',
|
||||
'anthropic',
|
||||
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
|
||||
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
|
||||
}
|
||||
|
||||
function replaceMistranslatedForm(value, wrong, brand, locale) {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
|
||||
const end = at + wrong.length
|
||||
result += value.slice(cursor, at)
|
||||
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
|
||||
cursor = end
|
||||
}
|
||||
return result + value.slice(cursor)
|
||||
}
|
||||
|
||||
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
let result = localeValue
|
||||
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
|
||||
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (!result.includes(wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
|
||||
// rewrote ~2000 translated values back to English on every repair run.
|
||||
if (isCanonicalGenericRendering(brand, locale, wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
|
||||
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
|
||||
continue
|
||||
}
|
||||
result = result.replaceAll(wrong, brand)
|
||||
result = replaceMistranslatedForm(result, wrong, brand, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
|
||||
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
|
||||
// rewrites values that already exist, so skip instead of crashing on undefined.
|
||||
if (typeof current !== 'string') {
|
||||
continue
|
||||
}
|
||||
const next = repairTranslatedValue({
|
||||
key: leaf.key,
|
||||
enValue: leaf.value,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI agent を開始しました。')
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンから terminals と agents を操作')
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '未已检测代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('未检测到 agents')
|
||||
).toBe('未检测到代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '代理技巧',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agent 技能')
|
||||
).toBe('代理技能')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commit するものはありません。PR はすでにマージされています。')
|
||||
).toBe('コミットするものはありません。PR はすでにマージされています。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.integrations.search.581844769a',
|
||||
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '玩',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('播放')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
|
||||
|
||||
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('集成')
|
||||
})
|
||||
|
||||
it('keeps Terminal as a product surface term', () => {
|
||||
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
|
||||
// connector") mistranslation still reverts to Latin.
|
||||
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.Settings.3de4bbb841',
|
||||
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal')
|
||||
).toBe('终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端 1',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('终端 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '分体式端子右',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('向右拆分 Terminal')
|
||||
).toBe('向右拆分终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
|
||||
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端Cursor',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal Cursor')
|
||||
).toBe('终端 Cursor')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
|
||||
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('新 Terminal')
|
||||
})
|
||||
|
||||
it('keeps workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
|
||||
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agents')
|
||||
).toBe('代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.28986b3747',
|
||||
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '已启动 AI 代理处理失败的检查。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('已启动 AI agent 处理失败的检查。')
|
||||
).toBe('已启动 AI 代理处理失败的检查。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '次提交',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('次提交')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.d1d78a7baa',
|
||||
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
|
||||
})
|
||||
|
||||
it('does not confuse proxy copy with Agent terminology', () => {
|
||||
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
|
||||
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
|
||||
})
|
||||
|
||||
it('keeps product, provider, code, and shell tokens untranslated', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'OpenCode Go': 'OpenCode Go',
|
||||
'Open in Cursor': 'Abrir en Cursor',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'Proyecto local, repo de Git o carpeta con muchos repos'
|
||||
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
|
||||
},
|
||||
ko: {
|
||||
Save: '저장',
|
||||
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
teams: '团队',
|
||||
passing: '通过',
|
||||
'opened this issue': '创建了此议题',
|
||||
Open: '进行中',
|
||||
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
|
||||
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
|
||||
'Join Discord': '加入 Discord',
|
||||
'Pull request merged': '拉取请求已合并',
|
||||
'Agent Skills': '代理技能',
|
||||
|
||||
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
Inline: '内联',
|
||||
thread: '帖子串',
|
||||
destructive: 'destructive',
|
||||
sheet: 'sheet',
|
||||
page: 'page',
|
||||
sheet: '面板',
|
||||
page: '页面',
|
||||
Annotation: '批注',
|
||||
'check #': '检查 #',
|
||||
by: '由',
|
||||
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
|
||||
Play: 'Play',
|
||||
Flag: 'Flag',
|
||||
Zinc: 'Zinc',
|
||||
Rose: 'Rose',
|
||||
Emerald: 'Emerald',
|
||||
Amber: 'Amber',
|
||||
Violet: 'Violet',
|
||||
Sky: 'Sky',
|
||||
Blue: 'Blue',
|
||||
Neutral: 'Neutral',
|
||||
// Workspace status icon and color swatch labels; siblings below are translated, so English
|
||||
// pins here would leave the picker half-Chinese on the next catalog regeneration.
|
||||
Play: '播放',
|
||||
Flag: '标记',
|
||||
Zinc: '锌灰',
|
||||
Rose: '玫瑰',
|
||||
Emerald: '翡翠',
|
||||
Amber: '琥珀',
|
||||
Violet: '紫罗兰',
|
||||
Sky: '天蓝',
|
||||
Blue: '蓝色',
|
||||
Neutral: '中性',
|
||||
Dashed: '虚线',
|
||||
Dot: '圆点',
|
||||
Circle: '圆形',
|
||||
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
|
||||
'Show Linear in the Tasks source picker and sidebar shortcuts.':
|
||||
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'本地项目、Git repo 或包含多个 repos 的文件夹',
|
||||
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
|
||||
'Staged Changes': '已暂存的更改',
|
||||
Changes: '更改',
|
||||
'Untracked Files': '未跟踪文件',
|
||||
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Split Down': '向下拆分',
|
||||
'Split Left': '向左拆分',
|
||||
'Split Right': '向右拆分',
|
||||
'Split Terminal Down': '向下拆分 Terminal',
|
||||
'Split Terminal Right': '向右拆分 Terminal',
|
||||
'Split Terminal Down': '向下拆分终端',
|
||||
'Split Terminal Right': '向右拆分终端',
|
||||
'Optional account switching for Claude while preserving shared chat context.':
|
||||
'Claude 的可选账户切换,同时保留共享聊天上下文。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
|
||||
@@ -227,12 +227,89 @@ describe('orchestration skill guidance', () => {
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('makes settled worker terminal release an explicit coordinator step', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
const nextAction = getSection(skill, 'Next Action')
|
||||
|
||||
expect(workerLoop).toContain(
|
||||
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
|
||||
'orca orchestration worker-release --dispatch <dispatch_id> --json'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Acknowledge only after every message and required release decision is handled'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
|
||||
'transfers cleanup ownership to the new Dispatch'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
|
||||
'explicitly asked to keep that worker live.'
|
||||
)
|
||||
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
|
||||
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
|
||||
expect(workerLoop).toContain(
|
||||
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
|
||||
'the turn'
|
||||
)
|
||||
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
|
||||
expect(nextAction).toContain(
|
||||
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
|
||||
'follow-up Dispatch or run `worker-release` before the next wait.'
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
// The prohibition sentence is the guard the negative patterns below rely on.
|
||||
expect(workerLoop).toContain(
|
||||
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
|
||||
'escalation, or rejected/stale `worker_done`.'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'do not start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard'
|
||||
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
|
||||
|
||||
let outputDir: string | undefined
|
||||
|
||||
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
// require("electron") throws "Cannot find module 'electron'" inside a
|
||||
// main-process worker and kills it at startup. The worker entries carried only
|
||||
// hand-written "must stay electron-free" comments, and the port-scan worker sits
|
||||
// one import away from a client that deliberately does require electron.
|
||||
describe('worker thread entry guard', () => {
|
||||
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
bundle
|
||||
)
|
||||
}
|
||||
|
||||
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
|
||||
return {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: `${name}.js`,
|
||||
imports,
|
||||
isEntry: true,
|
||||
name
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
|
||||
it('rejects an Electron require reachable from a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'port-scan-command-worker-entry.js': workerChunk(
|
||||
'port-scan-command-worker-entry',
|
||||
'require("electron")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
|
||||
})
|
||||
|
||||
it('names the worker-thread runtime so the failure is actionable', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
|
||||
})
|
||||
|
||||
// The real risk is transitive: a worker entry importing a shared chunk that
|
||||
// reaches the electron-requiring client, not a direct import anyone would spot.
|
||||
it('follows shared chunks out of a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'require("./chunks/shared.js")',
|
||||
['chunks/shared.js']
|
||||
),
|
||||
'chunks/shared.js': {
|
||||
type: 'chunk',
|
||||
code: 'require("electron")',
|
||||
dynamicImports: [],
|
||||
fileName: 'chunks/shared.js',
|
||||
imports: [],
|
||||
isEntry: false,
|
||||
name: 'shared'
|
||||
} as Rollup.OutputChunk
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
|
||||
})
|
||||
|
||||
it('passes a clean worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'warp-theme-parser-worker.js': workerChunk(
|
||||
'warp-theme-parser-worker',
|
||||
'require("node:worker_threads")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { build } from 'esbuild'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
@@ -9,6 +10,12 @@ import { dirname, join, resolve } from 'node:path'
|
||||
const WAIT_TIMEOUT_MS = 20_000
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
// Why: endpoint credentials are 32–256 base64url chars; openClient requires an authenticated
|
||||
// transport, and #12746 admits pty.data only after a consumer grant.
|
||||
function writeEndpointCredential(path) {
|
||||
return writeFile(path, randomBytes(32).toString('base64url'), 'utf8')
|
||||
}
|
||||
|
||||
function withTimeout(promise, label, stderr) {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -68,14 +75,59 @@ async function loadProtocol(bundleDir) {
|
||||
return require(outfile)
|
||||
}
|
||||
|
||||
function attachProcessStreams(proc) {
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
return {
|
||||
stderr: () => stderr
|
||||
}
|
||||
}
|
||||
|
||||
function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
return withTimeout(
|
||||
new Promise((resolvePromise, rejectPromise) => {
|
||||
let settled = false
|
||||
const onData = (chunk) => {
|
||||
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
|
||||
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
|
||||
const index = stdoutBuffer.indexOf(sentinel)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
proc.off('exit', onExit)
|
||||
resolvePromise(stdoutBuffer.subarray(index + sentinel.length))
|
||||
}
|
||||
const onExit = (code, signal) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
rejectPromise(
|
||||
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
|
||||
)
|
||||
}
|
||||
proc.stdout.on('data', onData)
|
||||
proc.once('exit', onExit)
|
||||
}),
|
||||
'relay sentinel',
|
||||
stderr
|
||||
)
|
||||
}
|
||||
|
||||
function createRelayClient(entryPath, args, env, protocol) {
|
||||
const proc = spawn(process.execPath, [entryPath, ...args], {
|
||||
cwd: dirname(entryPath),
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
const streams = attachProcessStreams(proc)
|
||||
const messages = []
|
||||
let stderr = ''
|
||||
let nextSequence = 1
|
||||
let stdoutBuffer = Buffer.alloc(0)
|
||||
let ready = false
|
||||
@@ -88,9 +140,6 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
messages.push(protocol.parseJsonRpcMessage(frame.payload))
|
||||
}
|
||||
})
|
||||
proc.stderr.on('data', (chunk) => {
|
||||
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
|
||||
})
|
||||
proc.stdout.on('data', (chunk) => {
|
||||
if (ready) {
|
||||
decoder.feed(chunk)
|
||||
@@ -111,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
})
|
||||
|
||||
const waitForMessage = (startIndex, predicate, label) =>
|
||||
pollUntil(
|
||||
() => messages.slice(startIndex).find(predicate),
|
||||
label,
|
||||
() => stderr
|
||||
)
|
||||
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
|
||||
|
||||
const request = async (method, params = {}) => {
|
||||
const id = nextSequence++
|
||||
@@ -141,7 +186,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
proc,
|
||||
request,
|
||||
notify,
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
|
||||
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', streams.stderr),
|
||||
messageCount: () => messages.length,
|
||||
waitForNotification: (startIndex, method, predicate = () => true) =>
|
||||
waitForMessage(
|
||||
@@ -149,7 +194,20 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
(message) => message.method === method && predicate(message.params ?? {}),
|
||||
`${method} notification`
|
||||
),
|
||||
stderr: () => stderr
|
||||
stderr: streams.stderr
|
||||
}
|
||||
}
|
||||
|
||||
async function stopProcess(proc, stderr, label) {
|
||||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(proc), `${label} shutdown`, stderr)
|
||||
} catch {
|
||||
proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(proc), `forced ${label} shutdown`, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,24 +242,64 @@ async function main() {
|
||||
}
|
||||
|
||||
let tempRoot
|
||||
let daemon
|
||||
let daemonStreams
|
||||
let relay
|
||||
try {
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
|
||||
const watchRoot = await realpath(tempRoot)
|
||||
const pidFile = join(tempRoot, 'watcher.pid')
|
||||
const credentialFile = join(tempRoot, 'endpoint.credential')
|
||||
const protocol = await loadProtocol(tempRoot)
|
||||
const socketPath =
|
||||
process.platform === 'win32'
|
||||
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
|
||||
: join(tempRoot, 'relay.sock')
|
||||
await writeEndpointCredential(credentialFile)
|
||||
|
||||
// Why detached + --connect: the daemon primary stdio is unproved, so it cannot open a consumer
|
||||
// session; only an endpoint-credential socket client is admitted for pty.data after #12746.
|
||||
daemon = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
relayEntry,
|
||||
'--detached',
|
||||
'--grace-time',
|
||||
'0',
|
||||
'--sock-path',
|
||||
socketPath,
|
||||
'--endpoint-dir',
|
||||
join(tempRoot, 'agent-hooks'),
|
||||
'--credential-file',
|
||||
credentialFile
|
||||
],
|
||||
{
|
||||
cwd: dirname(relayEntry),
|
||||
env: { ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
)
|
||||
daemonStreams = attachProcessStreams(daemon)
|
||||
await waitForStdoutSentinel(daemon, protocol, daemonStreams.stderr)
|
||||
|
||||
relay = createRelayClient(
|
||||
relayEntry,
|
||||
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
|
||||
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
|
||||
['--connect', '--sock-path', socketPath, '--credential-file', credentialFile],
|
||||
process.env,
|
||||
protocol
|
||||
)
|
||||
await relay.sentinelReceived
|
||||
|
||||
// Why no outputFlowControl: legacy owner grant admits plain pty.data without delivery tokens.
|
||||
const grant = await relay.request('pty.openClient', {
|
||||
protocolVersion: 1,
|
||||
clientInstanceId: `relay-watcher-fault-${process.pid}`,
|
||||
requestedRole: 'session-owner'
|
||||
})
|
||||
if (grant?.role !== 'session-owner') {
|
||||
throw new Error(`expected session-owner grant, got ${JSON.stringify(grant)}`)
|
||||
}
|
||||
|
||||
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
|
||||
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
|
||||
let startIndex = relay.messageCount()
|
||||
@@ -213,7 +311,11 @@ async function main() {
|
||||
)
|
||||
|
||||
await relay.request('fs.watch', { rootPath: watchRoot })
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
|
||||
const firstWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
undefined,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
const beforePath = join(watchRoot, 'before.txt')
|
||||
startIndex = relay.messageCount()
|
||||
await writeFile(beforePath, 'before')
|
||||
@@ -224,7 +326,11 @@ async function main() {
|
||||
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
|
||||
startIndex = relay.messageCount()
|
||||
process.kill(firstWatcherPid, faultSignal)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
|
||||
const replacementWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
firstWatcherPid,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
|
||||
Array.isArray(params.events)
|
||||
? params.events.some(
|
||||
@@ -234,7 +340,7 @@ async function main() {
|
||||
)
|
||||
|
||||
const status = await relay.request('relay.status')
|
||||
if (status.pid !== relay.proc.pid) {
|
||||
if (status.pid !== daemon.pid) {
|
||||
throw new Error('relay.status did not come from the original surviving relay process')
|
||||
}
|
||||
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
|
||||
@@ -257,7 +363,7 @@ async function main() {
|
||||
await relay.request('pty.shutdown', { id: spawned.id })
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
relayPid: relay.proc.pid,
|
||||
relayPid: daemon.pid,
|
||||
killedWatcherPid: firstWatcherPid,
|
||||
replacementWatcherPid,
|
||||
faultSignal,
|
||||
@@ -268,15 +374,8 @@ async function main() {
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
|
||||
relay.proc.kill('SIGTERM')
|
||||
try {
|
||||
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
|
||||
} catch {
|
||||
relay.proc.kill('SIGKILL')
|
||||
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
|
||||
}
|
||||
}
|
||||
await stopProcess(relay?.proc, relay?.stderr ?? (() => ''), 'connect bridge')
|
||||
await stopProcess(daemon, daemonStreams?.stderr ?? (() => ''), 'relay daemon')
|
||||
if (tempRoot) {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
|
||||
|
||||
/**
|
||||
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
|
||||
* `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*
|
||||
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
|
||||
@@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RELEASE_NAME_TIME_ZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
|
||||
hourCycle: 'h23'
|
||||
hour12: true
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => [part.type, part.value])
|
||||
)
|
||||
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
||||
// Assembled from parts rather than by string-editing the formatted output:
|
||||
// recent ICU separates the time from AM/PM with U+202F, not a plain space, so
|
||||
// a naive replace(' ', '') leaves the gap on some runtimes and not others.
|
||||
return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}`
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [
|
||||
'HOMEPATH',
|
||||
'CODEX_HOME',
|
||||
'ORCA_CODEX_HOME',
|
||||
'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME',
|
||||
'ORCA_E2E_HOME_DIR',
|
||||
'ORCA_E2E_USER_DATA_DIR',
|
||||
'ORCA_USER_DATA_PATH',
|
||||
@@ -69,7 +68,7 @@ async function resolveRealPath(candidate) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createValidationEnv(inheritedEnv, layout, options = {}) {
|
||||
export function createValidationEnv(inheritedEnv, layout) {
|
||||
const env = { ...inheritedEnv }
|
||||
for (const key of RESTRICTED_ENV_KEYS) {
|
||||
delete env[key]
|
||||
@@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) {
|
||||
NODE_ENV: 'development',
|
||||
ORCA_E2E_HOME_DIR: layout.homeDir,
|
||||
ORCA_E2E_USER_DATA_DIR: layout.userDataDir,
|
||||
ORCA_USER_DATA_PATH: layout.userDataDir,
|
||||
// Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME,
|
||||
// so native codex never resolves the OS profile — the only Windows
|
||||
// configuration where strict zero-event containment is reachable. It also
|
||||
// exercises the emergency kill-switch lane users fall back to.
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1'
|
||||
ORCA_USER_DATA_PATH: layout.userDataDir
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,8 +262,7 @@ function parseArgs(argv) {
|
||||
primaryHome: os.homedir(),
|
||||
configTemplate: null,
|
||||
tempParent: null,
|
||||
laneAwareContainment: false,
|
||||
systemDefaultRealHome: 'on'
|
||||
laneAwareContainment: false
|
||||
}
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
@@ -299,14 +292,8 @@ function parseArgs(argv) {
|
||||
options.skipBuild = true
|
||||
} else if (arg === '--keep') {
|
||||
options.keep = true
|
||||
} else if (arg === '--system-default-real-home') {
|
||||
const value = readValue()
|
||||
if (value !== 'on' && value !== 'off') {
|
||||
throw new Error('--system-default-real-home must be "on" or "off"')
|
||||
}
|
||||
options.systemDefaultRealHome = value
|
||||
} else if (arg === '--lane-aware-containment') {
|
||||
// Why: on Windows the flag-ON system-default lane cannot be env-sandboxed
|
||||
// Why: on Windows the system-default real-home lane cannot be env-sandboxed
|
||||
// (native codex ignores USERPROFILE), so strict zero-event containment is
|
||||
// structurally unreachable there. This mode records codex's designed
|
||||
// volatile churn without aborting while every other real-home write stays
|
||||
@@ -314,7 +301,7 @@ function parseArgs(argv) {
|
||||
options.laneAwareContainment = true
|
||||
} else if (arg === '--help') {
|
||||
console.log(
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
|
||||
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report <path>]'
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
@@ -480,7 +467,7 @@ async function main() {
|
||||
const reportPath =
|
||||
options.reportPath ??
|
||||
path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`)
|
||||
const launchEnv = createValidationEnv(process.env, layout, options)
|
||||
const launchEnv = createValidationEnv(process.env, layout)
|
||||
let app = null
|
||||
let tripwire = null
|
||||
const abortController = new AbortController()
|
||||
|
||||
@@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => {
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.ORCA_CODEX_HOME).toBeUndefined()
|
||||
expect(env.ZDOTDIR).toBeUndefined()
|
||||
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1')
|
||||
expect(env.SAFE_VALUE).toBe('preserved')
|
||||
})
|
||||
|
||||
it('pins the real-home flag off when the system-default lane is disabled', async () => {
|
||||
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
|
||||
const { layout, env } = runValidationModule<{
|
||||
layout: { tempRoot: string }
|
||||
env: Record<string, string | undefined>
|
||||
}>(
|
||||
`
|
||||
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
|
||||
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
|
||||
const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' })
|
||||
console.log(JSON.stringify({ layout, env }))
|
||||
`,
|
||||
[primaryHome]
|
||||
)
|
||||
cleanupPaths.push(layout.tempRoot)
|
||||
|
||||
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0')
|
||||
})
|
||||
|
||||
it('records only fingerprints for system-default and managed auth', async () => {
|
||||
const { layout, snapshot } = runValidationModule<{
|
||||
layout: { tempRoot: string }
|
||||
|
||||
@@ -483,7 +483,6 @@ async function main() {
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_E2E_HOME_DIR: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -55,7 +55,6 @@ Object.assign(childEnv, {
|
||||
ORCA_DEV_USER_DATA_PATH: profileDir,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
...(process.platform === 'linux'
|
||||
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
|
||||
: {})
|
||||
|
||||
@@ -39,14 +39,23 @@ else
|
||||
# Why the features are off: this repo holds releases and nothing else. Leaving
|
||||
# issues open invites bug reports against a branch build in a repo nobody
|
||||
# watches, where they are simply lost.
|
||||
#
|
||||
# Why --add-readme in a repo with no source: publishing a release creates a tag,
|
||||
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
|
||||
gh repo create "$ADHOC_REPO" \
|
||||
--public \
|
||||
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
|
||||
--add-readme \
|
||||
--disable-issues \
|
||||
--disable-wiki ||
|
||||
fail "Could not create $ADHOC_REPO."
|
||||
fi
|
||||
|
||||
# Also checked outside the create branch: a repo made before --add-readme is here.
|
||||
if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then
|
||||
fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
|
||||
|
||||
|
||||
@@ -219,7 +219,6 @@ async function runValidation(mode) {
|
||||
ORCA_DEV_USER_DATA_PATH: userDataPath,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
ELECTRON_ENABLE_STACK_DUMPING: '1',
|
||||
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
|
||||
|
||||
@@ -6,6 +6,9 @@ import process from 'node:process'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
|
||||
@@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms
|
||||
// back to English, so drift only surfaces when someone regenerates the catalog.
|
||||
export function collectGenericTermRegressions(enEntries, localeEntries, localeName) {
|
||||
const renderings = canonicalGenericRenderings(localeName)
|
||||
if (renderings.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regressions = []
|
||||
for (const [key, enValue] of enEntries) {
|
||||
const localeValue = localeEntries.get(key)
|
||||
if (typeof enValue !== 'string' || typeof localeValue !== 'string') {
|
||||
continue
|
||||
}
|
||||
const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName })
|
||||
if (repaired === localeValue) {
|
||||
continue
|
||||
}
|
||||
// Why: {{agent}} is an interpolation name, not English copy the reader sees.
|
||||
const repairedCopy = repaired.replace(PLACEHOLDER_RE, '')
|
||||
for (const { form, terms } of renderings) {
|
||||
if (!localeValue.includes(form) || repaired.includes(form)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy))
|
||||
) {
|
||||
regressions.push({ key, form, localeValue, repaired })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return regressions
|
||||
}
|
||||
|
||||
function formatGenericTermRegressions(regressions) {
|
||||
return regressions
|
||||
.map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
|
||||
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
@@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
if (
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0 ||
|
||||
genericTermRegressions.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
@@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
|
||||
}
|
||||
}
|
||||
if (genericTermRegressions.length > 0) {
|
||||
console.error('')
|
||||
console.error(
|
||||
'repair-locale-catalog would rewrite these translated terms back to English.',
|
||||
'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs',
|
||||
'instead of listing its translation as a mistranslation.'
|
||||
)
|
||||
console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20)))
|
||||
if (genericTermRegressions.length > 20) {
|
||||
console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
|
||||
import {
|
||||
collectGenericTermRegressions,
|
||||
main as verifyLocalizationCatalog
|
||||
} from './verify-localization-catalog.mjs'
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
@@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => {
|
||||
report.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
|
||||
it('flags catalog values the repair policy would rewrite back to English', () => {
|
||||
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
|
||||
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// A locale whose committed value is the English term is stable, not a regression.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores interpolation names when looking for English rewrites', () => {
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
new Map([
|
||||
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
|
||||
]),
|
||||
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
|
||||
'zh'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) {
|
||||
ORCA_DEV_USER_DATA_PATH: userDataDir,
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
|
||||
ORCA_SKIP_DEV_WEB_PREPARE: '1',
|
||||
ORCA_STARTUP_DIAGNOSTICS: '1',
|
||||
REMOTE_DEBUGGING_PORT: String(cdpPort),
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/grok/windows-grok-hook-script.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
"../src/main/devin/hook-service.ts",
|
||||
"../src/main/devin/hook-config-json.ts",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": [
|
||||
"../electron.vite.config.*",
|
||||
"../build-plugins/**/*",
|
||||
"./build-plugins/**/*",
|
||||
"../src/main/**/*",
|
||||
"../src/renderer/src/lib/skill-freshness-display-status.ts",
|
||||
"../src/preload/**/*",
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl-distro-list-output.ts",
|
||||
"../src/main/wsl-distro-retry.ts",
|
||||
"../src/main/wsl.ts"
|
||||
"../src/main/wsl.ts",
|
||||
"../src/main/startup/serve-desktop-activation.ts",
|
||||
"../src/main/startup/single-instance-lock.ts",
|
||||
"../src/main/startup/startup-diagnostics.ts",
|
||||
"../src/main/window/focus-existing-window.ts",
|
||||
"../src/main/window/macos-app-activation.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
|
||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
||||
environment: 'node',
|
||||
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
|
||||
execArgv: ['--no-experimental-webstorage'],
|
||||
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
|
||||
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
|
||||
include: [
|
||||
'src/**/*.test.ts',
|
||||
'src/**/*.test.tsx',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
|
||||
<title>downloads: 13m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 15m">
|
||||
<title>downloads: 15m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">13m</text>
|
||||
<text x="90" y="14">13m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">15m</text>
|
||||
<text x="90" y="14">15m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -0,0 +1,442 @@
|
||||
# Mobile Relay UX — Investigation Findings & Fix Plan
|
||||
|
||||
Scope: phone-side presentation/state-machine issues behind three reported symptoms on Android over
|
||||
the cloud relay. The relay protocol and server-side assignment are healthy; nothing here changes
|
||||
desktop or relay-server code. All file references are in `mobile/` of this worktree.
|
||||
|
||||
## 1. Symptom → root-cause summary
|
||||
|
||||
| # | Symptom | Root cause (verified) |
|
||||
|---|---------|----------------------|
|
||||
| S1 | Resume lands on an empty "Host" page, grey dot | Bare cross-stack `router.push` into a cold nested host navigator resolves to the host index route **without the `hostId` param**; every screen below then runs with `hostId: undefined` |
|
||||
| S2 | Tapping a healthy relay host shows grey 1–2s before green | Every screen focus funnels into the network-handoff recovery path, which **suspends the healthy relay session** (publishes `disconnected`) and re-dials; the re-dial is invisible because `migrateTo` binds new-session state only after authentication |
|
||||
| S3 | Relay-forced pairing looks dead ~5–10s | The pairing relay path has **no log sink** (only direct-path entries reach the "Pairing log"), and post-pairing the app dials the unreachable LAN endpoint for up to 12s before relay recovery is even eligible |
|
||||
|
||||
## 2. Verified end-to-end causal chains
|
||||
|
||||
### S1 — Resume dead-ends on the host index page
|
||||
|
||||
1. Home renders the Resume card only once `hostStates[lastVisited.hostId] === 'connected'`
|
||||
(`app/index.tsx:488`); over relay that is seconds after the host list paints, and the card
|
||||
inserts **above** the Tasks card in the same footer (`app/index.tsx:733-780`) — a layout shift
|
||||
under the thumb.
|
||||
2. Tap → bare `router.push(createMobileSessionHref(...))` (`app/index.tsx:740-746`) targeting
|
||||
`/h/[hostId]/session/[worktreeId]`.
|
||||
3. With the `h` group cold (cold start, or host never visited this session), Expo Router resolves
|
||||
the push to the host stack's **index route with no `hostId` param**. This exact failure mode is
|
||||
documented twice in-repo ("cold Expo deep links resolve to index" —
|
||||
`src/transport/host-edit-navigation.ts:52`, `src/tasks/mobile-task-navigation.ts:90`) and is the
|
||||
root cause named by PR #12001.
|
||||
4. `app/h/_layout.tsx:64` reads `hostId` via `useGlobalSearchParams` → `undefined`.
|
||||
`HostProtocolGate` gets `hostId: undefined`; `useHostClient(undefined)` returns
|
||||
`state: 'disconnected'` (`src/transport/client-context.tsx:344`) → **grey dot**.
|
||||
5. The host index screen renders the fallback title `'Host'` (`app/h/[hostId]/index.tsx:821`), and
|
||||
every fetch no-ops on `!client || connState !== 'connected'`
|
||||
(`app/h/[hostId]/index.tsx:298,364,418,520`) → **empty list**. The Filter/Recent/Repo chips are
|
||||
static toolbar UI, so the page looks "real" but dead.
|
||||
6. "Sometimes": a warm host stack resolves the same push correctly, so the bug is intermittent by
|
||||
navigation history.
|
||||
|
||||
Corrections to the preliminary sweep: the empty page is primarily the missing `hostId` param, not
|
||||
the connection-gated fetches or the cold 30s worktree cache (those matter only when landing *with*
|
||||
a valid `hostId`, e.g. the mistap-strand case). Also, the Resume target "validation" is weaker than
|
||||
it looks: `getCachedWorktrees` is seeded from the persisted home snapshot at hydration
|
||||
(`app/index.tsx:264-277`) and the 30s TTL is stamped at seed time (`src/cache/worktree-cache.ts:20`),
|
||||
so a worktree deleted while the phone was off still passes until a live `worktree.ps` overwrites it.
|
||||
|
||||
**Fix**: PR #12001 ("open the Resume workspace through a mounted host stack") routes Resume through
|
||||
the same mount-then-replace mechanism Tasks uses, extracted to `src/navigation/host-stack-navigation.ts`.
|
||||
Reviewed and validated per Jinwoo; **merged to main as `7948e46db855`** after final validation
|
||||
(see §4, F0). Residual S1 items it does not cover: bare notification/accounts/deep-link pushes (F4),
|
||||
catalog validation + not-found bounce (F7), Resume-card layout shift (F8), gate unmount hazard (F9).
|
||||
|
||||
### S2 — grey blink when focusing a healthy relay host
|
||||
|
||||
1. Every focus of the host screen fires `notifyForeground()`
|
||||
(`app/h/[hostId]/index.tsx:512-517`, deliberately empty deps).
|
||||
2. `openHostLogicalClient` wraps that into `endpointLifecycle.setForeground(true)`
|
||||
(`src/transport/host-logical-client.ts:31-33`); the lifecycle forwards without dedupe
|
||||
(`src/transport/mobile-endpoint-lifecycle.ts:62-64`).
|
||||
3. `MobileEndpointSupervisor.setForeground(true)` computes `wasForeground = true` and calls
|
||||
`RelayReconnectController.handleForeground` (`src/transport/mobile-endpoint-supervisor.ts:115-119`).
|
||||
4. `handleForeground` with `wasForeground && state === 'connected'` **suspends the healthy session**
|
||||
(`src/transport/mobile-relay-reconnect-controller.ts:53-60`). `suspendActiveRelay` early-returns
|
||||
unless the active path is `'relay'` (`:77-84`) — which is why LAN hosts never blink.
|
||||
5. `suspendActiveSession` closes the physical session, disposes all subscriptions, and publishes
|
||||
`'disconnected'` (`src/transport/stable-logical-rpc-client.ts:164-180`) → grey dot
|
||||
(`src/components/StatusDot.tsx:11`), worktree queries blocked.
|
||||
6. `onRetry()` → `recoverRelay()` → `openRelay` + `migrateTo`. During the dial the logical state
|
||||
**stays 'disconnected'**: `migrateTo` only binds the new session's state after
|
||||
`waitForAuthenticated` resolves (`src/transport/stable-logical-rpc-client.ts:188,213`), and the
|
||||
dialing session's own `connecting`/`handshaking` publishes (`src/transport/mobile-relay-rpc-session.ts:43,74`)
|
||||
fire with no listeners attached. Grey persists the full 1–2s (happy path has no artificial
|
||||
delays; any failure adds ≥250ms full-jitter backoff, `src/transport/mobile-relay-retry-delays.ts:3-5`).
|
||||
7. `migrateTo` completes → `'connected'` → green; subscriptions replay; gated fetches rerun.
|
||||
|
||||
Second trigger for the same path: OS network-revival nudges call `notifyForeground()` on every live
|
||||
client (`src/transport/client-context.tsx:286-292`, `src/transport/connection-revival-triggers.ts`)
|
||||
— any Wi-Fi↔cellular transition or came-online event grey-blinks every connected relay host.
|
||||
|
||||
Design context: the suspend-on-repeat-foreground is pinned by the supervisor test as the
|
||||
network-handoff half-open case (`src/transport/mobile-endpoint-supervisor.test.ts:~160-197`). The
|
||||
asymmetry is that **direct sockets probe instead of tearing down** — `notifyForeground` on a
|
||||
connected direct client runs an activity probe that detects a half-open socket in ≤8s
|
||||
(`src/transport/rpc-client.ts:1119-1124`) — while relay sessions have a no-op `notifyForeground`
|
||||
(`src/transport/mobile-relay-rpc-session.ts:106`) and the supervisor's only tool is
|
||||
suspend-then-redial. There is also an in-repo make-before-break precedent: lease rotation calls
|
||||
`recoverRelay(forceReplacement = true)` and migrates a **live** session with zero visible blink
|
||||
(`src/transport/mobile-endpoint-supervisor.ts:56-59,146,249`).
|
||||
|
||||
Divergent mount defaults (secondary): home renders `hostStates[id] ?? 'connecting'` (amber,
|
||||
`app/index.tsx:707`) while `getState()`/`useHostClient` return `'disconnected'` (grey) for a
|
||||
missing store entry (`src/transport/client-context.tsx:221,344`) — so host screens flash grey
|
||||
during the async client acquire (Keychain read) that home never shows.
|
||||
|
||||
### S3 — silent 5–10s relay-forced pairing
|
||||
|
||||
Pairing phase:
|
||||
|
||||
1. `pair-confirm.tsx` / `pair-scan.tsx` pass `connectOptions.onLog` into `startPreProfilePairing`
|
||||
(`app/pair-confirm.tsx:91-99`, `app/pair-scan.tsx:135-143`).
|
||||
2. The coordinator threads it **only to the direct candidate**
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:152-157`). The relay candidate
|
||||
(`:161-187`) gets nothing: `connectMobileRelayForPairing` has no log parameter at all
|
||||
(`src/transport/mobile-relay-physical-client.ts:22-30`), nor do the director resolution,
|
||||
journal writes, or the recovery loop in `src/transport/pairing-relay-candidate.ts`.
|
||||
3. With LAN unreachable, the visible "Pairing log" shows only the direct dial stalling toward its
|
||||
12s connect timeout while the relay path does the real work silently: cell WebSocket + E2EE
|
||||
handshake + `pairing.provisionRelay` + `pairing.getEndpoints` + credential-bundle write
|
||||
(`pre-profile-pairing-coordinator.ts:206-233`). The error copy even says "see log below for
|
||||
where it stalled" (`app/pair-confirm.tsx:138`) — the log cannot show it.
|
||||
4. Un-logged waits in the relay recovery loop: each of up to 3 attempts wraps a 5s director
|
||||
resolution (`src/transport/mobile-relay-invite-director.ts:16`) plus full-jitter sleeps capped
|
||||
at 100/200/400ms (`src/transport/pairing-relay-candidate.ts:58-59,70-71`) — worst case ~15s of
|
||||
silence. (Correction: the preliminary "~3×2s of backoff" was wrong; the sleeps are small, the
|
||||
director resolves dominate.) The relay E2EE layer itself has **no timers**: a pairing relay
|
||||
request is unbounded except the screen's 25s cap (`app/pair-confirm.tsx:27`).
|
||||
5. Pairing logs also never reach `connectionLogStore` (single producer:
|
||||
`src/transport/client-context.tsx:132`), so the Connection Log screen shows nothing about a
|
||||
pairing that just failed.
|
||||
|
||||
Post-pairing phase:
|
||||
|
||||
6. `pair-confirm` calls `closeHost(hostId)` then replaces to `/h/<id>`
|
||||
(`app/pair-confirm.tsx:118-123`).
|
||||
7. The destination re-acquires a client asynchronously (grey `'disconnected'` default during the
|
||||
Keychain read, `src/transport/client-context.tsx:221`), then dials the **LAN endpoint first**
|
||||
(`src/transport/host-logical-client.ts:12`) — amber for up to `CONNECT_TIMEOUT_MS = 12s`
|
||||
(`src/transport/rpc-client.ts:126`) on a black-holed LAN.
|
||||
8. Relay recovery cannot start earlier: `needsRecovery` treats `connecting`/`handshaking` as live
|
||||
progress (`src/transport/mobile-relay-reconnect-controller.ts:73-75`), checked at supervisor
|
||||
start and on every retry (`src/transport/mobile-endpoint-supervisor.ts:106,146`).
|
||||
9. When the direct dial finally fails, the relay dial runs invisibly (same `migrateTo` mechanism
|
||||
as S2) → green. Worst case with a director resolution failure and grace-credential retry:
|
||||
~29–58s under the old session's labels.
|
||||
10. The "Orca Relay" path label only renders once `state === 'connected'`
|
||||
(`src/components/MobileHostCard.tsx:23,47`) — the user learns the phone is using relay only
|
||||
after the wait ends, and `classifyConnection` has no relay-aware branch
|
||||
(`src/transport/connection-health.ts:45-100`).
|
||||
|
||||
## 3. Anti-pattern sweep
|
||||
|
||||
### (a) Uncoordinated deep pushes into `/h` from outside the host stack
|
||||
|
||||
Coordinated today (mount-then-replace): host edit (`src/transport/host-edit-navigation.ts`) and
|
||||
Tasks (`src/tasks/mobile-task-navigation.ts`). Note host-edit's predicate is weaker — it checks the
|
||||
root route only and can fire its `replace` while the nested stack is still gated/unmounted; Tasks
|
||||
proves the nested stack exists (`mountedHostStack`, `mobile-task-navigation.ts:53-70`).
|
||||
|
||||
Bare pushes remaining (host stack plausibly cold at each):
|
||||
|
||||
| Call site | Target | Cold scenario |
|
||||
|---|---|---|
|
||||
| `app/_layout.tsx:127` via `src/notifications/notification-routing.ts:58,64` | `/h/<id>/session/<wt>` or `/h/<id>` | **Coldest path** — `getLastNotificationResponse()` after launch from a killed app, plus the warm listener |
|
||||
| `app/index.tsx:740-746` (Resume) | `/h/[hostId]/session/[worktreeId]` | Fixed by PR #12001 |
|
||||
| `app/index.tsx:835` (Account-usage card) | `/h/<id>/accounts` | Home is the root route |
|
||||
| `orca://` deep links (scheme in `app.json:9`, no linking config) | any `/h/...` | Default filesystem linking, zero coordination; `app/_layout.tsx:52-58` only intercepts pairing codes |
|
||||
| `app/h/[hostId]/history/[worktreeId].tsx:15`, `pr/[worktreeId].tsx:16` | redirect to source-control | A cold deep link to these hits the same cold-navigator resolution first |
|
||||
|
||||
Shallow index-only pushes (`app/index.tsx:723,803`, onboarding/pair flows) don't need coordination.
|
||||
No `<Link>`, `navigationRef`, or `router.navigate` anywhere in `mobile/`.
|
||||
|
||||
Related hazard: `HostProtocolGate` **unmounts the mounted HostStack mid-connect** for a first-visit
|
||||
host — stack mounts while `connecting`, is replaced by a spinner when `status.get` goes in flight
|
||||
(`statusPending` true only when connected: `src/transport/host-status-gates.ts:111`), then remounts
|
||||
(`src/components/HostProtocolGate.tsx:34-44`). A deep navigation that resolved into the first mount
|
||||
can be destroyed by the gate cycle.
|
||||
|
||||
### (b) Surfaces that render grey 'disconnected' during expected transients
|
||||
|
||||
Store defaults: every read API on the canonical store defaults to `'disconnected'` for a missing
|
||||
entry — `getState` (`src/transport/client-context.tsx:221`), `useHostClient` seed/re-seed/unbound
|
||||
fallback (`:343-345,377,393`). Exactly one call site defaults to amber instead: the home screen's
|
||||
`hostStates[id] ?? 'connecting'` (`app/index.tsx:707,903`), whose reconciliation effect also
|
||||
refuses to write `'disconnected'` for a never-tracked host (`:378-394`) — home already solved
|
||||
locally what every other surface gets wrong.
|
||||
|
||||
The grey window is not one frame: `openEntry` awaits `loadHosts()` (a Keychain/SecureStore pass)
|
||||
**before** inserting the store entry (`client-context.tsx:87-161`, insert at `:153`), so a cold
|
||||
start or deep link into `/h/[hostId]` shows grey for the whole Keychain latency. The physical
|
||||
client is not the cause — it already reports `'connecting'` synchronously by the time `connect()`
|
||||
returns (`rpc-client.ts:302,967`). Additionally, `forceReconnect` deletes the entry then awaits the
|
||||
async reopen (`client-context.tsx:201-218`), so **every Retry button drives the UI grey before
|
||||
amber**.
|
||||
|
||||
Surfaces that show grey / "disconnected" copy for a healthy host during these transients (all via
|
||||
`useHostClient`): host header dot (`app/h/[hostId]/index.tsx:819`); host toolbar + FAB disabled
|
||||
(`:850-860,941-1000,1063-1078,1209`); the workspace list body renders **nothing at all** for
|
||||
`disconnected` — `selectHostWorkspaceListState` falls through to `null`, not even a spinner
|
||||
(`src/worktree/host-workspace-list-state.ts:17-24`); tasks header dot + "Connect to a host" empty
|
||||
state (`app/h/[hostId]/tasks.tsx:8681,8661-8663`); session dot (no `verdict` prop at all,
|
||||
`session/[worktreeId].tsx:4434`) and the literal "Disconnected" chip (`:4246-4255`); native-chat
|
||||
composer lock (`src/session/MobileNativeChatView.tsx:427-432`); source-control / git-history /
|
||||
diff-review / file-explorer / agent-history "Waiting for desktop…" states; the connection-log
|
||||
screen prints the raw enum (`app/connection-log.tsx:113-117`); home's Resume/Accounts/Tasks/Quick
|
||||
Action gates all read `=== 'connected'`; voice settings goes fully inert
|
||||
(`app/voice-settings.tsx:50-55`). Counter-examples that behave well: home host card, the accounts
|
||||
screen ("Connecting to {host}…" + cached snapshot, `app/h/[hostId]/accounts.tsx:366-370`).
|
||||
|
||||
Deliberate transients that publish `'disconnected'` while healthy work proceeds: relay suspend on
|
||||
focus/network nudges (S2); background suspend (`src/transport/mobile-endpoint-supervisor.ts:123` —
|
||||
correct per billing, but state stays grey through the entire foreground re-dial rather than
|
||||
flipping to `'connecting'`); post-migration cleanup (`:251-253`); `closeHost` during the
|
||||
pair-confirm handoff (`src/transport/client-context.tsx:83`); three open-failure paths
|
||||
(`:107,114,135`).
|
||||
|
||||
Destructive companion pattern — state flips don't just recolor, they **wipe loaded data**:
|
||||
`host-status-gates.ts:32,100-112` wipes cached host capabilities on every disconnect;
|
||||
`tasks.tsx:2774-2800` resets the whole screen's hydration and force-closes ~15 sheets;
|
||||
`session/[worktreeId].tsx:2043,2432-2439,3713-3716` clears diff comments/capability flags/agent
|
||||
lists; the PR sidebar hides entirely (`src/session/use-mobile-pr-branch-context.ts:59-66` →
|
||||
`use-mobile-pr-sidebar-controller.ts:113-118`); git history blanks rows on the **reconnect** branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68`); the repo cache survives disconnect but is
|
||||
wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333`); the worktree cache is read
|
||||
only at mount/hostId change (`app/h/[hostId]/index.tsx:129,330`), never on reconnect, so a >30s
|
||||
entry means an empty remount.
|
||||
|
||||
Same bug class in a second enum: `workspaceSshStatusLabel` defaults a `null` SSH status to
|
||||
"Disconnected" (`src/tasks/workspace-ssh-gate.ts:14-37`, rendered in `NewWorktreeModal.tsx:863`
|
||||
and `tasks.tsx:10970`).
|
||||
|
||||
### (c) Invisible relay establishment phases
|
||||
|
||||
- `connectMobileRelayRpcSession` (normal relay connects) has **no onLog** — the entire relay
|
||||
session lifecycle emits nothing (`src/transport/mobile-relay-rpc-session.ts:30-39`); the
|
||||
supervisor logs only coarse post-hoc lines (`mobile-endpoint-supervisor.ts:185-188,261`).
|
||||
- `migrateTo` structurally discards the dialing session's `connecting`/`handshaking` states
|
||||
(`src/transport/stable-logical-rpc-client.ts:182-223,267-299`).
|
||||
- Direct→relay upgrade path has no sink at all (`src/transport/mobile-endpoint-lifecycle.ts:49-58`,
|
||||
`mobile-relay-direct-upgrade-controller.ts:19`).
|
||||
- Pairing relay path fully silent (S3 above); pairing logs never reach `connectionLogStore`.
|
||||
- Path label ("Orca Relay") gated on `connected` (`src/components/MobileHostCard.tsx:47`);
|
||||
`classifyConnection` collapses `connecting`/`handshaking`/`reconnecting` and has no relay branch.
|
||||
- Regression suite for connect-label stalls exists for the direct path only
|
||||
(`src/transport/cellular-connecting-label-stall.test.ts`); no relay equivalent.
|
||||
|
||||
## 4. Fix plan
|
||||
|
||||
Ordered by felt-flakiness-removed per unit risk. All fixes are phone-local; none change the wire
|
||||
protocol, so every old/new phone × old/new desktop pairing keeps working unless noted.
|
||||
|
||||
### F0 (S1, quick win) — land PR #12001 ✅ MERGED
|
||||
|
||||
Squash-merged to main as `7948e46db855` (2026-08-04) after validation: CI fully green; drift check
|
||||
against current main clean (only overlap, #12575, touches different regions of `app/index.tsx` and
|
||||
auto-merges); full mobile suite (411 files, 3110 tests) passed on a local merge of main into the PR
|
||||
branch. The PR routes Resume through the shared mount-then-replace mechanism
|
||||
(`src/navigation/host-stack-navigation.ts`) and adds a source-guard test against reintroducing the
|
||||
bare push. This branch has since been fast-forwarded onto that merge, and F4 builds on the
|
||||
extracted module.
|
||||
Backward compat: navigation-only, none.
|
||||
Residuals tracked as F4/F7/F8/F9.
|
||||
|
||||
### F1 (S2, quick win) — stop suspending a healthy relay on focus ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: split the nudge reasons that today all funnel into `setForeground(true)`:
|
||||
|
||||
- Screen-focus nudge (`app/h/[hostId]/index.tsx:515`): must not suspend. For the relay path, either
|
||||
no-op (state changes already drive the UI) or run a cheap liveness probe (an RPC with a short
|
||||
budget) and only enter recovery on failure — mirroring the direct path's activity probe.
|
||||
- Network-change / app-resume nudges: keep half-open protection, but **verify by replacement**
|
||||
instead of break-before-make: call the existing `recoverRelay(forceReplacement = true)` path
|
||||
(proven by lease rotation) so `migrateTo` swaps sessions with the dot staying green; only if the
|
||||
replacement dial fails, fall back to `suspendActiveRelay` so a genuinely dead link stops lying
|
||||
green and the retry loop re-arms (plain `recoverRelay` early-returns while the stale state is
|
||||
still `'connected'`, so the fallback suspend is required for convergence).
|
||||
|
||||
Files: `src/transport/mobile-relay-reconnect-controller.ts` (`handleForeground`),
|
||||
`src/transport/mobile-endpoint-supervisor.ts` (thread a nudge reason; failure-path suspend),
|
||||
`src/transport/mobile-endpoint-lifecycle.ts`, `src/transport/host-logical-client.ts` (reason-tagged
|
||||
`notifyForeground`), optionally `src/transport/rpc-client.ts` type for the reason parameter.
|
||||
Risk: PEER_DROPPED/LIMIT_EXCEEDED churn if replacement dials overlap — reuse the existing
|
||||
`shouldDefer` cooldown; billed duplicate socket for the overlap window (lease rotation already
|
||||
accepts this). Half-open regression risk is covered by the fallback suspend.
|
||||
Tests: split `mobile-endpoint-supervisor.test.ts:~160-197` into (focus nudge → no suspend, dot
|
||||
stays green) and (network handoff → replacement dial; failure → suspend + cooldown). Keep the
|
||||
background-suspend test unchanged.
|
||||
|
||||
### F2 (S2/S3, quick win) — unify mount defaults to 'connecting' ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: `getState(hostId)` returns `'connecting'` when the host is known (primed profile or
|
||||
pending open) and no entry exists yet; `'disconnected'` only for unknown/closed hosts. Aligns every
|
||||
host screen with home's `?? 'connecting'`. Two companion changes in the same class:
|
||||
- `forceReconnect` should notify `'connecting'` (or insert a placeholder entry) instead of leaving
|
||||
the deleted-entry window grey (`src/transport/client-context.tsx:201-218`) — every Retry button
|
||||
currently drives the UI grey before amber.
|
||||
- Optionally have `openEntry` insert a `'connecting'` placeholder before the Keychain read so the
|
||||
cold-start gap (`client-context.tsx:87-153`) is amber too.
|
||||
|
||||
**Required interaction fix**: `app/h/[hostId]/index.tsx:738-741` falls back to
|
||||
`lastKnownWorktrees` only for `disconnected | reconnecting | auth-failed`; `connecting`/
|
||||
`handshaking` fall through to the live (empty on fresh mount) array. Flipping the default without
|
||||
extending that predicate would silently disable the stale-list fallback and blank the list —
|
||||
extend it to every not-connected state (or key it on "no live fetch has succeeded this mount").
|
||||
Files: `src/transport/client-context.tsx` (`getState`, `useHostClient`, `forceReconnect`,
|
||||
`openEntry`), `app/h/[hostId]/index.tsx` (fallback predicate),
|
||||
`src/worktree/host-workspace-list-state.ts` (render a spinner for the not-connected states instead
|
||||
of `null`).
|
||||
Risk: a permanently unreachable host now shows amber briefly before the verdict system escalates —
|
||||
acceptable; `classifyConnection` already owns escalation. Audit the §3(b) "wipe" sites for any that
|
||||
key on `'disconnected'` specifically.
|
||||
Tests: `client-context.test.ts` known-vs-unknown host defaults + forceReconnect state sequence;
|
||||
host screen test for the stale-list fallback under `'connecting'`.
|
||||
|
||||
### F3 (S3, quick win) — give the pairing relay path a log sink ✅ IMPLEMENTED (this branch)
|
||||
|
||||
Approach: add an optional `onLog` to `connectMobileRelayForPairing`,
|
||||
`createRecoveringPairingRelayCandidate`, and `resolvePairingInviteThroughDirector`; thread
|
||||
`connectOptions.onLog` from the coordinator to the relay candidate; emit phase lines ("relay:
|
||||
resolving director…", "relay: cell connected", "relay: E2EE handshake…", "relay: authenticated",
|
||||
"relay: installing credential…"). Optionally also append pairing logs into `connectionLogStore`
|
||||
under the resolved host id so the Connection Log screen has a record post-pairing.
|
||||
Files: `src/transport/mobile-relay-physical-client.ts`, `pairing-relay-candidate.ts`,
|
||||
`mobile-relay-invite-director.ts`, `pre-profile-pairing-coordinator.ts`.
|
||||
Risk: none (additive, phone-local). Old desktops: unaffected — logging only.
|
||||
Tests: coordinator test asserting relay-path log entries arrive through `connectOptions.onLog`;
|
||||
extend `pairing-relay-candidate.test.ts` for per-attempt lines.
|
||||
|
||||
### F4 (S1 class, quick win after F0) — coordinate the remaining bare deep pushes
|
||||
|
||||
Approach: route notification taps (`app/_layout.tsx:127` + `src/notifications/notification-routing.ts`)
|
||||
and the Account-usage card (`app/index.tsx:835`) through `src/navigation/host-stack-navigation.ts`
|
||||
once #12001 lands; migrate host-edit onto the same stricter mechanism (#12001's own noted
|
||||
follow-up). `orca://` deep links can follow later via a route-level guard.
|
||||
Risk: notification cold-start ordering (push before root nav ready) — the mechanism already
|
||||
tolerates that by waiting for state commits.
|
||||
Tests: reuse the `host-stack-navigation.test.ts` harness for a notification-shaped target.
|
||||
|
||||
### F5 (S2/S3, deeper) — make relay dials visible through `migrateTo`
|
||||
|
||||
Approach: while the logical client is `suspended`/`'disconnected'`, have `migrateTo` forward the
|
||||
dialing session's state publishes (`connecting`/`handshaking`) to `publishState`, unbinding on
|
||||
success (normal bind takes over) or failure (restore `'disconnected'`). Guard: never downgrade a
|
||||
still-`'connected'` previous session (make-before-break migrations must stay green). Follow-on UI:
|
||||
show the path being dialed ("Connecting via Orca Relay…") by exposing the pending path, and let
|
||||
`MobileHostCard`/`classifyConnection` render it while not yet connected.
|
||||
Files: `src/transport/stable-logical-rpc-client.ts` (+ its test), `src/transport/connection-health.ts`,
|
||||
`src/components/MobileHostCard.tsx`, `src/transport/mobile-connection-path-label.ts`.
|
||||
Risk: state-ordering regressions in the pinned stable-client and connecting-label suites; keep the
|
||||
forwarding strictly gated on suspended/disconnected.
|
||||
Tests: add a relay-path analog of `cellular-connecting-label-stall.test.ts`; stable-client cases:
|
||||
forwarded states during suspended dial, no forwarding during live-session replacement, failure
|
||||
restores `'disconnected'`.
|
||||
|
||||
### F6 (S3, deeper) — happy-eyeballs relay start post-pairing
|
||||
|
||||
Approach: when a relay credential bundle exists and the direct dial has not authenticated within a
|
||||
short grace (2–3s), start the relay dial in parallel instead of waiting for the 12s direct failure;
|
||||
first authenticated path wins via the existing `migrateTo`/hysteresis machinery. Scope initially to
|
||||
the first connect after pairing (or hosts whose last success was relay) to avoid pointless relay
|
||||
sockets on healthy LANs.
|
||||
Files: `src/transport/mobile-endpoint-supervisor.ts` (start/needsRecovery gating), possibly a
|
||||
phone-local `HostProfile` hint field (no protocol impact; old desktops never present `relay`, so
|
||||
the path is naturally guarded).
|
||||
Risk: racing direct is exactly what `needsRecovery`'s design avoids — needs the mutex
|
||||
(`operationInFlight`) audit and dwell/hysteresis respect; billed relay data on LANs if scoped too
|
||||
broadly.
|
||||
Tests: supervisor fake-timer cases: black-holed LAN converges in ~3-5s; healthy LAN never opens a
|
||||
relay socket; relay loser closed after direct wins.
|
||||
|
||||
### F7 (S1, deeper) — catalog-validate resume targets + not-found bounce
|
||||
|
||||
Approach: (1) on Resume tap with the host connected, validate the target against the freshest
|
||||
`worktree.ps` result (not the snapshot-seeded cache); if absent, open the host index instead.
|
||||
(2) In the session screen, once connected and the catalog is known, bounce unknown `worktreeId`s
|
||||
(exempting `folder:` and floating-workspace sentinels, `app/h/[hostId]/session/[worktreeId].tsx:852-854`)
|
||||
to the host index with a notice. (3) Use the validating reader in
|
||||
`src/worktree/last-visited-worktree-repo.ts` on home instead of the raw `JSON.parse`
|
||||
(`app/index.tsx:315-322`), and import the storage-key constant at both literal call sites.
|
||||
Risk: false bounces during slow catalog loads — only bounce on a *confirmed* fresh catalog miss.
|
||||
Tests: repo tests for the validating reader on home; session-screen bounce cases incl. sentinel
|
||||
exemptions.
|
||||
|
||||
### F8 (S1 aggravator, cheap) — stop the Resume/Tasks layout shift
|
||||
|
||||
Approach: reserve the Resume card's slot (fixed-height placeholder or render-below-Tasks) so its
|
||||
late arrival cannot move the Tasks card under the thumb; alternatively render the card immediately
|
||||
from the snapshot in a disabled state until the host connects.
|
||||
Files: `app/index.tsx` footer.
|
||||
Risk: none.
|
||||
Tests: render test asserting footer order/height stability across `resumeWorktree` arrival.
|
||||
|
||||
### F9 (S1 class, deeper) — HostProtocolGate should not unmount a mounted stack
|
||||
|
||||
Approach: once the HostStack has mounted for a host, keep it mounted and overlay the pending
|
||||
spinner instead of replacing children, preserving in-flight nested navigation; keep the hard
|
||||
replace only for the `blocked` verdict.
|
||||
Files: `src/components/HostProtocolGate.tsx`.
|
||||
Risk: the gate exists so child routes don't call too-new RPCs while compatibility is unknown — an
|
||||
overlay must still block interaction until resolved; verify child mount effects don't fire gated
|
||||
RPCs pre-verdict before choosing overlay vs. current behavior.
|
||||
Tests: gate test asserting no unmount across `statusPending` for an already-mounted host.
|
||||
|
||||
### F10 (S2 class, deeper) — stop wiping loaded data on transient state flips
|
||||
|
||||
Approach: audit the §3(b) destructive-clear sites and make each preserve data across a
|
||||
not-`'connected'` blip, clearing only on host change or explicit sign-out. Top offenders by felt
|
||||
impact: git history blanking rows on the reconnect branch
|
||||
(`src/source-control/MobileGitHistoryList.tsx:63-68` — refetch without `setRows(null)`); the repo
|
||||
cache wiped by the rejected in-flight call (`NewWorktreeModal.tsx:330-333` — keep last-good on
|
||||
error); the diff-review "ready-state preserved" branch that is dead code because it sits after the
|
||||
early return (`src/session/use-mobile-diff-review-controller.ts:85-89`); host capability wipe
|
||||
(`src/transport/host-status-gates.ts:32`); the tasks-screen full re-hydration
|
||||
(`app/h/[hostId]/tasks.tsx:2774-2800`); worktree-cache re-read on reconnect, not only at mount
|
||||
(`app/h/[hostId]/index.tsx:129,330`).
|
||||
Risk: showing stale data as if live — pair each preservation with the existing staleness verdicts
|
||||
rather than inventing new indicators. The in-repo reference pattern is
|
||||
`src/worktree/home-worktree-info.ts:27-47`: counts older than a 10min TTL render as
|
||||
"Last known: N worktrees" instead of being dropped, and `markHomeWorktreeCatalogUnavailable`
|
||||
preserves proven counts across a failed refresh, flagging only `staleCounts`.
|
||||
Tests: per-surface "data survives disconnect→reconnect" cases; F1 largely removes the *trigger*
|
||||
(suspend blips), so this is hardening, not the primary fix.
|
||||
|
||||
## 5. Backward compatibility (old/new phone × old/new desktop)
|
||||
|
||||
- Every fix above is phone-app-local; no RPC methods, close codes, credential formats, or pairing
|
||||
steps change. Old phones against any desktop are untouched (they don't have the code).
|
||||
- New phone + old desktop without relay support: `host.relay` is absent → F1/F5/F6 relay paths
|
||||
never activate; pairing keeps the existing `method_not_found` downgrade
|
||||
(`src/transport/pre-profile-pairing-coordinator.ts:210-217`); F3 logging is inert (no relay
|
||||
candidate is created).
|
||||
- New phone + old desktop with relay: all paths use existing RPCs (`status.get`,
|
||||
`pairing.provisionRelay`, resume confirm) — no new calls introduced. F1's replacement dial reuses
|
||||
the same resume-credential flow lease rotation already exercises against production desktops.
|
||||
- F6's profile hint (if added) is a phone-local persisted field; absent values behave as today.
|
||||
|
||||
## 6. Constants appendix (verified)
|
||||
|
||||
| Constant | Value | Where |
|
||||
|---|---|---|
|
||||
| Direct connect timeout | 12s | `src/transport/rpc-client.ts:126` |
|
||||
| Direct handshake timeout | 5s | `rpc-client.ts:127` |
|
||||
| Direct reconnect ladder | 0.5→60s, give up 12, trickle 90s | `rpc-client.ts:113-117` |
|
||||
| `migrateTo` auth timeout | 12s | `src/transport/stable-logical-rpc-client.ts:182` |
|
||||
| Relay backoff | 250ms floor, 500ms base, 30s ceiling, full jitter | `src/transport/mobile-relay-retry-delays.ts:3-5` |
|
||||
| Host-offline relay retry | 5–15s | `mobile-relay-retry-delays.ts:7-8` |
|
||||
| Gate reprobe cadence | 60s→15min | `mobile-relay-retry-delays.ts:13-14` |
|
||||
| Director resolve timeout | 5s (invite & resume) | `mobile-relay-invite-director.ts:16`, `mobile-relay-resume-director.ts:21` |
|
||||
| Pairing relay recovery | ≤3 attempts × (5s director + ≤100/200/400ms jitter) | `src/transport/pairing-relay-candidate.ts:42,58-71` |
|
||||
| Pairing overall cap | 25s | `app/pair-confirm.tsx:27` |
|
||||
| Relay E2EE layer timers | none | `mobile-relay-e2ee-link.ts`, `mobile-e2ee-v2-*.ts` |
|
||||
| Worktree cache TTL | 30s from write/seed | `src/cache/worktree-cache.ts:12` |
|
||||
| Direct activity probe (foreground) | detects half-open ≤8s | `rpc-client.ts:1119-1124` |
|
||||
@@ -155,6 +155,8 @@ display `:99` when no display exists:
|
||||
Description=Orca runtime server
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
@@ -174,6 +177,19 @@ WantedBy=multi-user.target
|
||||
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
|
||||
clients should use.
|
||||
|
||||
Exit status `3` means another process already owns this userData profile, so
|
||||
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
|
||||
cannot succeed. Any other permanent startup fault is capped at 5 starts per
|
||||
5 minutes; systemd's defaults (10s window, 5 starts) can never trip at
|
||||
`RestartSec=5`, which is how one bad launch could restart thousands of times.
|
||||
The start limit counts operator-initiated starts too, so once it trips systemd
|
||||
refuses a plain `systemctl start` until the 5-minute window rolls over. Run
|
||||
`sudo systemctl reset-failed orca-serve.service` first to clear it — the
|
||||
[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do.
|
||||
On systemd older than 230 those two directives are spelled
|
||||
`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu
|
||||
20.04, Orca's oldest supported base, ships systemd 245.
|
||||
|
||||
Enable the service:
|
||||
|
||||
```bash
|
||||
@@ -228,6 +244,8 @@ Then add the display dependency to the Orca service:
|
||||
Description=Orca runtime server
|
||||
After=network-online.target orca-xvfb.service
|
||||
Wants=network-online.target orca-xvfb.service
|
||||
StartLimitIntervalSec=300
|
||||
StartLimitBurst=5
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -237,6 +255,7 @@ Environment=DISPLAY=:99
|
||||
Environment=LIBGL_ALWAYS_SOFTWARE=1
|
||||
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
|
||||
Restart=on-failure
|
||||
RestartPreventExitStatus=3
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
@@ -415,6 +434,8 @@ recover_failed_upgrade() {
|
||||
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
|
||||
/opt/orca/VERSION.recovering
|
||||
if ((recovery_ok)); then
|
||||
# A tripped StartLimitBurst refuses a plain start
|
||||
sudo systemctl reset-failed orca-serve.service || true
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Upgrade recovery failed; service remains stopped' >&2
|
||||
@@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
|
||||
ORCA_BINARY_PROMOTED=1
|
||||
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
|
||||
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
|
||||
# Clears a start-limit hit left by the version being replaced
|
||||
sudo systemctl reset-failed orca-serve.service
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
trap - EXIT
|
||||
@@ -614,6 +637,8 @@ restart_after_rollback_error() {
|
||||
fi
|
||||
fi
|
||||
if ((recovery_ok)); then
|
||||
# A tripped StartLimitBurst refuses a plain start
|
||||
sudo systemctl reset-failed orca-serve.service || true
|
||||
sudo systemctl start orca-serve.service || true
|
||||
else
|
||||
echo 'Rollback recovery failed; service remains stopped' >&2
|
||||
@@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then
|
||||
else
|
||||
sudo rm -f /opt/orca/VERSION
|
||||
fi
|
||||
# The crash-looping build you are rolling back from tripped StartLimitBurst
|
||||
sudo systemctl reset-failed orca-serve.service
|
||||
sudo systemctl start orca-serve.service
|
||||
ORCA_SERVICE_STOPPED=0
|
||||
sudo rm -rf -- "$ORCA_RESTORE"
|
||||
@@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want.
|
||||
`orca` user and that `/opt/orca` is readable by that user.
|
||||
- Clients cannot connect: make sure `--pairing-address` is an address reachable
|
||||
from the client, and make sure firewalls allow the selected `--port`.
|
||||
- Journal shows `Another Orca instance is already running for this userData
|
||||
profile` and the unit exits `3`: another process already owns the profile, so
|
||||
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
|
||||
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
|
||||
keep it and leave the unit down), then run
|
||||
`sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` —
|
||||
`reset-failed` clears the failed state and any start-limit counter. If no owner
|
||||
exists, the lock is stale (Chromium recorded a pid that
|
||||
has since been reused): remove `SingletonLock` and `SingletonSocket` from the
|
||||
userData directory and start again. If an earlier crash-loop already leaked
|
||||
AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and
|
||||
release only the ones with no live owner using `fusermount -uz <target>` (or
|
||||
`umount -l <target>`), leaving the running instance's mount alone.
|
||||
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
|
||||
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
|
||||
make the crashing version the next rollback binary.
|
||||
make the crashing version the next rollback binary. The loop trips
|
||||
`StartLimitBurst`, so any manual `systemctl start` outside that script needs
|
||||
`sudo systemctl reset-failed orca-serve.service` first.
|
||||
- Diagnosing other missing libraries: extract the AppImage without launching it
|
||||
with `./orca-linux.AppImage --appimage-extract`, then run
|
||||
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Remote wire compatibility
|
||||
|
||||
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and
|
||||
users update the two independently. **Mixed versions are the normal state**, not an
|
||||
edge case. This page is the contract for changing anything a paired client and host
|
||||
exchange: the runtime RPC envelope, the terminal binary stream, and the content
|
||||
either side publishes over them.
|
||||
|
||||
`src/shared/protocol-version.ts` says when to bump `RUNTIME_PROTOCOL_VERSION`. This
|
||||
page covers the changes that do _not_ bump it and are therefore easy to get wrong.
|
||||
|
||||
## Rule 1 — a new optional JSON field on an existing frame is safe
|
||||
|
||||
Every JSON payload is parsed with a decoder that ignores unknown keys (zod `.strip()`
|
||||
on RPC params, `JSON.parse` on stream frames). An older peer that has never heard of
|
||||
the field simply does not read it.
|
||||
|
||||
Safe:
|
||||
|
||||
```ts
|
||||
// host adds a field; older clients ignore it
|
||||
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
|
||||
```
|
||||
|
||||
**The field is safe only for as long as every reader treats it as optional.** The
|
||||
moment a newer client _requires_ it, that client is broken against every host that
|
||||
predates the field — which is the same defect as removing a field, just discovered
|
||||
later. If new behavior depends on the field being present, that is Rule 2: negotiate
|
||||
it, or make the reader fall back.
|
||||
|
||||
## Rule 2 — a new stream opcode is NOT safe; negotiate it
|
||||
|
||||
`decodeTerminalStreamFrame` returns `null` for an opcode it does not know, and
|
||||
`runtime-rpc.ts` drops that frame without an error:
|
||||
|
||||
```ts
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return // silently dropped — the sender never learns
|
||||
}
|
||||
```
|
||||
|
||||
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the
|
||||
feature behind it appears to hang. Input sent under a new opcode is swallowed.
|
||||
|
||||
A new opcode must be announced in the subscribe handshake and sent only after the
|
||||
peer confirms it. The existing pattern is `SetOutputPaused` (opcode 16):
|
||||
|
||||
- the client advertises support in the `Subscribe` frame's `capabilities`;
|
||||
- the host echoes `capabilities: { outputPause: 1 }` on the `subscribed` event;
|
||||
- the client sends opcode 16 only after that echo (`stream.supportsOutputPause`);
|
||||
- the host only acts on opcode 16 when it negotiated it (`stream.supportsOutputPause`).
|
||||
|
||||
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that
|
||||
expresses the change; reach for a new opcode only when framing genuinely differs.
|
||||
|
||||
Opcode numbers are permanent. See the `Ack = 13` and `ClaimViewport = 14` comments
|
||||
in `src/shared/terminal-stream-protocol.ts` for why a shipped number cannot be
|
||||
reused even if the feature behind it is removed.
|
||||
|
||||
## Rule 3 — changing what the host publishes breaks old clients with no wire change
|
||||
|
||||
The frame shape can be untouched and the skew still real, because clients react to
|
||||
frame _content_. PR #12641 is the worked example: the host stopped synthesizing a
|
||||
finished agent status, and clients running older code saw different content in an
|
||||
identical frame.
|
||||
|
||||
Treat these as wire changes even though nothing in the codec moves:
|
||||
|
||||
- a field the host stops populating (an old client reading it now sees `undefined`);
|
||||
- a value whose meaning, units, or nullability changes;
|
||||
- content the host stops synthesizing, trims, or starts deriving from a new source;
|
||||
- a frame the host stops sending, or starts sending, on an existing path.
|
||||
|
||||
If old clients cannot interpret the new projection correctly, gate it behind a
|
||||
runtime capability the same way Rule 2 gates an opcode.
|
||||
|
||||
## Enforcement
|
||||
|
||||
`tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts` runs the real
|
||||
host RPC methods and the real renderer multiplexer from two builds against each
|
||||
other — current working tree against the newest release tag, in both skew
|
||||
directions — over one scripted terminal journey (subscribe, input, hide/reveal
|
||||
snapshot, drop, reconnect).
|
||||
|
||||
Run it with:
|
||||
|
||||
```bash
|
||||
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
```
|
||||
|
||||
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the
|
||||
observed frame sequence changes (Rule 3), or when published snapshot content or
|
||||
negotiated capabilities differ from the contract. Adding an optional field keeps it
|
||||
green (Rule 1); making a client depend on that field turns the new-client/old-host
|
||||
pairing red.
|
||||
|
||||
The harness covers the terminal stream only. It does **not** cover the session-tab
|
||||
sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or
|
||||
the relay transport. A change on those paths still needs its own reasoning against
|
||||
the three rules above.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Windows setup-runner shell
|
||||
|
||||
On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated
|
||||
runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by
|
||||
default**, exactly as it has been since setup hooks shipped.
|
||||
|
||||
A script opts into bash by starting with a `#!` interpreter line:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
setup: |
|
||||
#!/usr/bin/env bash
|
||||
[ -f .env ] || cp .env.example .env
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Without that line the script keeps running under `cmd.exe`:
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
setup: |
|
||||
copy .env.example .env
|
||||
xcopy /E assets dist
|
||||
```
|
||||
|
||||
## Why the script declares it, not the terminal preference
|
||||
|
||||
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
|
||||
language a project's setup script is written in. Deriving the runner from it had two consequences:
|
||||
|
||||
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
|
||||
`xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working.
|
||||
- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project
|
||||
could write a setup script that worked for all of its Windows contributors.
|
||||
|
||||
A `#!` line is per-project, explicit, and identical for everyone who checks the repo out.
|
||||
|
||||
The same rule applies to the per-user setup command in **Settings → repository hooks**
|
||||
(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a
|
||||
POSIX one-liner stored there needs its own `#!` line to run under bash on Windows.
|
||||
|
||||
## What the `#!` line does and does not select
|
||||
|
||||
The generated runner is always executed by bash (`bash <runner>`; Git Bash on native Windows), on
|
||||
every platform. The `#!` line therefore does two things:
|
||||
|
||||
- It declares the script is written for a POSIX shell, which is what selects the bash runner.
|
||||
- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does
|
||||
get `pipefail`. Without that replay the flags would be silently dropped, because `bash <runner>`
|
||||
never parses the interpreter line. Only the flags `set` itself accepts
|
||||
(`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are
|
||||
dropped, because `set -l` exits 2 and would abort the runner before its first line.
|
||||
|
||||
The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and
|
||||
`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux.
|
||||
|
||||
## Requirements for the bash runner
|
||||
|
||||
A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the
|
||||
terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner
|
||||
uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is
|
||||
typed into whatever shell the terminal opened with.
|
||||
|
||||
When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses
|
||||
the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd`
|
||||
runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script
|
||||
(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a
|
||||
half-set-up worktree that looks finished.
|
||||
|
||||
## Launching a `.cmd` runner from a Git Bash terminal
|
||||
|
||||
The runner format and the shell that types the launch command are independent: a Git Bash terminal
|
||||
with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c
|
||||
"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens
|
||||
interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell
|
||||
`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch
|
||||
and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the
|
||||
launching pane; the runner file's `.cmd`/`.sh` extension describes the format.
|
||||
|
||||
The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the
|
||||
agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the
|
||||
bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the
|
||||
same `ProcessStartInfo` launcher, so the batch runner is never handed to bash.
|
||||
|
||||
WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts
|
||||
choose their runner from the remote path format, never from local Windows preferences.
|
||||
@@ -3,13 +3,14 @@ import { resolve } from 'node:path'
|
||||
import { defineConfig, type UserConfig } from 'electron-vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { createBootstrapFatalExitBanner } from './build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
|
||||
import { createBootstrapFatalExitBanner } from './config/build-plugins/bootstrap-fatal-exit-banner'
|
||||
import { createPlainNodeEntryGuardPlugin } from './config/build-plugins/plain-node-entry-guard'
|
||||
import packageJson from './package.json' with { type: 'json' }
|
||||
|
||||
const BUNDLED_MAIN_DEPENDENCIES = new Set([
|
||||
'@xterm/headless',
|
||||
'@xterm/addon-serialize',
|
||||
'psl',
|
||||
// Why: Windows NSIS deploys app.asar before external resources; bootstrap must
|
||||
// not race the later resources/node_modules copy.
|
||||
'zod'
|
||||
@@ -216,6 +217,11 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
'src/main/ports/port-scan-command-worker-entry.ts'
|
||||
),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
|
||||
.expo/
|
||||
dist/
|
||||
/android/
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.36",
|
||||
"version": "0.0.41",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 9
|
||||
"versionCode": 11
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
+17
-10
@@ -8,8 +8,9 @@ import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
|
||||
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
|
||||
@@ -34,6 +35,7 @@ Notifications.setNotificationHandler({
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const openNotificationRoute = useOpenNotificationRoute()
|
||||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
@@ -68,6 +70,7 @@ export default function RootLayout() {
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// ─── Notification tap routing ───
|
||||
// Why: iOS delivers local notification taps through expo-notifications,
|
||||
// not Linking. Route both cold-start and warm-start responses to the host
|
||||
// and worktree that scheduled the notification.
|
||||
@@ -91,10 +94,13 @@ export default function RootLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getNavigationPath(data: unknown): Promise<string | null> {
|
||||
const hosts = await loadHosts().catch(() => null)
|
||||
return getNotificationNavigationPath(data, {
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
|
||||
async function getNavigationTarget(data: unknown) {
|
||||
const hosts = await loadHostCatalog().catch(() => null)
|
||||
return getNotificationNavigationTarget(data, {
|
||||
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
|
||||
credentialStatusByHostId: hosts
|
||||
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,13 +124,13 @@ export default function RootLayout() {
|
||||
}
|
||||
}
|
||||
|
||||
const path = await getNavigationPath(response.notification.request.content.data)
|
||||
const target = await getNavigationTarget(response.notification.request.content.data)
|
||||
clearLastNotificationResponse()
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (path) {
|
||||
router.push(path)
|
||||
if (target) {
|
||||
openNotificationRoute(target)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +146,8 @@ export default function RootLayout() {
|
||||
disposed = true
|
||||
sub.remove()
|
||||
}
|
||||
}, [router])
|
||||
}, [openNotificationRoute])
|
||||
// ─── End notification tap routing ───
|
||||
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
|
||||
@@ -55,6 +55,8 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { useHostProtocolGates } from '../../../src/components/HostProtocolGate'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { HostRouteNoticeBanner } from '../../../src/components/HostRouteNoticeBanner'
|
||||
import { visibleHostRouteNotice } from '../../../src/host-route-notice'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
@@ -117,9 +119,12 @@ export function HostScreen({
|
||||
action: actionProp,
|
||||
onHideSidebar
|
||||
}: HostScreenProps = {}) {
|
||||
const params = useLocalSearchParams<{ hostId: string; action?: string }>()
|
||||
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
|
||||
const hostId = hostIdProp ?? params.hostId
|
||||
const action = actionProp ?? params.action
|
||||
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
|
||||
const noticeParam = params.notice?.trim()
|
||||
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const insets = useSafeAreaInsets()
|
||||
@@ -164,7 +169,8 @@ export function HostScreen({
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
filterRepoIds: new Set(),
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true
|
||||
})
|
||||
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
|
||||
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
|
||||
@@ -195,6 +201,7 @@ export function HostScreen({
|
||||
sortMode: 'recent',
|
||||
hideSleeping: false,
|
||||
hideDefaultBranch: false,
|
||||
alwaysShowDefaultBranch: true,
|
||||
filterRepoIds: [],
|
||||
collapsedGroups: [],
|
||||
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
|
||||
@@ -206,6 +213,7 @@ export function HostScreen({
|
||||
sortMode,
|
||||
hideSleeping: filters.hideSleeping,
|
||||
hideDefaultBranch: filters.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
|
||||
filterRepoIds: [...filters.filterRepoIds],
|
||||
collapsedGroups: [...collapsedGroups],
|
||||
workspaceStatuses
|
||||
@@ -222,7 +230,8 @@ export function HostScreen({
|
||||
setFilters({
|
||||
filterRepoIds: new Set(next.filterRepoIds),
|
||||
hideSleeping: next.hideSleeping,
|
||||
hideDefaultBranch: next.hideDefaultBranch
|
||||
hideDefaultBranch: next.hideDefaultBranch,
|
||||
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -234,6 +243,9 @@ export function HostScreen({
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
|
||||
// but has no toggle, so echoing its local default would silently revert a
|
||||
// desktop opt-out on the first filter tap before ui.get lands (#8873).
|
||||
const payload: WorkspaceViewSettings = {
|
||||
groupBy: groupModeToDesktop(next.groupMode),
|
||||
sortBy: next.sortMode,
|
||||
@@ -454,7 +466,7 @@ export function HostScreen({
|
||||
setWorktreesLoaded(true)
|
||||
// Why (#8498): overwrite the home-written cache with the confirmed snapshot so a reconnect/remount can't serve a stale list.
|
||||
if (hostId) {
|
||||
setCachedWorktrees(hostId, confirmed)
|
||||
setCachedWorktrees(hostId, confirmed, { proven: true })
|
||||
}
|
||||
// Drop the optimistic active override once the host reports it active, so later desktop changes win.
|
||||
setOptimisticActiveWorktreeId((pending) =>
|
||||
@@ -505,7 +517,8 @@ export function HostScreen({
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Why: focus nudges reconnect and probes a possibly half-open socket; empty deps fire per focus, not per state flip (which defeats backoff).
|
||||
clientRef.current?.notifyForeground()
|
||||
// 'focus' keeps a healthy relay green — probe, never suspend (S2 grey blink).
|
||||
clientRef.current?.notifyForeground('focus')
|
||||
}, [])
|
||||
)
|
||||
|
||||
@@ -727,10 +740,9 @@ export function HostScreen({
|
||||
)
|
||||
|
||||
const displayWorktrees = useMemo(() => {
|
||||
const base =
|
||||
connState === 'disconnected' || connState === 'reconnecting' || connState === 'auth-failed'
|
||||
? lastKnownWorktrees
|
||||
: worktrees
|
||||
// Why: live `worktrees` is authoritative only while connected; under the amber
|
||||
// mount default, connecting/handshaking must keep the pre-reconnect list too.
|
||||
const base = connState === 'connected' ? worktrees : lastKnownWorktrees
|
||||
if (sleptIds.size === 0 && optimisticActiveWorktreeId === null) {
|
||||
return base
|
||||
}
|
||||
@@ -1093,6 +1105,14 @@ export function HostScreen({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Why a bounced route landed here (e.g. the workspace was deleted on the desktop). */}
|
||||
{routeNotice && (
|
||||
<HostRouteNoticeBanner
|
||||
message={routeNotice}
|
||||
onDismiss={() => setDismissedNotice(noticeParam ?? null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
|
||||
@@ -107,6 +107,7 @@ import type {
|
||||
TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/terminal-webview-contract'
|
||||
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
|
||||
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
|
||||
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
|
||||
import {
|
||||
getDefaultTerminalAccessoryBuiltInIds,
|
||||
@@ -195,8 +196,11 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
|
||||
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 { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
|
||||
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
|
||||
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
@@ -235,6 +239,11 @@ import {
|
||||
} from '../../../../src/session/mobile-terminal-prune-decision'
|
||||
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
|
||||
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
|
||||
import {
|
||||
TerminalViewportResubscribeBudget,
|
||||
readTerminalViewportDims,
|
||||
runTerminalViewportFitPass
|
||||
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
|
||||
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
|
||||
@@ -853,12 +862,19 @@ export default function SessionScreen() {
|
||||
const reconnectAttempts = useReconnectAttempt(hostId)
|
||||
const lastConnectedAt = useLastConnectedAt(hostId)
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const worktreeName = useLiveWorktreeName({
|
||||
const { name: worktreeName, resolution: worktreeResolution } = useLiveWorktreeName({
|
||||
client,
|
||||
connState,
|
||||
routeName: routeWorktreeName,
|
||||
worktreeId
|
||||
})
|
||||
// Why: a workspace deleted on the desktop leaves every RPC on this route failing forever.
|
||||
useMissingWorktreeBounce({
|
||||
hostId,
|
||||
worktreeId,
|
||||
resolution: worktreeResolution,
|
||||
bounce: (id) => router.replace(hostRouteWithNotice(id, 'worktree-missing'))
|
||||
})
|
||||
// Master-detail state: wide layouts dock a tapped panel beside the session; narrow keeps it null and pushes full-screen routes.
|
||||
const { isWideLayout } = useResponsiveLayout()
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel>(null)
|
||||
@@ -1030,6 +1046,8 @@ export default function SessionScreen() {
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
|
||||
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
|
||||
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
|
||||
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
|
||||
const webReadyHandlesRef = useRef<Set<string>>(new Set())
|
||||
const activeHandleRef = useRef<string | null>(null)
|
||||
@@ -1376,6 +1394,7 @@ export default function SessionScreen() {
|
||||
subscribingHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
viewportResubscribeBudgetRef.current.clear()
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
@@ -1505,10 +1524,11 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
const scrollbackRows = rows
|
||||
const { hostCols, hostRows } = readTerminalViewportDims(data)
|
||||
// Why: absent host dims must not be coerced into a comparable size — 80x24
|
||||
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
|
||||
const cols = hostCols ?? viewportRef.current?.cols ?? 80
|
||||
const rows = hostRows ?? viewportRef.current?.rows ?? 24
|
||||
const initialData =
|
||||
typeof data.serialized === 'string' && data.serialized.length > 0
|
||||
? data.serialized
|
||||
@@ -1527,46 +1547,36 @@ export default function SessionScreen() {
|
||||
ref.init(cols, rows, initialData, false, oscLinks)
|
||||
initializedHandlesRef.current.add(handle)
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
|
||||
const needsResubscribe =
|
||||
!viewportMeasuredRef.current ||
|
||||
(viewportRef.current != null &&
|
||||
(scrollbackCols !== viewportRef.current.cols ||
|
||||
scrollbackRows !== viewportRef.current.rows))
|
||||
if (needsResubscribe) {
|
||||
void (async () => {
|
||||
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
|
||||
await getTerminalRef(handle)?.awaitReady()
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions(
|
||||
terminalFrameHeightRef.current || undefined
|
||||
)
|
||||
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
|
||||
if (subscribeSeqRef.current.get(handle) !== seq) {
|
||||
return
|
||||
}
|
||||
if (!getTerminalRef(handle)) {
|
||||
return
|
||||
}
|
||||
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
|
||||
if (dims) {
|
||||
diagnostics.streamResubscribing(handle, seq, dims)
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
unsubscribeTerminal(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
subscribeToTerminal(handle)
|
||||
}
|
||||
})()
|
||||
}
|
||||
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
|
||||
// and resubscribe so the server can phone-fit — bounded per handle so a
|
||||
// non-converging host degrades visibly instead of hot-looping (STA-3337).
|
||||
runTerminalViewportFitPass({
|
||||
handle,
|
||||
seq,
|
||||
hostCols,
|
||||
hostRows,
|
||||
budget: viewportResubscribeBudgetRef.current,
|
||||
diagnostics,
|
||||
viewportRef,
|
||||
viewportMeasuredRef,
|
||||
subscribeSeqRef,
|
||||
initializedHandlesRef,
|
||||
terminalUnsubsRef,
|
||||
terminalFrameHeightRef,
|
||||
getTerminalRef,
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal,
|
||||
scheduleDelayedAction,
|
||||
showToast
|
||||
})
|
||||
} else if (data.type === 'metadata') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
} else if (data.type === 'data') {
|
||||
@@ -1591,8 +1601,12 @@ export default function SessionScreen() {
|
||||
} else if (data.type === 'resized') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
|
||||
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
|
||||
handle,
|
||||
data,
|
||||
viewport
|
||||
)
|
||||
const serialized = typeof data.serialized === 'string' ? data.serialized : null
|
||||
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
|
||||
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
|
||||
@@ -1602,8 +1616,10 @@ export default function SessionScreen() {
|
||||
getTerminalRef(handle)?.resize(cols, rows)
|
||||
}
|
||||
if (data.displayMode) {
|
||||
const displayMode = data.displayMode as MobileDisplayMode
|
||||
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
|
||||
setTerminalModes((prev) =>
|
||||
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
|
||||
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
|
||||
)
|
||||
}
|
||||
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
|
||||
@@ -1619,7 +1635,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
},
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
|
||||
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
|
||||
)
|
||||
|
||||
const nativeChatStream = useMobileNativeChatTerminalStream({
|
||||
@@ -1687,7 +1703,8 @@ export default function SessionScreen() {
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
worktree: `id:${worktreeId}`,
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
@@ -1720,12 +1737,16 @@ export default function SessionScreen() {
|
||||
unsubscribeTerminal(handle)
|
||||
terminalRefs.current.delete(handle)
|
||||
initializedHandlesRef.current.delete(handle)
|
||||
viewportResubscribeBudgetRef.current.forget(handle)
|
||||
clearTerminalLiveInputDefault(handle)
|
||||
}
|
||||
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
|
||||
// Why: a chat-covered handle the host reports again refills its rearm budget,
|
||||
// so an exhausted rearm can't lock the composer until leave-chat.
|
||||
nativeChatStream.notifyListedHandles(liveHandles)
|
||||
// Why: same absence-gated refill for the viewport-fit budget — a handle that
|
||||
// left the list and returned may converge now, so it earns fresh attempts.
|
||||
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
|
||||
lastKnownTerminalCountRef.current = result.terminals.length
|
||||
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
|
||||
const seen = new Set<string>()
|
||||
@@ -2629,7 +2650,7 @@ export default function SessionScreen() {
|
||||
useEffect(() => {
|
||||
if (hostId && worktreeId) {
|
||||
void AsyncStorage.setItem(
|
||||
'orca:last-visited-worktree',
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
JSON.stringify({ hostId, worktreeId })
|
||||
)
|
||||
}
|
||||
@@ -2861,7 +2882,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -2901,7 +2923,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
@@ -2925,7 +2948,8 @@ export default function SessionScreen() {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
@@ -3182,44 +3206,23 @@ export default function SessionScreen() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
if (handle !== activeHandleRef.current || !client) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++handleFileTapActivationSeqRef.current
|
||||
openMobileTerminalFileTap<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => router.push(href),
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: handleFileTapActivationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: activeHandleRef.current,
|
||||
activeTabType: activeSessionTabTypeRef.current
|
||||
}),
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
|
||||
)
|
||||
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
|
||||
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure: nativeChatSendError.show
|
||||
})
|
||||
|
||||
const handleOpenedFileDiffActivationSeqRef = useRef(0)
|
||||
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
|
||||
@@ -3590,6 +3593,7 @@ export default function SessionScreen() {
|
||||
if (
|
||||
current &&
|
||||
current.cursorY === metrics.cursorY &&
|
||||
current.contentBottomRow === metrics.contentBottomRow &&
|
||||
current.rows === metrics.rows &&
|
||||
current.altScreen === metrics.altScreen
|
||||
) {
|
||||
@@ -4196,7 +4200,10 @@ export default function SessionScreen() {
|
||||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false,
|
||||
navigation: 'caller'
|
||||
navigation: 'caller',
|
||||
// Why: this only ever runs for the tab the user is looking at, so it is the
|
||||
// tail of their tap — the gesture that materializes a parked pane.
|
||||
intent: 'user'
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
@@ -4271,24 +4278,11 @@ export default function SessionScreen() {
|
||||
? Math.max(0, keyboardHeight - insets.bottom)
|
||||
: keyboardHeight
|
||||
: 0
|
||||
const activeTerminalKeyboardLift = (() => {
|
||||
if (keyboardLift <= 0 || !activeHandle) {
|
||||
return 0
|
||||
}
|
||||
const metrics = terminalKeyboardMetrics.get(activeHandle)
|
||||
if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) {
|
||||
return keyboardLift
|
||||
}
|
||||
if (metrics.altScreen) {
|
||||
return keyboardLift
|
||||
}
|
||||
const rowHeight = terminalFrameHeightRef.current / metrics.rows
|
||||
const cursorBottom = (metrics.cursorY + 1) * rowHeight
|
||||
const dockTop = terminalFrameHeightRef.current - keyboardLift
|
||||
const margin = rowHeight
|
||||
// Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
|
||||
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
|
||||
})()
|
||||
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
|
||||
keyboardLift,
|
||||
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
|
||||
terminalFrameHeight: terminalFrameHeightRef.current
|
||||
})
|
||||
const toastAnimatedStyle = {
|
||||
opacity: toastOpacityRef.current,
|
||||
transform: [{ translateY: -keyboardLift }]
|
||||
@@ -4752,6 +4746,7 @@ export default function SessionScreen() {
|
||||
))}
|
||||
<MobileNativeChatOverlay
|
||||
controller={nativeChatController}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
images={nativeChatImages}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
@@ -4991,6 +4986,7 @@ export default function SessionScreen() {
|
||||
<MobileTerminalLiveInputStatus
|
||||
dictation={dictation}
|
||||
isAttaching={isAttaching}
|
||||
liveInputText={liveInputCapture}
|
||||
/>
|
||||
</Pressable>
|
||||
<MobileTerminalInputActions
|
||||
|
||||
+211
-206
@@ -15,31 +15,25 @@ import {
|
||||
UsageBar
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
|
||||
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
|
||||
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
|
||||
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
|
||||
import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client'
|
||||
import {
|
||||
markHomeWorktreeCatalogUnavailable,
|
||||
type HomeWorktreeSummary,
|
||||
type HostWorktreeInfo
|
||||
} from '../src/worktree/home-worktree-info'
|
||||
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
|
||||
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
|
||||
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
|
||||
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
|
||||
import {
|
||||
useAllHostClients,
|
||||
useCloseHost,
|
||||
useForceReconnect,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import {
|
||||
loadMobileOnboardingSteps,
|
||||
mobileOnboardingDestination
|
||||
} from '../src/onboarding/mobile-onboarding-plan'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { MobileHostCard } from '../src/components/MobileHostCard'
|
||||
@@ -47,7 +41,15 @@ import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
||||
import { ActionSheetModal } from '../src/components/ActionSheetModal'
|
||||
import { getHostListActionSheetActions } from '../src/host-list-action-sheet-actions'
|
||||
import { ConfirmModal } from '../src/components/ConfirmModal'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
|
||||
import {
|
||||
setCachedWorktrees,
|
||||
getCachedWorktrees,
|
||||
getProvenCachedWorktrees
|
||||
} from '../src/cache/worktree-cache'
|
||||
import {
|
||||
LAST_VISITED_WORKTREE_STORAGE_KEY,
|
||||
readLastVisitedWorktreeRecord
|
||||
} from '../src/worktree/last-visited-worktree-repo'
|
||||
import { loadHomeSnapshot, saveHomeSnapshot } from '../src/cache/home-snapshot-cache'
|
||||
import { colors, spacing, radii } from '../src/theme/mobile-theme'
|
||||
import {
|
||||
@@ -57,7 +59,14 @@ import {
|
||||
} from '../src/tasks/mobile-task-providers'
|
||||
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
|
||||
import { useResponsiveLayout } from '../src/layout/responsive-layout'
|
||||
import { createMobileSessionHref } from '../src/session/mobile-session-route'
|
||||
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
|
||||
import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts'
|
||||
import {
|
||||
isResumeTargetConfirmedMissing,
|
||||
selectHomeResumeCard,
|
||||
type HomeResumeCard
|
||||
} from '../src/worktree/home-resume-card'
|
||||
import { hostRouteWithNotice } from '../src/host-route-notice'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
@@ -68,13 +77,6 @@ function endpointLabel(endpoint: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
type StatsSummary = {
|
||||
totalAgentsSpawned: number
|
||||
totalPRsCreated: number
|
||||
totalAgentTimeMs: number
|
||||
firstEventAt: number | null
|
||||
}
|
||||
|
||||
type HomeTaskSettings = {
|
||||
visibleTaskProviders?: unknown
|
||||
}
|
||||
@@ -123,7 +125,9 @@ function clientKey(client: RpcClient): number {
|
||||
function fetchStats(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setStats: (s: StatsSummary) => void,
|
||||
setStats: (
|
||||
updater: (prev: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
sendSingleFlightRequest(client, hostId, 'stats.summary')
|
||||
@@ -132,61 +136,13 @@ function fetchStats(
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
setStats(response.result as StatsSummary)
|
||||
// Keyed by host: the header totals every desktop instead of showing whoever replied last.
|
||||
setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function fetchWorktreeInfo(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
setInfo: (
|
||||
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
|
||||
) => void,
|
||||
disposed: () => boolean
|
||||
) {
|
||||
const markUnavailable = () => {
|
||||
setInfo((prev) => {
|
||||
const current = prev[hostId]
|
||||
const next = markHomeWorktreeCatalogUnavailable(current, hostId)
|
||||
return next === current ? prev : { ...prev, [hostId]: next }
|
||||
})
|
||||
}
|
||||
|
||||
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
|
||||
.then((response) => {
|
||||
if (disposed()) {
|
||||
return
|
||||
}
|
||||
if (response.ok) {
|
||||
const result = response.result as { worktrees: HomeWorktreeSummary[] }
|
||||
const worktrees = result.worktrees ?? []
|
||||
setCachedWorktrees(hostId, worktrees)
|
||||
const activeStatuses = new Set(['working', 'active', 'permission'])
|
||||
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
|
||||
// Mirror the desktop's focused workspace (see pickResumeWorktree).
|
||||
const lastActive = pickResumeWorktree(worktrees)
|
||||
setInfo((prev) => ({
|
||||
...prev,
|
||||
[hostId]: {
|
||||
hostId,
|
||||
totalWorktrees: worktrees.length,
|
||||
activeCount: active.length,
|
||||
lastActiveWorktree: lastActive
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
markUnavailable()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed()) {
|
||||
markUnavailable()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function fetchAccountsSnapshot(
|
||||
client: RpcClient,
|
||||
hostId: string,
|
||||
@@ -262,17 +218,20 @@ function repoColor(name: string): string {
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const openMobileHostEdit = useOpenMobileHostEdit()
|
||||
const openMobileTasks = useOpenMobileTasks()
|
||||
const openMobileSession = useOpenMobileSession()
|
||||
const openMobileAccounts = useOpenMobileAccounts()
|
||||
const insets = useSafeAreaInsets()
|
||||
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
|
||||
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
|
||||
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
|
||||
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
|
||||
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
|
||||
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
|
||||
const [stats, setStats] = useState<StatsSummary | null>(null)
|
||||
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
|
||||
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
|
||||
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
|
||||
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
|
||||
@@ -284,7 +243,10 @@ export default function HomeScreen() {
|
||||
const onboardingOptInCheckedRef = useRef(false)
|
||||
|
||||
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
|
||||
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
|
||||
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const hostPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
@@ -348,12 +310,12 @@ export default function HomeScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then(async (h) => {
|
||||
void loadHostCatalog().then(async (catalog) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHosts(h)
|
||||
if (h.length === 0 || onboardingOptInCheckedRef.current) {
|
||||
setHostCatalog(catalog)
|
||||
if (catalog.length === 0 || onboardingOptInCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
onboardingOptInCheckedRef.current = true
|
||||
@@ -365,18 +327,18 @@ export default function HomeScreen() {
|
||||
router.replace(mobileOnboardingDestination(onboardingSteps))
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
if (stale || !raw) {
|
||||
void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLastVisited(JSON.parse(raw))
|
||||
} catch {}
|
||||
// Why the validating reader: this record becomes the Resume card's navigation target,
|
||||
// so a malformed or older-shaped payload must read as no history, not a broken route.
|
||||
setLastVisited(readLastVisitedWorktreeRecord(raw))
|
||||
})
|
||||
for (const entry of allClientsRef.current) {
|
||||
if (entry.client.getState() === 'connected') {
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => stale)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
|
||||
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
|
||||
}
|
||||
@@ -391,6 +353,10 @@ export default function HomeScreen() {
|
||||
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
|
||||
[hosts]
|
||||
)
|
||||
const sortedHostCatalog = useMemo(
|
||||
() => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected),
|
||||
[hostCatalog]
|
||||
)
|
||||
|
||||
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
|
||||
useEffect(() => {
|
||||
@@ -429,17 +395,24 @@ export default function HomeScreen() {
|
||||
}
|
||||
}
|
||||
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
|
||||
for (const host of hosts) {
|
||||
for (const host of hostCatalog) {
|
||||
if (liveIds.has(host.id)) {
|
||||
continue
|
||||
}
|
||||
if (!host.publicKeyB64 || !host.deviceToken) {
|
||||
if (host.credentialStatus === 'missing') {
|
||||
if (next[host.id] !== 'auth-failed') {
|
||||
next[host.id] = 'auth-failed'
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (host.credentialStatus === 'temporarily-unavailable') {
|
||||
if (next[host.id] !== 'disconnected') {
|
||||
next[host.id] = 'disconnected'
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
const prevState = next[host.id]
|
||||
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
|
||||
next[host.id] = 'disconnected'
|
||||
@@ -448,69 +421,79 @@ export default function HomeScreen() {
|
||||
}
|
||||
// Drop entries for hosts we no longer track at all.
|
||||
for (const id of Object.keys(next)) {
|
||||
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
|
||||
if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) {
|
||||
delete next[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [allClients, hosts])
|
||||
}, [allClients, hostCatalog])
|
||||
|
||||
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
|
||||
useEffect(() => {
|
||||
const cleanups: Array<() => void> = []
|
||||
for (const entry of allClients) {
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let unsubAccounts: (() => void) | null = null
|
||||
let statsFetched = false
|
||||
const wireUp = (state: ConnectionState) => {
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
|
||||
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
|
||||
// returned disposer owns every handle allocated here.
|
||||
const wireHostSubscriptions = (entry: {
|
||||
hostId: string
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
}) => {
|
||||
let unsubNotif: (() => void) | null = null
|
||||
let unsubAccounts: (() => void) | null = null
|
||||
const refetchGate = createHostConnectRefetchGate()
|
||||
const wireUp = (state: ConnectionState) => {
|
||||
const reconnected = refetchGate.observe(state)
|
||||
if (state === 'connected') {
|
||||
if (!unsubNotif) {
|
||||
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
|
||||
}
|
||||
if (!unsubAccounts) {
|
||||
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(evt.snapshot)
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot; malformed remote data must
|
||||
// not enter render state or crash the home host cards.
|
||||
}
|
||||
const evt = payload as { type?: string; snapshot?: unknown }
|
||||
if (evt.type === 'ready' || evt.type === 'snapshot') {
|
||||
try {
|
||||
const snapshot = decodeAccountsSnapshot(evt.snapshot)
|
||||
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
|
||||
} catch {
|
||||
// Keep the last proven snapshot; malformed remote data must
|
||||
// not enter render state or crash the home host cards.
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if (!statsFetched) {
|
||||
statsFetched = true
|
||||
fetchStats(entry.client, entry.hostId, setStats, () => false)
|
||||
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
} else {
|
||||
if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
if (unsubAccounts) {
|
||||
unsubAccounts()
|
||||
unsubAccounts = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
|
||||
// snapshot on every reconnect — a one-shot latch left the card on stale data forever.
|
||||
if (reconnected) {
|
||||
fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
|
||||
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
|
||||
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
|
||||
}
|
||||
} else {
|
||||
if (unsubNotif) {
|
||||
unsubNotif()
|
||||
unsubNotif = null
|
||||
}
|
||||
if (unsubAccounts) {
|
||||
unsubAccounts()
|
||||
unsubAccounts = null
|
||||
}
|
||||
}
|
||||
wireUp(entry.state)
|
||||
const unsubState = entry.client.onStateChange(wireUp)
|
||||
cleanups.push(() => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
unsubAccounts?.()
|
||||
})
|
||||
}
|
||||
wireUp(entry.state)
|
||||
const unsubState = entry.client.onStateChange(wireUp)
|
||||
return () => {
|
||||
unsubState()
|
||||
unsubNotif?.()
|
||||
unsubAccounts?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
|
||||
useEffect(() => {
|
||||
const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
|
||||
return () => {
|
||||
for (const c of cleanups) {
|
||||
c()
|
||||
@@ -524,28 +507,43 @@ export default function HomeScreen() {
|
||||
.join(',')
|
||||
])
|
||||
|
||||
// Why: prefer the worktree last opened on this device so Resume reflects mobile session history.
|
||||
// Why: don't gate on 'connected' so the card doesn't flash empty for ~1s on cold-start; cached data holds until fresh RPC lands.
|
||||
const resumeWorktree = useMemo(() => {
|
||||
// Why: only surface Resume for connected hosts; a stale worktree taps into a route that can't load.
|
||||
if (lastVisited && hostStates[lastVisited.hostId] === 'connected') {
|
||||
const cached = getCachedWorktrees(lastVisited.hostId) as HomeWorktreeSummary[] | null
|
||||
const match = cached?.find((w) => w.worktreeId === lastVisited.worktreeId)
|
||||
if (match) {
|
||||
return { hostId: lastVisited.hostId, worktree: match }
|
||||
// Why: the card renders from cached/snapshot data the moment a candidate exists — see
|
||||
// selectHomeResumeCard for why its slot must not wait for the host to connect.
|
||||
const resumeCard = useMemo(
|
||||
() =>
|
||||
selectHomeResumeCard({
|
||||
hosts: sortedHosts,
|
||||
hostStates,
|
||||
worktreeInfo,
|
||||
lastVisited,
|
||||
cachedWorktrees: (hostId) => getCachedWorktrees(hostId) as HomeWorktreeSummary[] | null
|
||||
}),
|
||||
[sortedHosts, hostStates, worktreeInfo, lastVisited]
|
||||
)
|
||||
|
||||
// Why: the card is drawn from a snapshot that can name a workspace the desktop has since
|
||||
// deleted. When the host has proven otherwise, open its workspace list rather than a session
|
||||
// screen whose every RPC would fail. An unproven catalog is not evidence — that tap goes
|
||||
// through and the session screen bounces once the host answers (F7).
|
||||
const openResume = useCallback(
|
||||
(card: HomeResumeCard) => {
|
||||
if (
|
||||
isResumeTargetConfirmedMissing(
|
||||
card,
|
||||
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
|
||||
)
|
||||
) {
|
||||
router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
|
||||
return
|
||||
}
|
||||
}
|
||||
for (const host of sortedHosts) {
|
||||
if (hostStates[host.id] !== 'connected') {
|
||||
continue
|
||||
}
|
||||
const info = worktreeInfo[host.id]
|
||||
if (info?.lastActiveWorktree) {
|
||||
return { hostId: host.id, worktree: info.lastActiveWorktree }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}, [sortedHosts, hostStates, worktreeInfo, lastVisited])
|
||||
openMobileSession({
|
||||
hostId: card.hostId,
|
||||
worktreeId: card.worktree.worktreeId,
|
||||
name: card.worktree.displayName || card.worktree.repo
|
||||
})
|
||||
},
|
||||
[openMobileSession, router]
|
||||
)
|
||||
|
||||
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
|
||||
const accountsHosts = useMemo(() => {
|
||||
@@ -587,7 +585,7 @@ export default function HomeScreen() {
|
||||
disabled={!primaryConnectedHost}
|
||||
style={({ pressed }) => [
|
||||
styles.taskHomeCard,
|
||||
!primaryConnectedHost && styles.quickActionDisabled,
|
||||
!primaryConnectedHost && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
@@ -644,7 +642,7 @@ export default function HomeScreen() {
|
||||
try {
|
||||
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
|
||||
setConfirmRemove(null)
|
||||
setHosts(await loadHosts())
|
||||
setHostCatalog(await loadHostCatalog())
|
||||
} catch {
|
||||
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
|
||||
setConfirmRemove(hostToRemove)
|
||||
@@ -670,7 +668,7 @@ export default function HomeScreen() {
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
{hostCatalog.length === 0 ? (
|
||||
/* ─── Empty state: onboarding ─── */
|
||||
<View
|
||||
style={[
|
||||
@@ -709,7 +707,7 @@ export default function HomeScreen() {
|
||||
) : (
|
||||
/* ─── Populated state ─── */
|
||||
<FlatList
|
||||
data={sortedHosts}
|
||||
data={sortedHostCatalog}
|
||||
keyExtractor={(h) => h.id}
|
||||
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
|
||||
contentContainerStyle={[
|
||||
@@ -750,7 +748,6 @@ export default function HomeScreen() {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const info = worktreeInfo[item.id]
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
reconnectAttempts: attempts,
|
||||
@@ -760,74 +757,82 @@ export default function HomeScreen() {
|
||||
return (
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
credentialStatus={item.credentialStatus}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeCounts={
|
||||
info && !info.catalogUnavailable
|
||||
? { total: info.totalWorktrees, active: info.activeCount }
|
||||
: undefined
|
||||
}
|
||||
worktreeCountsUnavailable={info?.catalogUnavailable === true}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
worktreeInfo={worktreeInfo[item.id]}
|
||||
onPress={() => {
|
||||
if (item.credentialStatus === 'missing') {
|
||||
router.push('/pair-scan')
|
||||
} else if (item.credentialStatus === 'temporarily-unavailable') {
|
||||
void loadHostCatalog()
|
||||
.then(setHostCatalog)
|
||||
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
|
||||
} else {
|
||||
router.push(`/h/${item.id}`)
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setActionTarget(item)
|
||||
if (item.profile) {
|
||||
setActionTarget(item.profile)
|
||||
} else {
|
||||
setConfirmRemove(item)
|
||||
}
|
||||
}}
|
||||
onOpenActions={() => {
|
||||
if (item.profile) {
|
||||
setActionTarget(item.profile)
|
||||
} else {
|
||||
setConfirmRemove(item)
|
||||
}
|
||||
}}
|
||||
onOpenActions={() => setActionTarget(item)}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
ListFooterComponent={
|
||||
<View>
|
||||
{/* ─── Resume card ─── */}
|
||||
{resumeWorktree ? (
|
||||
{resumeCard ? (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
createMobileSessionHref({
|
||||
hostId: resumeWorktree.hostId,
|
||||
worktreeId: resumeWorktree.worktree.worktreeId,
|
||||
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
|
||||
})
|
||||
)
|
||||
}
|
||||
disabled={!resumeCard.actionable}
|
||||
style={({ pressed }) => [
|
||||
styles.resumeCard,
|
||||
!resumeCard.actionable && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => openResume(resumeCard)}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
<Terminal size={18} color={colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.resumeMain}>
|
||||
<Text style={styles.resumeTitle} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.displayName}
|
||||
{resumeCard.worktree.displayName}
|
||||
</Text>
|
||||
<View style={styles.resumeSub}>
|
||||
<View
|
||||
style={[
|
||||
styles.repoDot,
|
||||
{ backgroundColor: repoColor(resumeWorktree.worktree.repo) }
|
||||
{ backgroundColor: repoColor(resumeCard.worktree.repo) }
|
||||
]}
|
||||
/>
|
||||
<Text style={styles.resumeSubText} numberOfLines={1}>
|
||||
{resumeWorktree.worktree.repo}
|
||||
{resumeCard.worktree.repo}
|
||||
{' · '}
|
||||
{resumeWorktree.worktree.branch}
|
||||
{resumeCard.worktree.branch}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
|
||||
{renderTaskHomeCard()}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
|
||||
{renderTaskHomeCard()}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Tasks</Text>
|
||||
{renderTaskHomeCard()}
|
||||
|
||||
{/* ─── Quick actions ─── */}
|
||||
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
|
||||
@@ -845,7 +850,7 @@ export default function HomeScreen() {
|
||||
disabled={!primaryConnectedHost}
|
||||
style={({ pressed }) => [
|
||||
styles.quickAction,
|
||||
!primaryConnectedHost && styles.quickActionDisabled,
|
||||
!primaryConnectedHost && styles.cardDisabled,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
@@ -882,7 +887,7 @@ export default function HomeScreen() {
|
||||
styles.accountsCard,
|
||||
pressed && styles.hostCardPressed
|
||||
]}
|
||||
onPress={() => router.push(`/h/${host.id}/accounts`)}
|
||||
onPress={() => openMobileAccounts(host.id)}
|
||||
>
|
||||
{showHostName ? (
|
||||
<Text style={styles.accountsHostLabel} numberOfLines={1}>
|
||||
@@ -957,8 +962,8 @@ export default function HomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: closeHostClient,
|
||||
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
|
||||
onRemove: setConfirmRemove
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: (host) => setConfirmRemove(host)
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
/>
|
||||
@@ -1272,7 +1277,7 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
gap: 10
|
||||
},
|
||||
quickActionDisabled: {
|
||||
cardDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
quickActionIcon: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import { useAllHostClients } from '../src/transport/use-all-host-clients'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { BottomDrawer } from '../src/components/BottomDrawer'
|
||||
import { VoiceModelList } from '../src/components/VoiceModelList'
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "oxlint",
|
||||
@@ -48,6 +48,7 @@
|
||||
"expo-status-bar": "^55.0.6",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react-native": "^1.14.0",
|
||||
"mermaid": "11.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "19.2.6",
|
||||
"react-native": "^0.83.9",
|
||||
|
||||
Generated
+792
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const scriptDir = import.meta.dirname
|
||||
const mobileRoot = path.resolve(scriptDir, '..')
|
||||
const outputPath = path.join(
|
||||
mobileRoot,
|
||||
'src',
|
||||
'components',
|
||||
'pr-sidebar',
|
||||
'mermaid-webview-engine.generated.ts'
|
||||
)
|
||||
|
||||
// Why: unlike the terminal engine (esbuilt from source for old-WebView shims),
|
||||
// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact
|
||||
// the diagram WebView previously fetched from a CDN, now pinned and
|
||||
// integrity-checked through the lockfile and available offline.
|
||||
async function main() {
|
||||
const packageJsonPath = require.resolve('mermaid/package.json')
|
||||
const [packageJson, bundleJs] = await Promise.all([
|
||||
readFile(packageJsonPath, 'utf8').then(JSON.parse),
|
||||
readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8')
|
||||
])
|
||||
|
||||
// A literal </script> inside the bundle would close the inline script tag it
|
||||
// is embedded in; \/ is identical to / in JS strings, regexes, and comments.
|
||||
const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script')
|
||||
|
||||
const source = [
|
||||
'// Generated by scripts/build-mermaid-webview-engine.mjs.',
|
||||
`// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`,
|
||||
'// Do not edit by hand; regenerate via pnpm postinstall.',
|
||||
`export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
await writeFile(outputPath, source)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -139,7 +139,7 @@ async function chooseWorktree(ws: WebSocket): Promise<string> {
|
||||
}
|
||||
|
||||
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
|
||||
const list = await send(ws, 'terminal.list', { worktree })
|
||||
const list = await send(ws, 'terminal.list', { worktree, includeVisualLayouts: false })
|
||||
if (!list.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mobileAccountsRouteTarget } from './mobile-accounts-route'
|
||||
import {
|
||||
hostStackHostRoute,
|
||||
navigateToHostStackRoute,
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
let state = initialState
|
||||
const navigation = {
|
||||
addListener: vi.fn((_event: 'state', listener: () => void) => {
|
||||
stateListeners.add(listener)
|
||||
return () => stateListeners.delete(listener)
|
||||
}),
|
||||
dispatch: vi.fn(),
|
||||
getState: () => state
|
||||
}
|
||||
return {
|
||||
navigation,
|
||||
setState(nextState: HostStackNavigationState) {
|
||||
state = nextState
|
||||
for (const listener of stateListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile accounts route', () => {
|
||||
it('keeps the host id raw for the navigator to encode', () => {
|
||||
expect(mobileAccountsRouteTarget('host/one')).toEqual({
|
||||
name: '[hostId]/accounts',
|
||||
params: { hostId: 'host/one' }
|
||||
})
|
||||
})
|
||||
|
||||
it('mounts the host before replacing it with the accounts route', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push, replace: vi.fn() },
|
||||
'host/one',
|
||||
mobileAccountsRouteTarget('host/one')
|
||||
)
|
||||
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
harness.setState({
|
||||
index: 1,
|
||||
routes: [
|
||||
{ name: 'index' },
|
||||
{
|
||||
name: 'h',
|
||||
state: {
|
||||
key: '/h',
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
key: 'host-index',
|
||||
name: '[hostId]/index',
|
||||
params: { hostId: encodeURIComponent('host/one') }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
|
||||
type: 'REPLACE',
|
||||
target: '/h',
|
||||
source: 'host-index',
|
||||
payload: mobileAccountsRouteTarget('host/one')
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
|
||||
|
||||
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
|
||||
// missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
|
||||
const accountsSection = homeSource.slice(start)
|
||||
expect(accountsSection).toContain('openMobileAccounts(host.id)')
|
||||
expect(accountsSection).not.toContain('/accounts`')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
|
||||
|
||||
/** Host id stays raw — the navigator owns the params, so pre-encoding one would
|
||||
* reach the accounts screen still escaped. */
|
||||
export function mobileAccountsRouteTarget(hostId: string): HostStackRouteTarget {
|
||||
return {
|
||||
name: '[hostId]/accounts',
|
||||
params: { hostId }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
|
||||
import { mobileAccountsRouteTarget } from './mobile-accounts-route'
|
||||
|
||||
export function useOpenMobileAccounts(): (hostId: string) => void {
|
||||
const openHostStackRoute = useOpenHostStackRoute()
|
||||
|
||||
return useCallback(
|
||||
(hostId) => {
|
||||
openHostStackRoute(hostId, mobileAccountsRouteTarget(hostId))
|
||||
},
|
||||
[openHostStackRoute]
|
||||
)
|
||||
}
|
||||
+3
-16
@@ -5,25 +5,12 @@
|
||||
// WebSocket reconnects and the first responses come back.
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { AccountsSnapshot } from '../components/AccountUsage'
|
||||
// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card
|
||||
// needs it to know whether a rehydrated count is minutes or days old.
|
||||
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
|
||||
const STORAGE_KEY = 'orca:home-snapshot:v1'
|
||||
|
||||
type WorktreeSummary = {
|
||||
worktreeId: string
|
||||
repo: string
|
||||
branch: string
|
||||
displayName: string
|
||||
liveTerminalCount: number
|
||||
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
|
||||
}
|
||||
|
||||
type HostWorktreeInfo = {
|
||||
hostId: string
|
||||
totalWorktrees: number
|
||||
activeCount: number
|
||||
lastActiveWorktree: WorktreeSummary | null
|
||||
}
|
||||
|
||||
export type HomeSnapshot = {
|
||||
worktreeInfo: Record<string, HostWorktreeInfo>
|
||||
accountsByHost: Record<string, AccountsSnapshot>
|
||||
|
||||
+49
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
|
||||
import { setCachedWorktrees, getCachedWorktrees, getProvenCachedWorktrees } 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
|
||||
@@ -41,3 +41,51 @@ describe('worktree-cache write-through', () => {
|
||||
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
|
||||
})
|
||||
})
|
||||
|
||||
// Why (F7): home seeds this cache from a persisted cold-start snapshot as well as from a live
|
||||
// worktree.ps, and only the latter can prove a workspace *absent* — the Resume tap redirects
|
||||
// off that distinction, so a seeded entry must never look authoritative.
|
||||
describe('worktree-cache provenance', () => {
|
||||
it('withholds unmarked writes from the proven reader', () => {
|
||||
const hostId = 'host-seeded'
|
||||
const seeded = [{ worktreeId: 'a' }]
|
||||
|
||||
setCachedWorktrees(hostId, seeded)
|
||||
|
||||
expect(getCachedWorktrees(hostId)).toEqual(seeded)
|
||||
expect(getProvenCachedWorktrees(hostId)).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes a host-listed catalog to the proven reader', () => {
|
||||
const hostId = 'host-proven'
|
||||
const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }]
|
||||
|
||||
setCachedWorktrees(hostId, listed, { proven: true })
|
||||
|
||||
expect(getProvenCachedWorktrees(hostId)).toEqual(listed)
|
||||
})
|
||||
|
||||
it('keeps a fresh proven catalog when an unproven seed lands after it', () => {
|
||||
const hostId = 'host-kept'
|
||||
const listed = [{ worktreeId: 'a' }, { worktreeId: 'b' }]
|
||||
setCachedWorktrees(hostId, listed, { proven: true })
|
||||
|
||||
// A cold-start snapshot seed must neither truncate nor de-prove the host-listed rows.
|
||||
setCachedWorktrees(hostId, [{ worktreeId: 'a' }])
|
||||
|
||||
expect(getProvenCachedWorktrees(hostId)).toEqual(listed)
|
||||
})
|
||||
|
||||
it('lets an unproven seed replace another unproven entry', () => {
|
||||
const hostId = 'host-reseeded'
|
||||
setCachedWorktrees(hostId, [{ worktreeId: 'a' }])
|
||||
setCachedWorktrees(hostId, [{ worktreeId: 'b' }])
|
||||
|
||||
expect(getCachedWorktrees(hostId)).toEqual([{ worktreeId: 'b' }])
|
||||
expect(getProvenCachedWorktrees(hostId)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports nothing proven for a host it has never cached', () => {
|
||||
expect(getProvenCachedWorktrees('host-never-seen')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+28
-3
@@ -5,6 +5,9 @@
|
||||
type CachedWorktrees = {
|
||||
worktrees: unknown[]
|
||||
at: number
|
||||
// Whether the host itself listed these rows this session, as opposed to a cold-start seed
|
||||
// rebuilt from a persisted snapshot. Only a proven list can prove a worktree *absent*.
|
||||
proven: boolean
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedWorktrees>()
|
||||
@@ -12,12 +15,23 @@ const cache = new Map<string, CachedWorktrees>()
|
||||
const MAX_AGE_MS = 30_000
|
||||
const MAX_ENTRIES = 20
|
||||
|
||||
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
|
||||
export function setCachedWorktrees(
|
||||
hostId: string,
|
||||
worktrees: unknown[],
|
||||
options?: { proven?: boolean }
|
||||
): void {
|
||||
// Why: a cold-start snapshot seed landing after a live worktree.ps must not erase
|
||||
// the proof — or truncate the host-listed rows — the resume check depends on.
|
||||
if (options?.proven !== true && readFreshEntry(hostId)?.proven) {
|
||||
return
|
||||
}
|
||||
// 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() })
|
||||
// Default false: a caller that has not said where the rows came from must never be taken
|
||||
// as grounds for redirecting the user away from a workspace.
|
||||
cache.set(hostId, { worktrees, at: Date.now(), proven: options?.proven === true })
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
const oldest = cache.keys().next().value
|
||||
if (oldest) {
|
||||
@@ -27,6 +41,17 @@ export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
|
||||
}
|
||||
|
||||
export function getCachedWorktrees(hostId: string): unknown[] | null {
|
||||
return readFreshEntry(hostId)?.worktrees ?? null
|
||||
}
|
||||
|
||||
/** The rows only when the host listed them itself — null whenever absence cannot be trusted,
|
||||
* which is every unproven or expired entry. */
|
||||
export function getProvenCachedWorktrees(hostId: string): unknown[] | null {
|
||||
const entry = readFreshEntry(hostId)
|
||||
return entry?.proven ? entry.worktrees : null
|
||||
}
|
||||
|
||||
function readFreshEntry(hostId: string): CachedWorktrees | null {
|
||||
const entry = cache.get(hostId)
|
||||
if (!entry) {
|
||||
return null
|
||||
@@ -35,5 +60,5 @@ export function getCachedWorktrees(hostId: string): unknown[] | null {
|
||||
cache.delete(hostId)
|
||||
return null
|
||||
}
|
||||
return entry.worktrees
|
||||
return entry
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, useEffect } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -14,7 +14,10 @@ vi.mock('react-native', () => ({
|
||||
Linking: { openURL: nativeTestState.openUrl },
|
||||
Platform: nativeTestState.platform,
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T>(styles: T) => styles },
|
||||
StyleSheet: {
|
||||
create: <T>(styles: T) => styles,
|
||||
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
@@ -41,11 +44,20 @@ function GateConsumer() {
|
||||
return createElement('GateStatus', null, hostCapabilities.join(','))
|
||||
}
|
||||
|
||||
// Counts mounts so a test can prove the routes were never torn down, which presence alone can't.
|
||||
const probeMounts = { count: 0 }
|
||||
function MountProbe() {
|
||||
useEffect(() => {
|
||||
probeMounts.count += 1
|
||||
}, [])
|
||||
return createElement('MountProbe')
|
||||
}
|
||||
|
||||
function gateElement() {
|
||||
return createElement(
|
||||
HostProtocolGate,
|
||||
{ hostId: 'host-1' },
|
||||
createElement('HostContent', null, createElement(GateConsumer))
|
||||
createElement('HostContent', null, createElement(GateConsumer), createElement(MountProbe))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -69,6 +81,7 @@ describe('HostProtocolGate', () => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
nativeTestState.openUrl.mockClear()
|
||||
nativeTestState.platform.OS = 'ios'
|
||||
probeMounts.count = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -157,9 +170,56 @@ describe('HostProtocolGate', () => {
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Checking host compatibility')
|
||||
expect(output).not.toContain('HostContent')
|
||||
expect(probeMounts.count).toBe(0)
|
||||
expect(client.sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('overlays the pending spinner instead of unmounting routes mounted while connecting', async () => {
|
||||
hostClient.current = { client: null, state: 'connecting' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
expect(probeMounts.count).toBe(1)
|
||||
|
||||
const client = {
|
||||
sendRequest: vi.fn().mockReturnValue(new Promise(() => {}))
|
||||
} as unknown as RpcClient
|
||||
await act(async () => {
|
||||
hostClient.current = { client, state: 'connected' }
|
||||
renderer?.update(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
expect(output).toContain('Checking host compatibility')
|
||||
// Why: the cold-start remount this replaces is exactly what destroys in-flight deep navigation.
|
||||
expect(probeMounts.count).toBe(1)
|
||||
const overlay = renderer.root
|
||||
.findAllByType('View')
|
||||
.find((node) => node.props.accessibilityViewIsModal === true)
|
||||
expect(overlay?.props.pointerEvents).toBe('auto')
|
||||
})
|
||||
|
||||
it('still replaces mounted routes when the verdict comes back blocked', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
hostClient.current = { client: null, state: 'connecting' }
|
||||
renderer = await renderGate()
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
|
||||
await act(async () => {
|
||||
hostClient.current = {
|
||||
client: clientWithStatus({ protocolVersion: 5, minCompatibleMobileVersion: 999 }),
|
||||
state: 'connected'
|
||||
}
|
||||
renderer?.update(gateElement())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('Update Orca Mobile')
|
||||
expect(output).not.toContain('HostContent')
|
||||
})
|
||||
|
||||
it('keeps an already-validated host route mounted while reconnect status is pending', async () => {
|
||||
const client = {
|
||||
sendRequest: vi
|
||||
@@ -183,7 +243,10 @@ describe('HostProtocolGate', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(renderedText(renderer)).toContain('HostContent')
|
||||
const output = renderedText(renderer)
|
||||
expect(output).toContain('HostContent')
|
||||
// Why: the host already answered once, so a reconnect probe must not dim the UI it validated.
|
||||
expect(output).not.toContain('Checking host compatibility')
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useRef, type ReactNode } from 'react'
|
||||
import { createContext, useContext, useEffect, 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'
|
||||
@@ -27,12 +27,31 @@ export function HostProtocolGate({ hostId, children }: Props) {
|
||||
const gates = useHostStatusGates({ hostId, client, connState: state })
|
||||
const { compatVerdict, statusPending } = gates
|
||||
const resolvedHostIdRef = useRef<string | null>(null)
|
||||
const mountedHostIdRef = 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.
|
||||
const resolvedNow = state === 'connected' && client !== null && !statusPending
|
||||
const blocked = compatVerdict.kind === 'blocked'
|
||||
const pending = statusPending && resolvedHostIdRef.current !== hostKey
|
||||
const holdBack = pending && mountedHostIdRef.current !== hostKey
|
||||
|
||||
// Why: React can replay or discard a render, so the latches record committed
|
||||
// outcomes only — a discarded children render must not count as mounted.
|
||||
useEffect(() => {
|
||||
if (resolvedNow) {
|
||||
resolvedHostIdRef.current = hostKey
|
||||
}
|
||||
if (blocked) {
|
||||
// Why: the block screen unmounts the routes, so a later pending window
|
||||
// must not assume a live tree it can overlay.
|
||||
mountedHostIdRef.current = null
|
||||
} else if (!holdBack) {
|
||||
mountedHostIdRef.current = hostKey
|
||||
}
|
||||
})
|
||||
|
||||
if (holdBack) {
|
||||
// Why: nothing is mounted yet for this host, so hold the routes back entirely
|
||||
// rather than letting them mount (and fire their connect RPCs) pre-verdict.
|
||||
return (
|
||||
<View style={styles.pending}>
|
||||
<ActivityIndicator
|
||||
@@ -42,11 +61,42 @@ export function HostProtocolGate({ hostId, children }: Props) {
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (compatVerdict.kind === 'blocked') {
|
||||
if (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>
|
||||
return (
|
||||
<HostStatusGatesContext.Provider value={gates}>
|
||||
<View style={styles.host}>
|
||||
<View
|
||||
style={styles.host}
|
||||
// Why: the overlay blocks in-tree touches but TalkBack can still walk the covered
|
||||
// stack; hide it while pending (Android counterpart of the overlay's iOS modal flag).
|
||||
importantForAccessibility={pending ? 'no-hide-descendants' : 'auto'}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
{pending ? (
|
||||
// Why: once the stack is mounted, unmounting it for a pending status.get destroys
|
||||
// in-flight nested navigation, so cover it instead. Mount effects underneath still
|
||||
// run — they wait for connState 'connected' and every capability-dependent call
|
||||
// re-probes status.get itself, so nothing newer than the baseline fires here.
|
||||
<View
|
||||
style={styles.pendingOverlay}
|
||||
// Why: the fill owns the hit test for in-tree views only — native-Modal-hosted
|
||||
// surfaces (bottom drawers) present in a separate window above it and stay live.
|
||||
pointerEvents="auto"
|
||||
accessibilityViewIsModal
|
||||
>
|
||||
<ActivityIndicator
|
||||
color={colors.textSecondary}
|
||||
accessibilityLabel="Checking host compatibility"
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</HostStatusGatesContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -55,5 +105,17 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
// Stays mounted across the overlay toggling so the routes below keep their identity.
|
||||
host: {
|
||||
flex: 1
|
||||
},
|
||||
pendingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase,
|
||||
zIndex: 1000,
|
||||
elevation: 1000
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { X } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
|
||||
// Informational, not an error: the host is healthy and the user's target simply went away,
|
||||
// so this stays monochrome rather than borrowing the auth-failed red.
|
||||
export function HostRouteNoticeBanner({
|
||||
message,
|
||||
onDismiss
|
||||
}: {
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.text}>{message}</Text>
|
||||
<Pressable
|
||||
onPress={onDismiss}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss notice"
|
||||
hitSlop={spacing.sm}
|
||||
style={styles.dismiss}
|
||||
>
|
||||
<X size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
backgroundColor: colors.bgPanel,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
text: { flex: 1, color: colors.textSecondary, fontSize: 13 },
|
||||
dismiss: { padding: spacing.xs }
|
||||
})
|
||||
@@ -104,7 +104,13 @@ describe('MobileHostCard', () => {
|
||||
state: 'connected',
|
||||
verdict: { kind: 'normal', label: 'Connected' },
|
||||
path: 'tailscale',
|
||||
worktreeCounts: { total: 3, active: 2 },
|
||||
worktreeInfo: {
|
||||
hostId: 'desk',
|
||||
totalWorktrees: 3,
|
||||
activeCount: 2,
|
||||
lastActiveWorktree: null,
|
||||
countsProvenAt: Date.now()
|
||||
},
|
||||
onPress: vi.fn(),
|
||||
onLongPress: vi.fn(),
|
||||
onOpenActions: vi.fn()
|
||||
@@ -135,7 +141,13 @@ describe('MobileHostCard', () => {
|
||||
state: 'connected',
|
||||
verdict: { kind: 'normal', label: 'Connected' },
|
||||
path: 'relay',
|
||||
worktreeCountsUnavailable: true,
|
||||
worktreeInfo: {
|
||||
hostId: 'desk',
|
||||
totalWorktrees: 0,
|
||||
activeCount: 0,
|
||||
lastActiveWorktree: null,
|
||||
catalogUnavailable: true
|
||||
},
|
||||
onPress: vi.fn(),
|
||||
onLongPress: vi.fn(),
|
||||
onOpenActions: vi.fn()
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
|
||||
import {
|
||||
markHomeWorktreeCatalogUnavailable,
|
||||
type HostWorktreeInfo
|
||||
} from '../worktree/home-worktree-info'
|
||||
import { MobileHostCard } from './MobileHostCard'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({ Monitor: 'Monitor', MoreVertical: 'MoreVertical' }))
|
||||
vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' }))
|
||||
|
||||
const host: HostProfile = {
|
||||
id: 'host-1',
|
||||
name: 'Studio',
|
||||
endpoint: 'ws://studio.local:8765',
|
||||
deviceToken: 'token',
|
||||
publicKeyB64: 'key',
|
||||
lastConnected: 0
|
||||
}
|
||||
const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' }
|
||||
const loaded: HostWorktreeInfo = {
|
||||
hostId: 'host-1',
|
||||
totalWorktrees: 12,
|
||||
activeCount: 2,
|
||||
lastActiveWorktree: null,
|
||||
countsProvenAt: Date.now()
|
||||
}
|
||||
|
||||
describe('MobileHostCard', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function renderCard(
|
||||
worktreeInfo: HostWorktreeInfo | undefined,
|
||||
overrides?: {
|
||||
state?: ConnectionState
|
||||
verdict?: ConnectionVerdict
|
||||
path?: MobileConnectionPath
|
||||
credentialStatus?: HostCredentialStatus
|
||||
}
|
||||
): Promise<string[]> {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileHostCard, {
|
||||
host,
|
||||
state: overrides?.state ?? 'connected',
|
||||
verdict: overrides?.verdict ?? verdict,
|
||||
path: overrides?.path ?? 'lan',
|
||||
credentialStatus: overrides?.credentialStatus,
|
||||
worktreeInfo,
|
||||
onPress: () => {},
|
||||
onLongPress: () => {},
|
||||
onOpenActions: () => {}
|
||||
})
|
||||
)
|
||||
})
|
||||
return renderer!.root
|
||||
.findAllByType('Text')
|
||||
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
|
||||
}
|
||||
|
||||
it('renders the counts the host proved', async () => {
|
||||
expect(await renderCard(loaded)).toContain('12 worktrees · 2 active')
|
||||
})
|
||||
|
||||
it('keeps rendering the last proven counts after a failed refresh', async () => {
|
||||
// The regression this card shipped once: the caller dropped the counts the
|
||||
// failure path deliberately preserved.
|
||||
expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain(
|
||||
'Last known: 12 worktrees · 2 active'
|
||||
)
|
||||
})
|
||||
|
||||
it('never asserts a count for a catalog that failed with nothing proven', async () => {
|
||||
expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain(
|
||||
'Worktree list unavailable'
|
||||
)
|
||||
})
|
||||
|
||||
it('names the relay while the dial is still in flight', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'connecting',
|
||||
verdict: { kind: 'normal', label: 'Connecting…' },
|
||||
path: 'relay'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Connecting…')
|
||||
expect(lines).toContain(' · Orca Relay')
|
||||
})
|
||||
|
||||
it('names the relay while a failed direct dial is still retrying', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'reconnecting',
|
||||
verdict: { kind: 'normal', label: 'Reconnecting…' },
|
||||
path: 'relay'
|
||||
})
|
||||
|
||||
expect(lines).toContain(' · Orca Relay')
|
||||
})
|
||||
|
||||
it('leaves an idle disconnected host unlabelled', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'disconnected',
|
||||
verdict: { kind: 'normal', label: 'Disconnected' },
|
||||
path: 'relay'
|
||||
})
|
||||
|
||||
expect(lines).not.toContain(' · Orca Relay')
|
||||
})
|
||||
|
||||
it('does not guess a direct path before the dial resolves', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'connecting',
|
||||
verdict: { kind: 'normal', label: 'Connecting…' },
|
||||
path: 'lan'
|
||||
})
|
||||
|
||||
expect(lines).not.toContain(' · Direct · LAN')
|
||||
})
|
||||
|
||||
it('shows no worktree line before the first read lands', async () => {
|
||||
const lines = await renderCard(undefined)
|
||||
|
||||
expect(lines).not.toContain('0 worktrees')
|
||||
expect(lines).not.toContain('Worktree list unavailable')
|
||||
})
|
||||
|
||||
it('offers re-pairing when the credential is missing', async () => {
|
||||
const lines = await renderCard(loaded, {
|
||||
state: 'connected',
|
||||
verdict: { kind: 'auth-failed', label: 'Pairing invalid' },
|
||||
credentialStatus: 'missing'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Pairing invalid')
|
||||
expect(lines).toContain('Tap to re-pair with your desktop')
|
||||
expect(lines).not.toContain('12 worktrees · 2 active')
|
||||
})
|
||||
|
||||
it('offers a retry without declaring a transient read failure invalid', async () => {
|
||||
const lines = await renderCard(undefined, {
|
||||
state: 'disconnected',
|
||||
verdict: { kind: 'normal', label: 'Disconnected' },
|
||||
credentialStatus: 'temporarily-unavailable'
|
||||
})
|
||||
|
||||
expect(lines).toContain('Pairing temporarily unavailable')
|
||||
expect(lines).toContain('Unlock your phone, then tap to retry')
|
||||
expect(lines).not.toContain('Pairing invalid')
|
||||
})
|
||||
})
|
||||
@@ -4,44 +4,65 @@ import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import { verdictDisplayLabel } from '../transport/connection-health'
|
||||
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from '../transport/types'
|
||||
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
|
||||
import { StatusDot } from './StatusDot'
|
||||
|
||||
export function MobileHostCard(props: {
|
||||
host: HostProfile
|
||||
host: HostProfile | HostCatalogEntry
|
||||
credentialStatus?: HostCatalogEntry['credentialStatus']
|
||||
state: ConnectionState
|
||||
verdict: ConnectionVerdict
|
||||
path: MobileConnectionPath
|
||||
worktreeCounts?: { total: number; active: number }
|
||||
// Why (STA-3123): the host is connected but its worktree catalog request failed,
|
||||
// so the card must say "unavailable" instead of asserting a count of zero.
|
||||
worktreeCountsUnavailable?: boolean
|
||||
// Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts
|
||||
// away (STA-3123 shipped that bug once already).
|
||||
worktreeInfo?: HostWorktreeInfo
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
onOpenActions: () => void
|
||||
}) {
|
||||
const connected = props.state === 'connected'
|
||||
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const statusLabel = verdictDisplayLabel(props.verdict)
|
||||
const connectionPathLabel = connected ? mobileConnectionPathLabel(props.path) : null
|
||||
const accessibleConnectionPathLabel = connectionPathLabel?.replace(' · ', ' via ')
|
||||
const worktreeSummary = props.worktreeCounts
|
||||
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
|
||||
: props.worktreeCountsUnavailable
|
||||
? 'Worktree list unavailable'
|
||||
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
|
||||
const credentialMissing = props.credentialStatus === 'missing'
|
||||
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
|
||||
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
|
||||
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
|
||||
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
|
||||
const dialingPath =
|
||||
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
|
||||
const isError =
|
||||
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const statusLabel = credentialMissing
|
||||
? 'Pairing invalid'
|
||||
: credentialUnavailable
|
||||
? 'Pairing temporarily unavailable'
|
||||
: verdictDisplayLabel(props.verdict)
|
||||
const statusVerdict: ConnectionVerdict = credentialMissing
|
||||
? { kind: 'auth-failed', label: statusLabel }
|
||||
: credentialUnavailable
|
||||
? { kind: 'warning', label: statusLabel }
|
||||
: props.verdict
|
||||
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
|
||||
const connectionPathLabel =
|
||||
!credentialMissing && !credentialUnavailable && (connected || dialingPath)
|
||||
? mobileConnectionPathLabel(props.path)
|
||||
: null
|
||||
const accessibleWorktreeSummary = connected ? worktreeSummary?.replace(' · ', ', ') : null
|
||||
const discoveryHint =
|
||||
props.verdict.kind === 'unreachable' && !props.host.relay
|
||||
? 'Update desktop Orca and sign in to connect from anywhere'
|
||||
: null
|
||||
const credentialHint = credentialMissing
|
||||
? 'Tap to re-pair with your desktop'
|
||||
: credentialUnavailable
|
||||
? 'Unlock your phone, then tap to retry'
|
||||
: null
|
||||
const accessibilityLabel = [
|
||||
`Open ${props.host.name}`,
|
||||
statusLabel,
|
||||
accessibleConnectionPathLabel,
|
||||
accessibleWorktreeSummary,
|
||||
discoveryHint
|
||||
connectionPathLabel?.replace(' · ', ' via '),
|
||||
connected ? worktreeSummary?.replace(' · ', ', ') : null,
|
||||
discoveryHint,
|
||||
credentialHint
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
@@ -66,9 +87,13 @@ export function MobileHostCard(props: {
|
||||
{props.host.name}
|
||||
</Text>
|
||||
<View style={styles.meta}>
|
||||
<StatusDot state={props.state} verdict={props.verdict} />
|
||||
<StatusDot state={props.state} verdict={statusVerdict} />
|
||||
<Text
|
||||
style={[styles.metaText, isError && { color: colors.statusRed }]}
|
||||
style={[
|
||||
styles.metaText,
|
||||
isError && { color: colors.statusRed },
|
||||
credentialUnavailable && { color: colors.statusAmber }
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{statusLabel}
|
||||
@@ -85,6 +110,11 @@ export function MobileHostCard(props: {
|
||||
{discoveryHint}
|
||||
</Text>
|
||||
) : null}
|
||||
{credentialHint ? (
|
||||
<Text style={styles.discoveryHint} numberOfLines={2}>
|
||||
{credentialHint}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
const openURL = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: (url: string) => openURL(url) },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
function flattenText(node: ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === 'string' ? child : flattenText(child)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function pressables(renderer: ReactTestRenderer): ReactTestInstance[] {
|
||||
return renderer.root.findAll(
|
||||
(node) => node.type === ('Text' as never) && typeof node.props.onPress === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function pressByText(renderer: ReactTestRenderer, text: string): void {
|
||||
const target = pressables(renderer).find((node) => flattenText(node) === text)
|
||||
expect(target, `no pressable text ${JSON.stringify(text)}`).toBeDefined()
|
||||
target!.props.onPress()
|
||||
}
|
||||
|
||||
describe('MobileMarkdown file links', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onOpenFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onOpenFile.mockClear()
|
||||
openURL.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(content: string): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content, onOpenFile }))
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
it('opens a tapped POSIX absolute path in prose', () => {
|
||||
pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx')
|
||||
})
|
||||
|
||||
it('opens a tapped path:line citation in prose', () => {
|
||||
pressByText(render('see src/foo.ts:42 for the fix'), 'src/foo.ts:42')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
|
||||
})
|
||||
|
||||
it('routes a relative markdown href to the file opener with its #L line', () => {
|
||||
pressByText(render('read [the plan](docs/plan.md#L7) first'), 'the plan')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/plan.md:7')
|
||||
expect(openURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a file: href to the file opener', () => {
|
||||
pressByText(render('[artifact](file:///tmp/out/result.json)'), 'artifact')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/tmp/out/result.json')
|
||||
})
|
||||
|
||||
it('keeps web links on the system browser', () => {
|
||||
pressByText(render('go to [site](https://example.com/docs)'), 'site')
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/docs')
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops unknown-scheme hrefs without opening anything', () => {
|
||||
pressByText(render('[ide](editor://file/x.ts)'), 'ide')
|
||||
expect(openURL).not.toHaveBeenCalled()
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps snake_case paths whole instead of shredding them as emphasis', () => {
|
||||
const rendered = render('compare src/foo_bar.ts and src/baz_qux.ts now')
|
||||
pressByText(rendered, 'src/foo_bar.ts')
|
||||
pressByText(rendered, 'src/baz_qux.ts')
|
||||
expect(onOpenFile).toHaveBeenNthCalledWith(1, 'src/foo_bar.ts')
|
||||
expect(onOpenFile).toHaveBeenNthCalledWith(2, 'src/baz_qux.ts')
|
||||
})
|
||||
|
||||
it('keeps markdown links between snake_case paths tappable', () => {
|
||||
pressByText(
|
||||
render('Updated src/foo_bar.py; see [the PR](https://example.com/x) before src/baz_qux.py'),
|
||||
'the PR'
|
||||
)
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/x')
|
||||
})
|
||||
|
||||
it('opens a dunder path as one link', () => {
|
||||
pressByText(render('see a/__tests__/x.ts now'), 'a/__tests__/x.ts')
|
||||
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('a/__tests__/x.ts')
|
||||
})
|
||||
|
||||
it('detects paths inside bold spans', () => {
|
||||
pressByText(render('changed **src/foo.ts** heavily'), 'src/foo.ts')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts')
|
||||
})
|
||||
|
||||
it('excludes trailing sentence punctuation from autolinks', () => {
|
||||
pressByText(render('see https://example.com/a.'), 'https://example.com/a')
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/a')
|
||||
})
|
||||
|
||||
it('opens inline-code path:line citations', () => {
|
||||
pressByText(render('fix `src/foo.ts:42` now'), 'src/foo.ts:42')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
|
||||
})
|
||||
|
||||
it('renders paths as plain text without onOpenFile', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content: 'Edit src/app/Main.tsx now' }))
|
||||
})
|
||||
expect(pressables(renderer!)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
|
||||
describe('isMobileMermaidLanguage', () => {
|
||||
it('matches mermaid case-insensitively after trim', () => {
|
||||
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
|
||||
expect(isMobileMermaidLanguage('Mermaid')).toBe(true)
|
||||
expect(isMobileMermaidLanguage(' MERMAID ')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-mermaid languages and missing language', () => {
|
||||
expect(isMobileMermaidLanguage(undefined)).toBe(false)
|
||||
expect(isMobileMermaidLanguage('')).toBe(false)
|
||||
expect(isMobileMermaidLanguage('ts')).toBe(false)
|
||||
expect(isMobileMermaidLanguage('mermaidx')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseMobileMarkdown', () => {
|
||||
it('parses mermaid fences as code blocks with language mermaid', () => {
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([
|
||||
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true }
|
||||
])
|
||||
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
|
||||
})
|
||||
|
||||
it('marks an unterminated fence as not closed while it streams', () => {
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([
|
||||
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false }
|
||||
])
|
||||
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({
|
||||
closed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('parses GFM tables into table blocks', () => {
|
||||
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
|
||||
{
|
||||
|
||||
@@ -7,7 +7,14 @@ import {
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
} from './markdown-file-path-detection'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
import {
|
||||
isIntrawordUnderscoreToken,
|
||||
trimAutolinkTrailingPunctuation
|
||||
} from './markdown-inline-token-rules'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
|
||||
|
||||
type Props = {
|
||||
content?: string
|
||||
@@ -15,19 +22,28 @@ type Props = {
|
||||
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
|
||||
* the chat view passes >1 so agent prose reads larger than the compact base. */
|
||||
textScale?: number
|
||||
/** When provided, detected file-path tokens render as tappable and invoke this
|
||||
* with the worktree-relative path. Omitted on screens with no file viewer, where
|
||||
/** When provided, detected file paths and file-target hrefs render as tappable
|
||||
* and invoke this with the path text (worktree-relative or absolute, with an
|
||||
* optional :line(:col) suffix). Omitted on screens with no file viewer, where
|
||||
* paths render as plain text (no behavior change). */
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
onOpenFile?: (pathText: string) => void
|
||||
}
|
||||
|
||||
const MAX_TABLE_ROWS = 40
|
||||
const MAX_TABLE_COLUMNS = 8
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
|
||||
function openMarkdownUrl(url: string): void {
|
||||
const trimmed = url.trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) {
|
||||
void Linking.openURL(trimmed).catch(() => {})
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
|
||||
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
|
||||
const route = routeMarkdownHref(href)
|
||||
if (route.kind === 'web') {
|
||||
void Linking.openURL(route.url).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (route.kind === 'file' && onOpenFile) {
|
||||
onOpenFile(route.pathText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +52,7 @@ function openMarkdownUrl(url: string): void {
|
||||
function renderTextRun(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
onOpenFile?: (pathText: string) => void
|
||||
): ReactNode {
|
||||
if (!onOpenFile) {
|
||||
return text
|
||||
@@ -61,39 +77,54 @@ function renderTextRun(
|
||||
})
|
||||
}
|
||||
|
||||
function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
|
||||
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
|
||||
const parts: ReactNode[] = []
|
||||
const pattern =
|
||||
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
|
||||
let lastIndex = 0
|
||||
let pendingStart = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text))) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
|
||||
}
|
||||
const token = match[0]
|
||||
// Intraword `_` runs (snake_case, dunder tails) are literal text per
|
||||
// CommonMark; leaving them unflushed keeps surrounding file paths whole
|
||||
// for detection in the eventual text run.
|
||||
if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
|
||||
// Resume after the opener so real tokens inside the rejected span are still scanned.
|
||||
pattern.lastIndex = match.index + 1
|
||||
continue
|
||||
}
|
||||
if (match.index > pendingStart) {
|
||||
parts.push(
|
||||
renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
|
||||
)
|
||||
}
|
||||
pendingStart = pattern.lastIndex
|
||||
const key = `${match.index}:${token}`
|
||||
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (image) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(image[2]!)}>
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(image[2]!, onOpenFile)}>
|
||||
{image[1] || 'image'}
|
||||
</Text>
|
||||
)
|
||||
} else if (link) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(link[2]!)}>
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(link[2]!, onOpenFile)}>
|
||||
{link[1]}
|
||||
</Text>
|
||||
)
|
||||
} else if (/^https?:\/\//i.test(token)) {
|
||||
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(token)}>
|
||||
{token}
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(url, onOpenFile)}>
|
||||
{url}
|
||||
</Text>
|
||||
)
|
||||
if (trailing) {
|
||||
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
|
||||
}
|
||||
} else if (token.startsWith('`')) {
|
||||
const code = token.slice(1, -1)
|
||||
if (onOpenFile && isFilePathCodeSpan(code)) {
|
||||
@@ -116,27 +147,26 @@ function renderInline(text: string, onOpenFile?: (relativePath: string) => void)
|
||||
} else if (token.startsWith('~~')) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.strike}>
|
||||
{token.slice(2, -2)}
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
} else if (token.startsWith('**') || token.startsWith('__')) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.bold}>
|
||||
{token.slice(2, -2)}
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.italic}>
|
||||
{token.slice(1, -1)}
|
||||
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
|
||||
if (pendingStart < text.length) {
|
||||
parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -153,6 +183,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
if (!text) {
|
||||
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
|
||||
}
|
||||
const mermaidSourceOccurrences = new Map<string, number>()
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
@@ -175,6 +206,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
)
|
||||
}
|
||||
if (block.type === 'code') {
|
||||
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
|
||||
// Unclosed fences are still streaming: mounting the WebView per tick would
|
||||
// reload its document up to 20x/sec, so they stay raw code until terminated.
|
||||
if (isMobileMermaidLanguage(block.language) && block.closed) {
|
||||
const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0
|
||||
mermaidSourceOccurrences.set(block.text, occurrence + 1)
|
||||
return (
|
||||
<MermaidDiagram
|
||||
key={`${block.text}:${occurrence}`}
|
||||
source={block.text}
|
||||
base={MERMAID_BASE}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<View key={index} style={styles.codeBlock}>
|
||||
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
|
||||
@@ -187,7 +232,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
<Pressable
|
||||
key={index}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownUrl(block.url)}
|
||||
onPress={() => openMarkdownHref(block.url, onOpenFile)}
|
||||
>
|
||||
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
|
||||
<Text style={styles.imageCaption} numberOfLines={1}>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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'
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
getItem: vi.fn().mockResolvedValue(null),
|
||||
setItem: vi.fn().mockResolvedValue(undefined),
|
||||
removeItem: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage }))
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
FlatList: 'FlatList',
|
||||
Image: 'Image',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Linking: { openURL: vi.fn() },
|
||||
Modal: 'Modal',
|
||||
ScrollView: 'ScrollView',
|
||||
Platform: { OS: 'ios', select: (options: { ios?: unknown }) => options.ios },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: <T,>(styles: T) => styles },
|
||||
Switch: 'Switch',
|
||||
Text: 'Text',
|
||||
TextInput: 'TextInput',
|
||||
View: 'View'
|
||||
}))
|
||||
// Every icon in the drawer tree renders as a host element named after itself.
|
||||
vi.mock(
|
||||
'lucide-react-native',
|
||||
() =>
|
||||
new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_target, name) => (typeof name === 'string' ? name : undefined),
|
||||
has: () => true
|
||||
}
|
||||
)
|
||||
)
|
||||
vi.mock('./BottomDrawer', () => ({ BottomDrawer: 'BottomDrawer' }))
|
||||
vi.mock('./bottom-drawer-modal-host', () => ({ BottomDrawerModalHost: 'BottomDrawerModalHost' }))
|
||||
vi.mock('./PickerListDrawer', () => ({ PickerListDrawer: 'PickerListDrawer' }))
|
||||
vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
|
||||
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
|
||||
|
||||
import { setCachedRepos } from '../cache/repo-cache'
|
||||
import { NewWorktreeModal } from './NewWorktreeModal'
|
||||
|
||||
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
|
||||
|
||||
function repoPickerNames(renderer: ReactTestRenderer | null): string[] {
|
||||
const pickers = renderer?.root.findAll((node) => node.type === 'PickerListDrawer') ?? []
|
||||
const repoPicker = pickers.find((node) => node.props.title === 'Repository')
|
||||
return ((repoPicker?.props.items ?? []) as { label: string }[]).map((item) => item.label)
|
||||
}
|
||||
|
||||
describe('NewWorktreeModal repo list', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
setCachedRepos('host-1', repos)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
|
||||
const sendRequest = vi.fn().mockImplementation((method: string) => {
|
||||
if (method === 'repo.list') {
|
||||
return Promise.reject(new Error('connection closed'))
|
||||
}
|
||||
return new Promise(() => {})
|
||||
})
|
||||
const client = { sendRequest } as unknown as RpcClient
|
||||
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(NewWorktreeModal, {
|
||||
visible: true,
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
onCreated: () => {},
|
||||
onClose: () => {}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('repo.list')
|
||||
expect(repoPickerNames(renderer)).toEqual(['orca'])
|
||||
})
|
||||
})
|
||||
@@ -327,11 +327,9 @@ function NewWorktreeModalContent({
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setRepos([])
|
||||
}
|
||||
})
|
||||
// Why (F10): a dropped connection rejects this call — keep the last-good list (the content
|
||||
// remounts with the new host's cache when the client changes) instead of emptying the picker.
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!stale) {
|
||||
setLoading(false)
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
detectFilePathSegments,
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
normalizeFilePath,
|
||||
splitFilePathLineSuffix
|
||||
} from './markdown-file-path-detection'
|
||||
|
||||
describe('detectFilePathSegments', () => {
|
||||
@@ -64,6 +65,69 @@ describe('detectFilePathSegments', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('detects POSIX absolute paths', () => {
|
||||
expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([
|
||||
{ type: 'text', value: 'Wrote ' },
|
||||
{ type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' },
|
||||
{ type: 'text', value: ' today' }
|
||||
])
|
||||
expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([
|
||||
{ type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('/root.ts')).toEqual([
|
||||
{ type: 'file', value: '/root.ts', path: '/root.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects files directly under explicit Windows and relative roots', () => {
|
||||
expect(detectFilePathSegments(String.raw`C:\root.ts`)).toEqual([
|
||||
{ type: 'file', value: String.raw`C:\root.ts`, path: String.raw`C:\root.ts` }
|
||||
])
|
||||
expect(detectFilePathSegments('./root.ts')).toEqual([
|
||||
{ type: 'file', value: './root.ts', path: 'root.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('../root.ts')).toEqual([
|
||||
{ type: 'file', value: '../root.ts', path: '../root.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects paths with :line and :line:col suffixes', () => {
|
||||
expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([
|
||||
{ type: 'text', value: 'see ' },
|
||||
{ type: 'file', value: 'src/foo.ts:42', path: 'src/foo.ts:42' },
|
||||
{ type: 'text', value: ' here' }
|
||||
])
|
||||
expect(
|
||||
detectFilePathSegments('/wt/src/app.tsx:120:7 and C:\\repo\\a.ts:3').filter(
|
||||
(s) => s.type === 'file'
|
||||
)
|
||||
).toEqual([
|
||||
{ type: 'file', value: '/wt/src/app.tsx:120:7', path: '/wt/src/app.tsx:120:7' },
|
||||
{ type: 'file', value: 'C:\\repo\\a.ts:3', path: 'C:\\repo\\a.ts:3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a non-line colon tail out of the match', () => {
|
||||
expect(detectFilePathSegments('edit src/foo.ts: then run')).toEqual([
|
||||
{ type: 'text', value: 'edit ' },
|
||||
{ type: 'file', value: 'src/foo.ts', path: 'src/foo.ts' },
|
||||
{ type: 'text', value: ': then run' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not partially parse numeric-looking non-line tails', () => {
|
||||
expect(detectFilePathSegments('log src/app.ts:1e3 oops')).toEqual([
|
||||
{ type: 'text', value: 'log ' },
|
||||
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
|
||||
{ type: 'text', value: ':1e3 oops' }
|
||||
])
|
||||
expect(detectFilePathSegments('coverage src/app.ts:80% of lines')).toEqual([
|
||||
{ type: 'text', value: 'coverage ' },
|
||||
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
|
||||
{ type: 'text', value: ':80% of lines' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match bare filenames without a slash', () => {
|
||||
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
|
||||
{ type: 'text', value: 'open Main.tsx please' }
|
||||
@@ -74,6 +138,15 @@ describe('detectFilePathSegments', () => {
|
||||
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
|
||||
{ type: 'text', value: 'https://example.com/path/file.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([
|
||||
{ type: 'text', value: 'see https://example.com/path/file.ts:42 now' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match protocol-relative URLs', () => {
|
||||
expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([
|
||||
{ type: 'text', value: 'load //cdn.example.com/lib/app.js' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match version numbers', () => {
|
||||
@@ -171,12 +244,61 @@ describe('isFilePathCodeSpan', () => {
|
||||
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts POSIX absolute paths and :line citations', () => {
|
||||
expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true)
|
||||
expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true)
|
||||
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects emails and git URLs with a mid-token @', () => {
|
||||
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
|
||||
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitFilePathLineSuffix', () => {
|
||||
it('splits :line and :line:col suffixes', () => {
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:42')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:42:7')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Windows drive colons intact', () => {
|
||||
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts`)).toEqual({
|
||||
path: String.raw`C:\repo\a.ts`,
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts:12`)).toEqual({
|
||||
path: String.raw`C:\repo\a.ts`,
|
||||
line: 12,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-numeric and zero suffixes', () => {
|
||||
expect(splitFilePathLineSuffix('src/foo.ts')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:0')).toEqual({
|
||||
path: 'src/foo.ts:0',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeFilePath', () => {
|
||||
it('strips a leading ./', () => {
|
||||
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
|
||||
|
||||
@@ -83,9 +83,11 @@ const FILE_EXTENSIONS = [
|
||||
const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
|
||||
|
||||
// Accept the host's native separator because transcript paths originate on the
|
||||
// connected runtime, which may be Windows even when the phone is not.
|
||||
// connected runtime, which may be Windows even when the phone is not. Leading
|
||||
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
|
||||
// tail captures only bounded agent-style :line(:col) citations.
|
||||
const CANDIDATE_PATTERN =
|
||||
/(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g
|
||||
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
|
||||
|
||||
// A path candidate in chat prose is short; a much longer run can't hold one worth
|
||||
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
|
||||
@@ -99,7 +101,31 @@ function hasMidTokenAt(candidate: string): boolean {
|
||||
return /[^\\/]@/.test(candidate)
|
||||
}
|
||||
|
||||
function isOpenablePath(candidate: string): boolean {
|
||||
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
|
||||
|
||||
/**
|
||||
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
|
||||
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
|
||||
*/
|
||||
export function splitFilePathLineSuffix(pathText: string): {
|
||||
path: string
|
||||
line: number | null
|
||||
column: number | null
|
||||
} {
|
||||
const match = LINE_SUFFIX_PATTERN.exec(pathText)
|
||||
if (!match) {
|
||||
return { path: pathText, line: null, column: null }
|
||||
}
|
||||
return {
|
||||
path: match[1]!,
|
||||
line: Number.parseInt(match[2]!, 10),
|
||||
column: match[3] ? Number.parseInt(match[3], 10) : null
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenablePath(pathText: string): boolean {
|
||||
// A :line(:col) tail is part of the citation, not the file name.
|
||||
const { path: candidate } = splitFilePathLineSuffix(pathText)
|
||||
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
|
||||
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
|
||||
return false
|
||||
@@ -153,10 +179,11 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
|
||||
|
||||
while ((match = CANDIDATE_PATTERN.exec(text))) {
|
||||
const candidate = match[0]
|
||||
// Skip candidates that are part of a URL (preceded by a scheme colon or an
|
||||
// alphanumeric/host char that would make this a domain tail, not a path).
|
||||
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
|
||||
// a preceding slash (the leading slash of an absolute path is part of the
|
||||
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
|
||||
const prev = match.index > 0 ? text[match.index - 1]! : ''
|
||||
if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
|
||||
if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
|
||||
continue
|
||||
}
|
||||
if (!isOpenablePath(candidate)) {
|
||||
@@ -195,16 +222,18 @@ export function isFilePathCodeSpan(code: string): boolean {
|
||||
if (isOpenablePath(trimmed)) {
|
||||
return true
|
||||
}
|
||||
// Separator-less code span: accept a clean name.ext with a known extension.
|
||||
if (/[\\/]/.test(trimmed)) {
|
||||
// Separator-less code span: accept a clean name.ext (with an optional
|
||||
// :line(:col) citation tail) and a known extension.
|
||||
const { path } = splitFilePathLineSuffix(trimmed)
|
||||
if (/[\\/]/.test(path)) {
|
||||
return false
|
||||
}
|
||||
const dot = trimmed.lastIndexOf('.')
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot <= 0) {
|
||||
return false
|
||||
}
|
||||
const name = trimmed.slice(0, dot)
|
||||
const ext = trimmed.slice(dot + 1).toLowerCase()
|
||||
const name = path.slice(0, dot)
|
||||
const ext = path.slice(dot + 1).toLowerCase()
|
||||
if (/[^\w.@+-]/.test(name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user