Merge remote-tracking branch 'origin/main' into brennanb2025/sta-3976-runtime-ssh-ai-vault

# Conflicts:
#	src/main/ipc/ai-vault.ts
This commit is contained in:
Brennan Benson
2026-08-14 15:40:04 -07:00
3527 changed files with 104863 additions and 31625 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ If your change affects UI or interaction behavior, verify it on the platforms it
Project-owned type declarations belong in `.ts` files. `.d.ts` is reserved for ambient shims (e.g., `env.d.ts`, `vite/client.d.ts`). TypeScript's `skipLibCheck: true` setting applies globally, including to our own `.d.ts` files, which means any unresolved type reference in a `.d.ts` silently becomes `any` at its call sites. Write your types in `.ts` files so the compiler actually checks them.
CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-typecheck-hole.md`.
CI enforces this for `src/preload/` and `src/shared/`.
## Pull Requests
+2 -1
View File
@@ -30,7 +30,8 @@ Fixes #
## AI Disclosure
<!-- For all external contributors only: Which AI model if anyone was used, please state the details -->
<!-- DO NOT FILL IN IF YOU ARE STABLYAI TEAM MEMBER (INTERNAL CONTRIBUTOR), IGNORE SECTION: -->
<!-- Which AI model if anyone was used, please state the details -->
## Review
+4
View File
@@ -29,6 +29,9 @@ on:
- 'electron.vite.config.ts'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
- 'src/main/providers/local-pty-provider.ts'
- 'src/main/providers/windows-shell-args.ts'
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
@@ -106,6 +109,7 @@ jobs:
src/main/computer/macos-native-provider-socket.test.ts
src/main/computer/macos-computer-use-permissions.test.ts
src/main/computer/macos-computer-use-permission-status.test.ts
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
src/main/computer/desktop-script-provider-client.test.ts
src/main/computer/desktop-script-provider-cache.test.ts
src/main/computer/desktop-script-provider-actions.test.ts
+31 -14
View File
@@ -126,7 +126,7 @@ jobs:
# scripts still need the Linux toolchain even though this shard reuses
# the prebuilt Electron output.
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
# Why: Electron on Linux needs an X display even when the app
# suppresses mainWindow.show() via ORCA_E2E_HEADLESS. xvfb provides a
@@ -174,7 +174,7 @@ jobs:
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
# launches but never creates a BrowserWindow.
- name: Run E2E tests (${{ matrix.shard_name }})
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e --shard=${{ matrix.shard }}
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 pnpm run test:e2e --shard=${{ matrix.shard }}
# Why: Playwright retains traces/screenshots only on failure. Uploading
# them as an artifact makes post-mortem debugging on CI possible without
@@ -202,7 +202,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -232,9 +232,24 @@ jobs:
env:
TEST_FILES_JSON: ${{ inputs.test_files }}
run: |
mapfile -t TEST_FILES < <(jq -r '.[]' <<<"$TEST_FILES_JSON")
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1
mapfile -t TEST_FILES < <(jq -r '.[] | select(
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts"
)' <<<"$TEST_FILES_JSON")
if [ "${#TEST_FILES[@]}" -eq 0 ]; then
echo "Changed startup-readiness specs are owned by the dedicated live lane."
exit 0
fi
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1)
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts'; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
E2E_PROJECT_ARGS=()
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
E2E_PROJECT_ARGS+=(--project=electron-headful)
fi
xvfb-run --auto-servernum env "${E2E_ENV[@]}" \
pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"
- name: Upload Playwright traces
if: failure()
@@ -248,10 +263,13 @@ jobs:
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
if: inputs.test_files == ''
if: >-
inputs.test_files == '' ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
# Why 35: the parking, retention, startup-exec, and paired parity specs run
# serially on isolated Electron/SSH fixtures after watcher isolation.
timeout-minutes: 35
steps:
@@ -261,7 +279,7 @@ jobs:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb zsh
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -295,10 +313,9 @@ jobs:
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
# Why always(): this lane gates SSH parking/retention plus startup-exec
# readiness across live SSH, headed paired, and headless serve topologies.
- name: Run Docker SSH terminal parking + startup readiness E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
+32 -4
View File
@@ -23,10 +23,9 @@ jobs:
platform: linux
- os: macos-15
platform: mac
# Why: Windows golden E2E is temporarily disabled on CI while its
# flaky runner-only failures are investigated.
# - os: windows-latest
# platform: windows
# Match the release gate: Windows runs a focused golden subset.
- os: windows-2022
platform: windows
steps:
- name: Checkout
@@ -62,17 +61,46 @@ jobs:
- name: Build Electron app for E2E
run: npx electron-vite build --mode e2e
# Why: this workflow can check out an older ref than the YAML that
# invoked it. Skip goldens the checked-out tree does not define.
- name: Run golden E2E tests on Linux
if: runner.os == 'Linux'
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on macOS
if: runner.os == 'macOS'
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
if [ -f tests/e2e/golden-fresh-profile-terminal.spec.ts ]; then
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
fi
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run golden E2E tests on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
if (Test-Path tests/e2e/golden-fresh-profile-terminal.spec.ts) {
pnpm run test:e2e -- tests/e2e/golden-fresh-profile-terminal.spec.ts tests/e2e/golden-shell-command.spec.ts
}
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
+14 -5
View File
@@ -74,14 +74,14 @@ jobs:
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
# in .d.ts to `any`, which is how #1186 shipped a broken IPC signature
# past typecheck. See docs/preload-typecheck-hole.md.
# past typecheck. See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts.
- name: Guard against project-owned .d.ts in preload/shared
run: |
matches=$(find src/preload src/shared -name '*.d.ts' 2>/dev/null || true)
if [ -n "$matches" ]; then
echo "::error::Project-owned .d.ts files are not allowed under src/preload or src/shared."
echo "Move type declarations into a .ts file so skipLibCheck does not hide errors."
echo "See docs/preload-typecheck-hole.md."
echo "See .github/CONTRIBUTING.md#type-declarations-prefer-ts-over-dts."
echo "Found:"
echo "$matches"
exit 1
@@ -383,7 +383,10 @@ jobs:
- name: Package unpacked app
env:
ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1'
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir
run: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage --x64 --publish never
- name: Verify headless serve signal shutdown
run: node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
@@ -426,8 +429,14 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows PowerShell CLI boundary
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
src/main/cli/wsl-cli-powershell-boundary.test.ts
src/main/orca-profiles/profile-index-store.test.ts
src/main/runtime/repo-worktree-admin-fingerprint.test.ts
src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts
src/shared/secure-file-fsync-flags.test.ts
- name: Build package inputs
run: pnpm run build:release
+47 -10
View File
@@ -783,7 +783,8 @@ jobs:
{
echo "## Release E2E Signal"
echo ""
echo "- Terminal rendering golden is release-blocking."
echo "- Platform golden E2E is release-blocking: terminal rendering, restrictive-umask profile writes, source control, and agent TUI launch on Linux/macOS, plus fresh startup and source control on Windows."
echo "- Exception: every golden except terminal rendering runs with \`--if-present\`, so it is skipped (not failed) on older tags that predate its script."
echo "- Full E2E runs separately after publication and cannot change the release result."
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
echo ""
@@ -817,7 +818,7 @@ jobs:
terminal-rendering-golden:
needs: cut
if: needs.cut.outputs.should_release == 'true'
name: terminal rendering golden ${{ matrix.platform }}
name: golden e2e ${{ matrix.platform }}
runs-on: ${{ matrix.os }}
timeout-minutes: 30
env:
@@ -830,10 +831,10 @@ jobs:
platform: linux
- os: macos-15
platform: mac
# Why: Windows terminal rendering golden is temporarily disabled on
# CI while its flaky runner-only failures are investigated.
# - os: windows-latest
# platform: windows
# Windows terminal rendering remains flaky; keep its blocking signal
# scoped to the fresh-profile startup regression from #14130.
- os: windows-2022
platform: windows
steps:
- name: Checkout
@@ -866,22 +867,58 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build Electron app for terminal rendering golden
- name: Build Electron app for platform golden
run: npx electron-vite build --mode e2e
# Why: this job is defined on the dispatch ref (usually main) but checks
# out the release tag. Cherry-pick / hotfix tags can predate a golden
# script that main already calls; --if-present keeps those cuts green
# instead of failing with ERR_PNPM_NO_SCRIPT.
- name: Run terminal rendering golden on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
run: |
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
- name: Run source-control golden on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run terminal rendering golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
run: |
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:workspace-session-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:posix-profile-index-golden
- name: Run agent TUI golden on Linux
if: runner.os == 'Linux'
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
- name: Run agent TUI golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:agent-tui-golden
- name: Run source-control golden on macOS
if: runner.os == 'macOS'
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run --if-present test:e2e:source-control-golden
- name: Run fresh-startup golden on Windows
if: runner.os == 'Windows'
shell: pwsh
run: |
$env:SKIP_BUILD = '1'
$env:ORCA_E2E_FORWARD_APP_LOGS = '1'
pnpm run --if-present test:e2e:workspace-session-golden
pnpm run --if-present test:e2e:windows-fresh-startup-golden
pnpm run --if-present test:e2e:source-control-golden
- name: Upload Playwright traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: terminal-rendering-golden-${{ matrix.platform }}-playwright-traces
name: golden-e2e-${{ matrix.platform }}-playwright-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+5
View File
@@ -104,6 +104,7 @@ docs/**
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/windows-setup-shell.md
!docs/reference/worktree-scan-fingerprint.md
# Stably CLI (only docs/ are tracked)
.stably/*
@@ -126,6 +127,10 @@ playwright-report/
/.agents/skills/
/skills-lock.json
# Generated Clawpatch state includes machine-local paths and review records.
/.clawpatch/
/mobile/.clawpatch/
# Agent hook runtime endpoints (machine-local secrets)
/agent-hooks/
validation-screenshots/
@@ -0,0 +1,37 @@
FROM ubuntu@sha256:678c6550cc43645e08669028bc177f50be4e7c5b8cca677067b1914d4afc7a03
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
dbus-x11 \
iproute2 \
jq \
libatk-bridge2.0-0 \
libatspi2.0-0 \
libasound2t64 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnss3 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
libxss1 \
p7zip-full \
procps \
util-linux \
xauth \
xvfb \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --shell /bin/bash orca
COPY run-signal-case.sh /usr/local/bin/run-signal-case
ENTRYPOINT ["/usr/local/bin/run-signal-case"]
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
set -euo pipefail
signal_name=${1:?signal name is required}
app_root=${ORCA_TEST_APP_ROOT:-/artifacts/root}
signal_target_kind=${ORCA_SIGNAL_TARGET:-app}
entrypoint_kind=${ORCA_TEST_ENTRYPOINT:-app}
int_delivery=${ORCA_INT_DELIVERY:-foreground-process-group}
startup_timeout_seconds=${ORCA_STARTUP_TIMEOUT_SECONDS:-90}
if ((EUID == 0)); then
exec runuser --user orca --preserve-environment -- "$0" "$@"
fi
case "$signal_name" in
INT|TERM) ;;
*) echo "unsupported signal: $signal_name" >&2; exit 64 ;;
esac
state_dir=$(mktemp -d "/tmp/orca-shutdown-${signal_name}.XXXXXX")
stdout_log="$state_dir/stdout.log"
stderr_log="$state_dir/stderr.log"
ulimit -c 0
sleep 300 &
canary_pid=$!
canary_start_ticks=$(awk '{print $22}' "/proc/$canary_pid/stat")
cleanup() {
kill "$canary_pid" 2>/dev/null || true
wait "$canary_pid" 2>/dev/null || true
}
trap cleanup EXIT
export HOME="$state_dir/home"
export XDG_CONFIG_HOME="$state_dir/config"
export XDG_CACHE_HOME="$state_dir/cache"
export XDG_RUNTIME_DIR="$state_dir/runtime"
export LIBGL_ALWAYS_SOFTWARE=1
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_RUNTIME_DIR"
chmod 700 "$XDG_RUNTIME_DIR"
case "$entrypoint_kind" in
app) entrypoint=("$app_root/AppRun" --no-sandbox) ;;
launcher)
export ELECTRON_DISABLE_SANDBOX=1
entrypoint=("$app_root/resources/bin/orca-ide")
;;
*) echo "unsupported entrypoint: $entrypoint_kind" >&2; exit 64 ;;
esac
setsid env -u DISPLAY "${entrypoint[@]}" serve --port 0 --pairing-address 127.0.0.1 --json \
>"$stdout_log" 2>"$stderr_log" &
app_pid=$!
app_start_ticks=$(awk '{print $22}' "/proc/$app_pid/stat")
# The inner shell expands its positional parameters.
# shellcheck disable=SC2016
ready_line=$(timeout "$startup_timeout_seconds" bash -c '
tail --pid="$1" -n +1 -F "$2" 2>/dev/null \
| jq --unbuffered -nc '\''first(inputs | select(.type == "orca_server_ready" and .schemaVersion == 1))'\''
' bash "$app_pid" "$stdout_log" || true)
if [[ -z "$ready_line" ]]; then
cat "$stdout_log" "$stderr_log" >&2
echo "FAIL: AppRun exited or timed out before orca_server_ready" >&2
exit 1
fi
bound_endpoint=$(jq -r '.boundEndpoint' <<<"$ready_line")
bound_port=${bound_endpoint##*:}
listener_before=$(ss -H -ltnp "sport = :$bound_port" || true)
if [[ -z "$listener_before" ]]; then
echo "FAIL: ready listener has no socket owner at $bound_endpoint" >&2
exit 1
fi
tree_pids=()
declare -A tree_start_ticks
tree_start_ticks["$app_pid"]=$app_start_ticks
frontier=("$app_pid")
while ((${#frontier[@]})); do
parent=${frontier[0]}
frontier=("${frontier[@]:1}")
while read -r child; do
[[ -n "$child" ]] || continue
child_start_ticks=$(awk '{print $22}' "/proc/$child/stat" 2>/dev/null || true)
[[ -n "$child_start_ticks" ]] || continue
tree_pids+=("$child")
tree_start_ticks["$child"]=$child_start_ticks
frontier+=("$child")
done < <(ps -o pid= --ppid "$parent" | tr -d ' ')
done
tree_pid_csv="$app_pid"
for pid in "${tree_pids[@]}"; do
tree_pid_csv+=",$pid"
done
tree_snapshot=$(ps -o pid=,ppid=,pgid=,lstart=,stat=,args= -p "$tree_pid_csv" 2>/dev/null || true)
xvfb_pids=$(awk '/[X]vfb :99 / {print $1}' <<<"$tree_snapshot" | paste -sd, -)
if [[ -z "$xvfb_pids" ]]; then
echo "$tree_snapshot" >&2
echo "FAIL: no run-owned Xvfb :99 process found after readiness" >&2
exit 1
fi
signal_target_pid=$app_pid
if [[ "$signal_target_kind" == serving-electron ]]; then
signal_target_pid=$(awk '/\/orca-ide .* --serve / {print $1; exit}' <<<"$tree_snapshot")
[[ -n "$signal_target_pid" ]] || { echo "FAIL: serving Electron process not found" >&2; exit 1; }
elif [[ "$signal_target_kind" != app ]]; then
echo "unsupported signal target: $signal_target_kind" >&2
exit 64
fi
signal_target_start_ticks=${tree_start_ticks[$signal_target_pid]:-}
if [[ -z "$signal_target_start_ticks" ]] \
|| [[ $(awk '{print $22}' "/proc/$signal_target_pid/stat") != "$signal_target_start_ticks" ]]; then
echo "FAIL: signal target identity changed before delivery" >&2
exit 1
fi
signal_delivery=pid
if [[ "$signal_name" == INT && "$int_delivery" == foreground-process-group ]]; then
signal_delivery=$int_delivery
kill -s "$signal_name" -- "-$signal_target_pid"
else
kill -s "$signal_name" "$signal_target_pid"
fi
sleep 30 &
watchdog_pid=$!
set +e
wait -n -p completed_pid "$app_pid" "$watchdog_pid"
wait_status=$?
set -e
if [[ "$completed_pid" == "$watchdog_pid" ]]; then
echo "FAIL: foreground AppRun did not exit after $signal_name" >&2
exit 1
fi
kill "$watchdog_pid" 2>/dev/null || true
wait "$watchdog_pid" 2>/dev/null || true
listener_after=$(ss -H -ltnp "sport = :$bound_port" || true)
survivors=()
for pid in "${tree_pids[@]}"; do
if [[ -r "/proc/$pid/stat" ]] \
&& [[ $(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true) == "${tree_start_ticks[$pid]}" ]] \
&& ps -o stat= -p "$pid" 2>/dev/null | grep -qv '^Z'; then
survivors+=("$pid")
fi
done
owned_residue=$(ps -eo pid=,ppid=,stat=,args= | awk -v state="$state_dir" \
'($0 ~ state || $0 ~ /\/artifacts\/root\/orca-ide/ || $0 ~ /[X]vfb :99 /) && $0 !~ /awk -v state=/ {print}' || true)
canary_alive=false
if kill -0 "$canary_pid" 2>/dev/null \
&& [[ $(awk '{print $22}' "/proc/$canary_pid/stat") == "$canary_start_ticks" ]]; then
canary_alive=true
fi
fatal_evidence=false
if grep -Eq 'Failed to shutdown|SIGTRAP|Trace/breakpoint trap|core dumped' \
"$stdout_log" "$stderr_log"; then
fatal_evidence=true
fi
jq -nc \
--arg signal "$signal_name" \
--arg signalDelivery "$signal_delivery" \
--arg entrypointKind "$entrypoint_kind" \
--arg signalTargetKind "$signal_target_kind" \
--argjson appPid "$app_pid" \
--argjson signalTargetPid "$signal_target_pid" \
--arg endpoint "$bound_endpoint" \
--arg listenerBefore "$listener_before" \
--arg listenerAfter "$listener_after" \
--arg xvfbPids "$xvfb_pids" \
--arg treeBefore "$tree_snapshot" \
--argjson waitStatus "$wait_status" \
--argjson fatalEvidence "$fatal_evidence" \
--argjson canaryAlive "$canary_alive" \
--arg survivors "${survivors[*]:-}" \
--arg residue "$owned_residue" \
--arg corePattern "$(cat /proc/sys/kernel/core_pattern)" \
'{signal:$signal,signalDelivery:$signalDelivery,entrypointKind:$entrypointKind,signalTargetKind:$signalTargetKind,appPid:$appPid,signalTargetPid:$signalTargetPid,boundEndpoint:$endpoint,listenerBefore:$listenerBefore,listenerAfter:$listenerAfter,xvfbPids:$xvfbPids,treeBefore:$treeBefore,waitStatus:$waitStatus,fatalEvidence:$fatalEvidence,canaryAlive:$canaryAlive,survivingTreePids:$survivors,ownedResidue:$residue,corePattern:$corePattern}'
if ((wait_status != 0)) || [[ -n "$listener_after" ]] || [[ "$fatal_evidence" != false ]] \
|| [[ "$canary_alive" != true ]] || ((${#survivors[@]})) || [[ -n "$owned_residue" ]]; then
echo "--- stdout ---" >&2
cat "$stdout_log" >&2
echo "--- stderr ---" >&2
cat "$stderr_log" >&2
exit 1
fi
+1 -6
View File
@@ -10,7 +10,6 @@ inline src/main/agent-hooks/server.test.ts
inline src/main/agent-hooks/server.ts
inline src/main/amp/hook-service.ts
inline src/main/antigravity/hook-service.ts
inline src/main/attribution/terminal-attribution.ts
inline src/main/automations/external-manager.ts
inline src/main/automations/hermes-cron-output.ts
inline src/main/browser/agent-browser-bridge.test.ts
@@ -168,7 +167,6 @@ inline src/main/window/attach-main-window-services.ts
inline src/main/window/createMainWindow.test.ts
inline src/main/window/createMainWindow.ts
inline src/main/workspace-space-analysis.ts
inline src/preload/api-types.ts
inline src/preload/index.ts
inline src/relay/agent-hook-server.ts
inline src/relay/dispatcher.ts
@@ -236,7 +234,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/WorktreeList.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.test.ts
inline src/renderer/src/components/sidebar/worktree-list-groups.ts
@@ -261,7 +258,6 @@ inline src/renderer/src/components/terminal-pane/terminal-link-handlers.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
inline src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
inline src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx
inline src/renderer/src/hooks/useAutomationDispatchEvents.ts
inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
@@ -319,7 +315,7 @@ inline src/shared/agent-hook-listener.ts
inline src/shared/automation-schedules.ts
inline src/shared/commit-message-agent-spec.ts
inline src/shared/constants.ts
inline src/shared/github-project-types.ts
inline src/shared/github/project-types.ts
inline src/shared/keybindings.test.ts
inline src/shared/keybindings.ts
inline src/shared/marine-creatures.ts
@@ -328,7 +324,6 @@ inline src/shared/runtime-types.ts
inline src/shared/source-control-ai.ts
inline src/shared/telemetry-events.ts
inline src/shared/text-search.ts
inline src/shared/types.ts
inline tests/e2e/helpers/terminal.ts
inline tests/e2e/terminal-panes.spec.ts
mobile-config app/h/*/files/*.tsx
File diff suppressed because it is too large Load Diff
@@ -22,7 +22,7 @@ const BUDGETS = {
maxTimerDriftMs: 150,
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
maxTimerDriftUnderLoadMs: 2_500,
maxTimerDriftUnderLoadMs: 3_500,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
maxRendererQueuedChars: 2 * 1024 * 1024,
@@ -132,14 +132,14 @@ describe('check-terminal-perf-report-budgets', () => {
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
const failPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=3501.0ms'].join(
' '
),
'opencode-cross-workspace-typing'
)
const failResult = runChecker(failPath)
expect(failResult.status).toBe(1)
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
expect(failResult.stderr).toContain('timer drift 3501ms exceeded budget 3500ms')
})
it('fails malformed metric values instead of treating them as absent', () => {
@@ -1,6 +1,8 @@
import { execFile } from 'node:child_process'
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import { afterEach, describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
import {
@@ -18,6 +20,7 @@ import {
const projectDir = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
const execFileAsync = promisify(execFile)
async function createFixture() {
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
@@ -92,6 +95,56 @@ describe('bundled skill guide generator', () => {
}
})
it('uses the exported recipe id variable in per-workspace environment examples', async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
expect(source).toContain('ORCA_RECIPE_ID')
expect(source).not.toContain('ORCA_VM_RECIPE_ID')
expect(source).toContain('recipe_id="${recipe_id//./-}"')
expect(source).toContain('max_recipe_id_length=$((128 - ${#instance_id} - 6))')
expect(source).toContain('name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"')
})
it.skipIf(process.platform === 'win32')(
'keeps Vercel sandbox names valid while preserving the instance suffix',
async () => {
const source = await readFile(
path.join(projectDir, 'skill-guides', 'orca-per-workspace-env.md'),
'utf8'
)
const startMarker = 'recipe_id="${ORCA_RECIPE_ID:-vercel-sandbox}"'
const endMarker = 'name="orca-${recipe_id:0:max_recipe_id_length}-${instance_id}"'
const start = source.indexOf(startMarker)
const endStart = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(endStart).toBeGreaterThan(start)
const script = `${source.slice(start, endStart + endMarker.length)}\nprintf '%s' "$name"`
const renderName = async (recipeId, instanceId) =>
(
await execFileAsync('bash', ['-u', '-c', script], {
env: { ...process.env, ORCA_RECIPE_ID: recipeId, ORCA_VM_INSTANCE_ID: instanceId }
})
).stdout
const instanceId = 'orca-123e4567-e89b-12d3-a456-426614174000'
const dotted = await renderName('provider.cloud_sandbox', instanceId)
const maximum = await renderName(`a${'.'.repeat(63)}`, instanceId)
const longInstanceId = 'i'.repeat(100)
const capped = await renderName(
'provider.cloud_sandbox.with.a.long.recipe.identifier',
longInstanceId
)
expect(dotted).toBe(`orca-provider-cloud_sandbox-${instanceId}`)
expect(maximum).toMatch(/^[a-zA-Z0-9_-]{1,128}$/u)
expect(capped).toHaveLength(128)
expect(capped.endsWith(`-${longInstanceId}`)).toBe(true)
}
)
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
@@ -0,0 +1,66 @@
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { describe, expect, it } from 'vitest'
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
const headlessLinuxGuide = readFileSync('docs/reference/headless-linux-server.md', 'utf8')
function readSystemdUnitBlocks(doc, unitName) {
const escapedUnitName = unitName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
return [...doc.matchAll(new RegExp(`^# /etc/systemd/system/${escapedUnitName}$`, 'gm'))].map(
(match) => {
const start = match.index + match[0].length
const end = doc.indexOf('```', start)
const nextUnitHeaderOffset = doc.slice(start).search(/^# \/etc\/systemd\/system\/.+$/m)
const nextUnitHeader = nextUnitHeaderOffset === -1 ? -1 : start + nextUnitHeaderOffset
if (end === -1 || (nextUnitHeader !== -1 && end > nextUnitHeader)) {
throw new Error(`Missing closing code fence for ${unitName}`)
}
return doc.slice(start, end)
}
)
}
describe('headless serve shutdown PR gate', () => {
it('reads only exact, closed systemd unit blocks', () => {
expect(
readSystemdUnitBlocks('# /etc/systemd/system/orca-serveXservice\n```', 'orca-serve.service')
).toEqual([])
expect(() =>
readSystemdUnitBlocks('# /etc/systemd/system/orca-serve.service\n', 'orca-serve.service')
).toThrow('Missing closing code fence for orca-serve.service')
expect(() =>
readSystemdUnitBlocks(
'# /etc/systemd/system/orca-serve.service\n' +
'KillMode=mixed\n' +
'# /etc/systemd/system/other.service\n```',
'orca-serve.service'
)
).toThrow('Missing closing code fence for orca-serve.service')
})
it('packages an x64 AppImage before running the Docker signal oracle', () => {
const steps = workflow.jobs.package.steps
const packageStep = steps.find((step) => step.name === 'Package unpacked app')
const shutdownStep = steps.find((step) => step.name === 'Verify headless serve signal shutdown')
expect(packageStep.run).toContain('--linux AppImage --x64 --publish never')
expect(shutdownStep.run).toBe(
'node config/scripts/run-headless-serve-shutdown-docker.mjs --appimage dist/orca-linux.AppImage'
)
expect(steps.indexOf(shutdownStep)).toBeGreaterThan(steps.indexOf(packageStep))
})
it('keeps owned Xvfb alive during the documented systemd graceful stop', () => {
const serveUnits = readSystemdUnitBlocks(headlessLinuxGuide, 'orca-serve.service')
const ownedXvfbUnits = serveUnits.filter((unit) => !/^Environment=DISPLAY=/m.test(unit))
const managedXvfbUnits = serveUnits.filter((unit) => /^Environment=DISPLAY=/m.test(unit))
expect(ownedXvfbUnits).toHaveLength(1)
expect(ownedXvfbUnits[0]).toMatch(/^ExecStart=.*orca-linux\.AppImage serve.*$/m)
expect(ownedXvfbUnits[0]).toMatch(/^KillMode=mixed$/m)
expect(managedXvfbUnits).toHaveLength(1)
expect(managedXvfbUnits[0]).not.toMatch(/^KillMode=/m)
})
})
@@ -115,10 +115,6 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
zh: '评审中',
ja: 'レビュー中'
},
'auto.components.skills.SkillsPage.b088e0785d': {
zh: '测试版',
ja: 'ベータ'
},
'auto.components.status.bar.WorkspaceSpaceCompactPanel.c361440dc0': {
zh: '测试版',
ja: 'ベータ'
-10
View File
@@ -312,11 +312,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '显示中',
ja: '表示中'
},
'auto.components.skills.SkillsPage.a68dee6a32': {
ko: '스킬 검색',
zh: '搜索技能',
ja: 'スキルを検索'
},
'auto.components.editor.RichMarkdownSlashMenu.550189b06c': {
ko: '블록 검색',
zh: '搜索块',
@@ -435,11 +430,6 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '代理位置',
ja: 'Agent の場所'
},
'auto.components.skills.SkillsPage.38e0951c3a': {
ko: '에이전트 스킬',
zh: '代理技能',
ja: 'Agent のスキル'
},
'auto.components.sidebar.SidebarNav.e518f544b1': {
ko: '감지된 에이전트 없음',
zh: '未检测到代理',
@@ -2273,12 +2273,6 @@
"auto.components.settings.GitPane.976afc6b3e": {
"ko": "워크스페이스를 만들면 Orca가 원격 기준 브랜치를 새로고침하고, 일치하는 로컬 브랜치를 안전하게 fast-forward합니다. 예:"
},
"auto.components.settings.GitPane.b5f534717a": {
"ko": "공동 작성"
},
"auto.components.settings.GitPane.b9b5771bb1": {
"ko": "표기"
},
"auto.components.settings.GitPane.b9c011fbc2": {
"ko": "속도 제한"
},
@@ -2288,9 +2282,6 @@
"auto.components.settings.GitPane.db3a127eb1": {
"ko": "이렇게 하면 다음과 같은 명령이 유지됩니다"
},
"auto.components.settings.GitPane.e02ea23a32": {
"ko": "Orca 표기"
},
"auto.components.settings.GitPane.f35007e6e8": {
"ko": "git-username"
},
@@ -4454,9 +4445,6 @@
"auto.components.sidebar.worktree.list.groups.6798dc7c94": {
"ko": "리뷰 중"
},
"auto.components.skills.SkillsPage.b088e0785d": {
"ko": "베타"
},
"auto.components.source.control.SourceControlActionVariableChips.4bf6d88039": {
"ko": "(비어 있음)"
},
+2 -4
View File
@@ -44,7 +44,6 @@ export const KO_VALUE_OVERRIDES = {
'Issue #{{value0}}': '이슈 #{{value0}}',
'Linked issue #{{value0}}': '연결된 이슈 #{{value0}}',
'Issue # or GitHub URL': '이슈 # 또는 GitHub URL',
'Add Orca attribution to commits, PRs, and issues.': '커밋, PR 및 이슈에 Orca 속성을 추가합니다.',
'Paste an issue URL, or enter a number. Leave blank to remove the link.':
'이슈 URL을 붙여넣거나 숫자를 입력하세요. 링크를 제거하려면 비워두세요.',
'Connect Linear in Settings to search issues.': '이슈를 검색하려면 설정에서 Linear를 연결하세요.',
@@ -157,8 +156,7 @@ export const KO_VALUE_OVERRIDES = {
'저장되지 않은 Git AI Author 변경사항이 있습니다. 떠나면 폐기됩니다.',
'Discard unsaved Git AI Author changes?':
'저장되지 않은 Git AI Author 변경사항을 삭제하시겠습니까?',
'Branch naming, base refs, attribution, and Git AI Author.':
'브랜치 이름 지정, 기본 참조, 속성 및 Git AI Author.',
'Branch naming, base refs, and Git AI Author.': '브랜치 이름 지정, 기본 참조 및 Git AI Author.',
'Used by repositories that inherit global hosted-review defaults.':
'전역 호스팅 PR 기본값을 상속하는 저장소에서 사용됩니다.',
'Defaults used when the hosted-review composer opens.':
@@ -175,7 +173,7 @@ export const KO_VALUE_OVERRIDES = {
'아래 워크스페이스 오류를 확인한 후 새로고침하세요.',
'Review inactive workspaces before deleting their local files and Orca state.':
'로컬 파일과 Orca 상태를 삭제하기 전에 비활성 워크스페이스를 확인하세요.',
'Review inactive workspaces (': '비활성 워크스페이스 확인(',
'Clean up workspaces': '워크스페이스 정리',
'Review the settings that will be imported from your Ghostty config.':
'Ghostty 구성에서 가져올 설정을 확인하세요.',
'GitHub requires review approval before this pull request can merge':
@@ -164,7 +164,6 @@ export const SEARCH_KEYWORD_OVERRIDES = {
gitlab: 'GitLab',
gitea: 'Gitea',
oauth: 'OAuth',
attribution: '归因',
skill: '技能',
skills: '技能',
phone: '手机',
@@ -25,10 +25,10 @@ describe('locale-translation-policy es PR glossary', () => {
it('rewrites PR inside longer sentences', () => {
expect(
repairEs(
'Add Orca attribution to commits, PRs, and issues.',
'Agregue la atribución de Orca a commits, relaciones públicas y problemas.'
'Split a large change into smaller PRs',
'Dividir un cambio grande en relaciones públicas más pequeños'
)
).toBe('Agregue la atribución de Orca a commits, PR y problemas.')
).toBe('Dividir un cambio grande en PR más pequeños')
expect(
repairEs(
'Open the PR details to view current reviewers.',
@@ -44,22 +44,14 @@ describe('locale-translation-policy zh round 5', () => {
locale: 'zh'
})
).toBe('显示 Orca Mobile 按钮')
expect(
repairTranslatedValue({
key: 'auto.components.settings.GitPane.e02ea23a32',
enValue: 'Orca Attribution',
localeValue: 'Orca归属',
locale: 'zh'
})
).toBe('Orca 归因')
expect(
repairTranslatedValue({
key: 'auto.hooks.useSettingsNavigationMetadata.ab4b21b58e',
enValue: 'Branch naming, base refs, attribution, and Git AI Author.',
localeValue: '分支命名、基本引用、归属和 Git AI 作者。',
enValue: 'Branch naming, base refs, and Git AI Author.',
localeValue: '分支命名、基本引用和 Git AI 作者。',
locale: 'zh'
})
).toBe('分支命名、基础引用、归因和 Git AI Author。')
).toBe('分支命名、基础引用和 Git AI Author。')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569',
@@ -3,7 +3,6 @@ export const ZH_PHRASE_FIXES_ROUND5 = [
{ pattern: /Orca集成开发环境/g, replacement: 'Orca IDE', whenEnIncludes: 'Orca IDE' },
{ pattern: /Orca第一/g, replacement: 'Orca 优先', whenEnIncludes: 'Orca first' },
{ pattern: /Orca移动/g, replacement: 'Orca Mobile', whenEnIncludes: 'Orca Mobile' },
{ pattern: /Orca归属/g, replacement: 'Orca 归因', whenEnIncludes: 'Orca Attribution' },
{ pattern: /Orca标志/g, replacement: 'Orca 标志', whenEnIncludes: 'Orca logo' },
{ pattern: /喜欢Orca/g, replacement: '喜欢 Orca', whenEnIncludes: 'Enjoying Orca' },
{ pattern: /认识Orca/g, replacement: '了解 Orca', whenEnIncludes: 'Get to know Orca' },
+1 -3
View File
@@ -144,7 +144,6 @@ export const ZH_VALUE_OVERRIDES = {
'Expand Orca': '展开 Orca',
'Support Orca': '支持 Orca',
'Restarting Orca…': '正在重启 Orca…',
'Orca Attribution': 'Orca 归因',
'Show Orca Mobile Button': '显示 Orca Mobile 按钮',
'Codex Accounts': 'Codex 账户',
'Codex Account': 'Codex 账户',
@@ -186,8 +185,7 @@ export const ZH_VALUE_OVERRIDES = {
"Give agents direct access to Orca's browser so they can test pages, capture screenshots, and act on what they see.":
'让代理直接访问 Orca 的浏览器,以便测试页面、捕获屏幕截图并根据所见内容执行操作。',
'X finishes, send it the review task.”': 'X 完成后,把评审任务发给它。”',
'Branch naming, base refs, attribution, and Git AI Author.':
'分支命名、基础引用、归因和 Git AI Author。',
'Branch naming, base refs, and Git AI Author.': '分支命名、基础引用和 Git AI Author。',
'You have unsaved Git AI Author changes. Leaving will discard them.':
'您有未保存的 Git AI Author 更改。离开将丢弃它们。',
'Discard unsaved Git AI Author changes?': '放弃未保存的 Git AI Author 更改?',
@@ -221,16 +221,20 @@ describe('orchestration skill guidance', () => {
expect(skill).toContain('the named owner edits files and creates the PR')
})
it('keeps worker_done post-completion guidance idle instead of polling', () => {
it('keeps post-completion workers idle without subordinating the user', () => {
const skill = readSkill()
const agentGuidance = getSection(skill, 'Agent Guidance')
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
expect(agentGuidance).toContain('A direct user instruction takes precedence')
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
)
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')
})
@@ -305,7 +309,7 @@ describe('orchestration skill guidance', () => {
/\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'
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
@@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => {
'build:linux',
'test:e2e',
'test:e2e:terminal-rendering-golden',
'test:e2e:posix-profile-index-golden',
'test:e2e:terminal-rendering-release-evidence',
'test:e2e:headful'
]
@@ -492,7 +493,7 @@ describe('Electron runtime package contract', () => {
expect(uploadStep.with.path).toBe('${{ env.ORCA_E2E_TERMINAL_PERF_REPORT_PATH }}')
})
it('keeps terminal rendering regressions in the manual golden E2E workflow', () => {
it('keeps platform golden regressions in the manual and release workflows', () => {
const packageScripts = packageJson.scripts
const goldenWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/golden-e2e-experiment.yml'), 'utf8')
@@ -506,17 +507,19 @@ describe('Electron runtime package contract', () => {
['mac', 'macOS'],
['windows', 'Windows']
])
const goldenPlatforms = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
.map(({ platform }) => platform)
.sort()
const goldenRunSteps = goldenPlatforms.map((platform) => {
const label = goldenPlatformLabels.get(platform)
const goldenMatrix = goldenWorkflow.jobs['golden-e2e'].strategy.matrix.include
const goldenPlatforms = goldenMatrix.map(({ platform }) => platform).sort()
const goldenRunSteps = new Map(
goldenPlatforms.map((platform) => {
const label = goldenPlatformLabels.get(platform)
expect(label, platform).toBeDefined()
expect(label, platform).toBeDefined()
return steps.find((step) => step.name === `Run golden E2E tests on ${label}`)
})
return [platform, steps.find((step) => step.name === `Run golden E2E tests on ${label}`)]
})
)
const releaseGoldenJob = releaseWorkflow.jobs['terminal-rendering-golden']
const releaseGoldenMatrix = releaseGoldenJob.strategy.matrix.include
const releaseEvidenceJob = releaseWorkflow.jobs['terminal-rendering-release-evidence']
const releaseBuildNeeds = releaseWorkflow.jobs.build.needs
const publishReleaseNeeds = releaseWorkflow.jobs['publish-release'].needs
@@ -535,15 +538,49 @@ describe('Electron runtime package contract', () => {
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
'golden-windows-fresh-startup.spec.ts'
)
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
'@windows-fresh-startup-golden'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'golden-posix-profile-index-fsync.spec.ts'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'golden-posix-fresh-startup.spec.ts'
)
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
'@posix-profile-index-golden'
)
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
'terminal-opencode-emoji-table-rendering.spec.ts'
)
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
'terminal-long-table-scroll-restore.spec.ts'
)
for (const runStep of goldenRunSteps) {
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
}
expect(goldenMatrix).toEqual([
{ os: 'ubuntu-latest', platform: 'linux' },
{ os: 'macos-15', platform: 'mac' },
{ os: 'windows-2022', platform: 'windows' }
])
expect(goldenRunSteps.get('linux')?.run).toContain(
'pnpm run test:e2e:terminal-rendering-golden'
)
expect(goldenRunSteps.get('linux')?.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(goldenRunSteps.get('mac')?.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
expect(goldenRunSteps.get('windows')).toMatchObject({
if: "runner.os == 'Windows'",
shell: 'pwsh'
})
expect(goldenRunSteps.get('windows').run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(goldenWorkflow.on.pull_request).toBeUndefined()
expect(goldenWorkflow.on.workflow_dispatch).toBeDefined()
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
@@ -552,11 +589,30 @@ describe('Electron runtime package contract', () => {
expect(publishReleaseNeeds).toContain('build')
expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence')
expect(releaseGoldenJob['continue-on-error']).toBeUndefined()
expect(releaseGoldenJob.strategy.matrix.include.map(({ platform }) => platform).sort()).toEqual(
goldenPlatforms
expect(releaseGoldenMatrix).toEqual(goldenMatrix)
const releaseLinuxRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run terminal rendering golden on Linux'
)
expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain(
'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden'
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(releaseLinuxRunStep.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
const releaseMacRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run terminal rendering golden on macOS'
)
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
expect(releaseMacRunStep.run).toContain(
'pnpm run --if-present test:e2e:posix-profile-index-golden'
)
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
(step) => step.name === 'Run fresh-startup golden on Windows'
)
expect(releaseWindowsRunStep).toMatchObject({
if: "runner.os == 'Windows'",
shell: 'pwsh'
})
expect(releaseWindowsRunStep.run).toContain(
'pnpm run --if-present test:e2e:windows-fresh-startup-golden'
)
expect(releaseEvidenceJob['continue-on-error']).toBe(true)
expect(
+32 -2
View File
@@ -6,6 +6,10 @@ import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
const prWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8'))
const e2eWorkflow = parse(readFileSync(join(projectDir, '.github/workflows/e2e.yml'), 'utf8'))
const sshDockerRunner = readFileSync(
join(projectDir, 'config/scripts/run-ssh-docker-terminal-parking-e2e.mjs'),
'utf8'
)
const filterStep = prWorkflow.jobs['e2e-paths'].steps.find(
(step) => step.name === 'Filter changed E2E specs'
@@ -64,12 +68,38 @@ describe('PR E2E gate contract', () => {
expect(e2eWorkflow.jobs.e2e.if).toBe("inputs.test_files == ''")
expect(e2eWorkflow.jobs['changed-e2e'].if).toBe("inputs.test_files != ''")
expect(e2eWorkflow.jobs['changed-e2e'].strategy).toBeUndefined()
expect(e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if).toBe("inputs.test_files == ''")
const changedRun = e2eWorkflow.jobs['changed-e2e'].steps.find(
(step) => step.name === 'Run changed E2E specs'
)
expect(changedRun.env.TEST_FILES_JSON).toBe('${{ inputs.test_files }}')
expect(changedRun.run).toContain('pnpm run test:e2e "${TEST_FILES[@]}" --workers=1')
expect(changedRun.run).toContain('. != "tests/e2e/ssh-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('. != "tests/e2e/paired-startup-exec-readiness.spec.ts"')
expect(changedRun.run).toContain('if [ "${#TEST_FILES[@]}" -eq 0 ]')
expect(changedRun.run).toContain('grep -l \'@headful\' "${TEST_FILES[@]}"')
expect(changedRun.run).toContain('E2E_PROJECT_ARGS+=(--project=electron-headful)')
expect(changedRun.run).toContain(
'pnpm run test:e2e "${TEST_FILES[@]}" --workers=1 "${E2E_PROJECT_ARGS[@]}"'
)
})
it('keeps startup-exec live parity in the isolated SSH lane', () => {
const sshLaneCondition = e2eWorkflow.jobs['ssh-docker-watcher-isolation'].if
expect(sshLaneCondition).toContain("inputs.test_files == ''")
expect(sshLaneCondition).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshLaneCondition).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/ssh-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain('tests/e2e/paired-startup-exec-readiness.spec.ts')
expect(sshDockerRunner).toContain("'electron-headless'")
expect(sshDockerRunner).toContain("'electron-headful'")
})
it('installs zsh in every Linux lane that can run paired startup readiness', () => {
for (const jobName of ['e2e', 'changed-e2e', 'ssh-docker-watcher-isolation']) {
const installStep = e2eWorkflow.jobs[jobName].steps.find((step) =>
step.name.startsWith('Install native build')
)
expect(installStep.run, jobName).toMatch(/\bzsh\b/)
}
})
it('keeps dedicated E2E workflows out of pull request CI', () => {
@@ -5,7 +5,7 @@ import {
resolveEnvironment,
resolveEnvironmentPairingOffer
} from '../../src/shared/runtime-environment-store'
import type { MemorySnapshot } from '../../src/shared/types'
import type { MemorySnapshot } from '../../src/shared/process-stats-types'
import type { RuntimeStatus } from '../../src/shared/runtime-types'
async function main(): Promise<void> {
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
const args = process.argv.slice(2)
const appImageArg = valueAfter('--appimage')
const platform = valueAfter('--platform') ?? 'linux/amd64'
const signalTarget = valueAfter('--signal-target') ?? 'app'
const entrypoint = valueAfter('--entrypoint') ?? 'app'
const intDelivery = valueAfter('--int-delivery') ?? 'foreground-process-group'
const launcherExecOverlay = args.includes('--launcher-exec-overlay')
if (!appImageArg) {
fail('Usage: run-headless-serve-shutdown-docker.mjs --appimage /path/to/orca.AppImage')
}
if (!['app', 'serving-electron'].includes(signalTarget)) {
fail(`Unsupported --signal-target: ${signalTarget}`)
}
if (!['app', 'launcher'].includes(entrypoint)) {
fail(`Unsupported --entrypoint: ${entrypoint}`)
}
if (!['pid', 'foreground-process-group'].includes(intDelivery)) {
fail(`Unsupported --int-delivery: ${intDelivery}`)
}
if (intDelivery === 'foreground-process-group' && signalTarget !== 'app') {
fail('--int-delivery foreground-process-group requires --signal-target app')
}
if (launcherExecOverlay && entrypoint !== 'launcher') {
fail('--launcher-exec-overlay requires --entrypoint launcher')
}
const appImage = resolve(appImageArg)
const shutdownDockerDirectory = resolve('config', 'docker', 'headless-serve-shutdown')
const shutdownDockerfile = resolve(shutdownDockerDirectory, 'Dockerfile')
if (!existsSync(appImage)) {
fail(`AppImage not found: ${appImage}`)
}
const suffix = `${process.pid}-${Date.now()}`
const image = `orca-headless-serve-shutdown:${suffix}`
const artifactVolume = `orca-headless-serve-shutdown-${suffix}`
const sha256 = createHash('sha256').update(readFileSync(appImage)).digest('hex')
try {
docker([
'build',
'--platform',
platform,
'-f',
shutdownDockerfile,
'-t',
image,
shutdownDockerDirectory
])
docker(['volume', 'create', artifactVolume])
docker([
'run',
'--rm',
'--platform',
platform,
'--entrypoint',
'bash',
'-v',
`${appImage}:/input/orca.AppImage:ro`,
'-v',
`${artifactVolume}:/artifacts`,
image,
'-lc',
[
'7z x /input/orca.AppImage -o/artifacts/root -y >/dev/null',
launcherExecOverlay
? "sed -i 's/^ELECTRON_RUN_AS_NODE=1 /export ELECTRON_RUN_AS_NODE=1\\nexec /' /artifacts/root/resources/bin/orca-ide"
: ':',
'chmod -R a+rX /artifacts/root'
].join(' && ')
])
console.log(
JSON.stringify({
type: 'appimage_under_test',
appImage,
sha256,
platform,
signalTarget,
entrypoint,
intDelivery,
launcherExecOverlay
})
)
const failedSignals = []
for (const signal of ['INT', 'TERM']) {
const result = docker(
[
'run',
'--rm',
'--init',
'--platform',
platform,
'--shm-size',
'256m',
'--name',
`orca-headless-serve-shutdown-${signal.toLowerCase()}-${suffix}`,
'-e',
`ORCA_SIGNAL_TARGET=${signalTarget}`,
'-e',
`ORCA_TEST_ENTRYPOINT=${entrypoint}`,
'-e',
`ORCA_INT_DELIVERY=${intDelivery}`,
'-v',
`${artifactVolume}:/artifacts:ro`,
image,
signal
],
{ allowFailure: true }
)
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
if (result.status !== 0) {
failedSignals.push(`${signal}:${result.status}`)
}
}
if (failedSignals.length > 0) {
fail(`Shutdown oracle failed: ${failedSignals.join(', ')}`)
}
console.log('Headless serve packaged shutdown Docker validation passed.')
} finally {
docker(['volume', 'rm', artifactVolume], { allowFailure: true })
docker(['image', 'rm', image], { allowFailure: true })
}
function valueAfter(flag) {
const index = args.indexOf(flag)
return index === -1 ? null : (args[index + 1] ?? null)
}
function docker(dockerArgs, options = {}) {
const result = spawnSync('docker', dockerArgs, {
cwd: process.cwd(),
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.error) {
throw result.error
}
if (result.status !== 0 && !options.allowFailure) {
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
fail(`docker ${dockerArgs[0]} failed with status ${result.status}`)
}
return result
}
function fail(message) {
console.error(message)
process.exitCode = 1
throw new Error(message)
}
@@ -5,7 +5,8 @@ const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArg
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
ORCA_E2E_SSH_DOCKER: '1',
ORCA_E2E_WEB_CLIENT: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
@@ -21,9 +22,8 @@ if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
// Why both specs in one runner: they share the docker SSH rig and the same
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
// restore, the retention spec proves the C1 budget bounds what they retain.
// Why one runner: these specs share the Docker SSH rig; startup-exec also joins
// the paired desktop/headless parity proof under the same isolated CI lane.
const result = spawnSync(
pnpm,
[
@@ -32,10 +32,14 @@ const result = spawnSync(
'test',
'tests/e2e/ssh-terminal-parking.spec.ts',
'tests/e2e/terminal-retention-budget.spec.ts',
'tests/e2e/ssh-startup-exec-readiness.spec.ts',
'tests/e2e/paired-startup-exec-readiness.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--project',
'electron-headful',
'--workers=1',
...extraArgs
],
@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { performance } from 'node:perf_hooks'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree/sort-order-update.ts'
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
const WORKTREE_COUNT = 655
+1
View File
@@ -56,6 +56,7 @@
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
"../src/main/codex/managed-home-shell-preflight.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
+1
View File
@@ -5,6 +5,7 @@
"../src/renderer/src/**/*",
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/shared/**/*",
"../src/main/gitlab/mappers.ts",
"../src/main/ipc/worktree-branch-name.ts",
+1
View File
@@ -5,6 +5,7 @@
"../src/renderer/src/**/*",
"../src/renderer/src/**/*.tsx",
"../src/preload/api-types.ts",
"../src/preload/api/**/*",
"../src/shared/**/*"
],
"compilerOptions": {
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
<title>downloads: 19m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 20m">
<title>downloads: 20m</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">19m</text>
<text x="90" y="14">19m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">20m</text>
<text x="90" y="14">20m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+5
View File
@@ -166,6 +166,7 @@ Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
StandardOutput=journal
StandardError=journal
KillMode=mixed
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
@@ -177,6 +178,10 @@ WantedBy=multi-user.target
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
`KillMode=mixed` sends the graceful stop signal only to Orca's main process,
then retains systemd's cgroup-wide `SIGKILL` fallback if shutdown times out.
This lets Orca keep its owned Xvfb alive until Electron disconnects cleanly.
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
+274
View File
@@ -0,0 +1,274 @@
# Steady-state worktree rescan: Git-admin fingerprint gate
## Status
Adopted for the main-process worktree resolution cache
(`OrcaRuntimeService.listRepoWorktreesForResolution`). It keeps the existing
30-second freshness contract for externally created, removed, moved, locked, and
re-checked-out worktrees while removing the `git worktree list` subprocess that
previously ran for every registered repository every 30 seconds.
## Context
A production trace (10 registered repositories, 3 h 27 min) recorded 4,272
`git worktree` invocations and 8,663 total Git subprocesses. The invocations
arrived in full-fleet sweeps roughly every 30.5 s — one sweep per repository per
`WORKTREE_SCAN_CACHE_TTL_MS`.
### What actually expires the cache
The originating report assumed some specific terminal/status/orchestration
request was invalidating the 30-second cache. That is not what happens, and the
distinction changes the fix:
- `resolvedWorktreeCache` (the whole-fleet snapshot) has a **1-second** TTL
(`RESOLVED_WORKTREE_CACHE_TTL_MS`). Any caller polling faster than 1 Hz
recomputes the snapshot.
- `computeResolvedWorktrees` fans out over **every** registered repo
(`src/main/runtime/orca-runtime.ts`), calling
`listRepoWorktreesForResolution` per repo.
- That per-repo call is backed by `worktreeScanCache` with a **30-second** TTL.
When it expires, the next poll shells out.
So no request "expires" the 30-second cache. It expires on wall-clock time, and
whichever poller arrives first afterwards pays a full-fleet `git worktree list`
fan-out. The many high-frequency callers — `listTerminals` without a selector,
`showTerminal`, `getWorktreePs`, `listManagedWorktrees`,
`resolveWorktreeSelector`, orchestration authority refresh — only determine
*who* pays, not *how often*. Steady-state subprocess volume is therefore
`repos / 30 s`, independent of poll rate, which is exactly the observed
~1 sweep / 30.5 s.
The in-Orca mutation surface is already event-driven: create, remove, rename,
folder-rename, sparse edits, repo add/update/remove, SSH reconnect, and
mixed-version remote invalidation all call
`invalidateWorktreeScanCacheForRepo` / `invalidateResolvedWorktreeCache`
(≈40 call sites). The 30-second TTL exists for exactly one reason: discovering
worktree changes made **outside** Orca (`git worktree add/remove/move/prune`,
`git checkout` in another worktree, `rm -rf` of a worktree directory).
That is a filesystem question, and the filesystem can answer it without a
subprocess.
## Goals
- Remove the periodic all-repository `git worktree list` fan-out in steady
state.
- Preserve the current ≈30 s discovery latency for externally created, removed,
moved, pruned, locked/unlocked, and re-checked-out worktrees.
- Keep a bounded reconciliation so anything the cheap probe cannot observe still
converges.
- Change nothing for SSH repos, WSL-routed repos, folder workspaces, bare repos,
or hosts where the probe cannot resolve Git's admin layout.
- Fail open: any probe error must behave exactly like today (run the real scan).
## Non-goals
- Changing `RESOLVED_WORKTREE_CACHE_TTL_MS` or the whole-fleet snapshot shape.
- Changing the renderer-facing `worktrees:list` / `worktrees:listAll` IPC scan
cache in `src/main/ipc/worktrees.ts` (separate 5 s cache, invalidated by
`registerWorktreeChangeInvalidator`, not a polling source in the trace).
- Scoping `resolveWorktreeSelector` to a single repository. See
"Rejected alternatives".
- Adding a filesystem watcher per repository.
- Any wire/RPC/persisted-schema change.
## Design
Introduce a cheap, subprocess-free **Git worktree admin fingerprint** for a
local repository, and consult it before re-running a scan whose TTL has expired.
### Fingerprint inputs
`readRepoWorktreeAdminFingerprint(repoPath)` in
`src/main/runtime/repo-worktree-admin-fingerprint.ts` resolves the repo's Git
common directory without a subprocess (read `.git`; if it is a `gitdir:` file,
follow it and then its `commondir`; if `.git` is absent, treat `repoPath` as a
bare gitdir), then records:
| Input | External change it catches |
| --- | --- |
| sorted entry names of `<commonDir>/worktrees` | `worktree add`, `worktree remove`, `worktree prune` |
| existence of `repoPath` | main checkout deleted |
| `<commonDir>/packed-refs` mtime + size | a tip moved while its loose ref is packed away |
| `<commonDir>/reftable` mtime + size | a tip moved under the reftable backend |
| per checkout: `HEAD` contents | branch switch, detach (the detached oid is in HEAD itself) |
| per checkout: contents of the ref HEAD names | a plain `git commit`, `reset`, or `fetch` that moves the tip |
| per entry: `gitdir` contents | `worktree move`, `worktree repair` |
| per entry: `locked` presence | `worktree lock` / `unlock` |
| per entry: existence of the path named by `gitdir` | a worktree directory deleted with `rm -rf` (flips `prunable`) |
"per checkout" covers the main worktree and each linked worktree. Reading the
ref HEAD names is what makes an ordinary commit visible: committing rewrites
`refs/heads/<branch>` and leaves `HEAD` untouched, but moves the oid
`git worktree list --porcelain` prints. A symref target is only followed when it
is a relative path under `refs/`, so a hand-edited `HEAD` cannot steer the probe
outside the ref store.
Every input is a `stat`, `readdir`, or small `readFile` on an already-hot inode,
and the fingerprint depends on nothing but the repo path — no prior scan result
is threaded in. Per-repo fan-out is capped at 8 concurrent linked-worktree
probes, mirroring `SPARSE_CHECKOUT_DETECTION_CONCURRENCY`. A 10-repo fleet with
10 worktrees each costs a few hundred filesystem calls per 30 s window versus 10
process spawns; the spawns dominate by orders of magnitude, and process-table
churn was the reported symptom.
The fingerprint is a NUL-delimited string; any read failure yields `null`, which
the caller treats as "cannot prove unchanged".
### Cache decision
`listRepoWorktreesForResolution` gains one branch on the expired-TTL path:
```text
cached entry exists, same generation + runtimeKey, TTL expired
└─ probe eligible? (no connectionId, no wslDistro, fingerprint recorded)
├─ no → real scan (today's behaviour)
└─ yes → read fingerprint now
├─ null or different → real scan
├─ equal, last real scan < 5 min → extend TTL, no subprocess
└─ equal, last real scan ≥ 5 min → real scan (bounded reconcile)
```
`WORKTREE_SCAN_ADMIN_RECONCILE_INTERVAL_MS` is 5 min, matching the existing
`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS` precedent. A cached result whose scan
failed (`ok: false`) is never extended, so a transient Git failure still retries
on the 30 s TTL.
### Git version compatibility
Every path the probe reads is part of Git's on-disk layout well before the 2.25
baseline in [`git-compatibility.md`](./git-compatibility.md): `.git` as a
directory or `gitdir:` file, `commondir`, `worktrees/<name>/{HEAD,gitdir,locked}`,
`packed-refs`, and loose `refs/`. `reftable` arrived in 2.45; on older Git it
simply stats as missing, which is a stable value and therefore harmless. No new
Git command is introduced — this change only skips one.
Agent-scratch repos already carry a 5-minute scan TTL
(`WORKTREE_SCAN_AGENT_SCRATCH_TTL_MS`), which equals the reconciliation
interval, so the gate never fires for them and their behaviour is unchanged.
### Ordering
The fingerprint stored with a scan result is captured **before** the scan runs.
A mutation landing while the scan is in flight therefore leaves the stored
fingerprint stale-by-construction, so the next probe sees a difference and
rescans. Capturing after the scan would let that mutation be masked forever.
### Interaction with existing invalidation
Untouched. `invalidateWorktreeScanCacheForRepo` deletes the entry (fingerprint
included) and bumps the generation, so every event-driven path still forces a
real scan on the next read. The fingerprint only ever *extends* an entry that
the TTL alone would have refreshed.
## Freshness budget
| Change | Before | After |
| --- | --- | --- |
| Orca-initiated create/remove/rename/sparse/repo edit | immediate (event) | immediate (event) |
| SSH reconnect / provider generation bump | immediate (event) | immediate (event) |
| External `worktree add/remove/move/prune/lock` | ≤ 30 s | ≤ 30 s |
| External `git checkout` / `commit` / `reset` in any worktree | ≤ 30 s | ≤ 30 s |
| External `rm -rf <worktree>` | ≤ 30 s | ≤ 30 s |
| External sparse-checkout pattern edit | ≤ 30 s | ≤ 5 min |
| Packed/reftable tip moved within one mtime tick at an equal file size | ≤ 30 s | ≤ 5 min |
| SSH / WSL repos, folder workspaces | unchanged | unchanged |
The two regressions are bounded by the reconciliation interval and are both
changes Orca does not make itself.
### Main-thread cost
Both the scan and the probe are asynchronous, so neither "runs on the main
thread" in the naive sense — but they are not equally free there. Measured on
macOS with a 1 ms interval sampling event-loop lag while each ran 30 times
against a repo with 20 linked worktrees:
| | wall per call | main-thread stall per call | worst single stall |
| --- | --- | --- | --- |
| `git worktree list` | 18.66 ms | 2.69 ms | 3.02 ms |
| fingerprint probe | 1.66 ms | 0.01 ms | 0.04 ms |
`fs/promises` dispatches to libuv's threadpool, so ~99 % of the probe's latency
is off-thread. Spawning Git does not: `uv_spawn`, fd and pipe setup, and stdout
collection and decoding are real synchronous main-process work. Ten repos
refreshing together therefore cost ≈27 ms of event-loop stall per sweep before
this change and ≈0.1 ms after — this reduces main-thread pressure rather than
adding to it, which is why moving either side onto a worker thread would not
help.
### Residual risk
A repo registered at a UNC path (`\\wsl$\...`) but executed by the local Windows
Git runtime is still probed, because it is not WSL-routed from Orca's point of
view. The probe is correct there and strictly cheaper than the subprocess it
replaces, but its filesystem calls cross the 9p boundary like the existing
sparse-checkout probes already do.
## Measured effect
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts` drives the
reported steady state — 10 idle local repos, a caller polling at 1 Hz for 30
simulated minutes — and counts `git worktree list` invocations:
| | `git worktree list` per 30 min | per hour |
| --- | --- | --- |
| TTL only (before) | 600 | 1,200 |
| fingerprint gate (after) | 60 | 120 |
A 90 % reduction, with the remainder being the bounded reconciliation. Repos
with genuine external activity keep rescanning at the 30 s cadence because the
fingerprint flips.
Extrapolating to the original trace's shape (10 repos, 3 h 27 min): 4,272
`git worktree` invocations would become ≈427.
## Rejected alternatives
**Raise `WORKTREE_SCAN_CACHE_TTL_MS` to 5 min.** One line, same subprocess
reduction, but it degrades *every* external-change latency to 5 min, including
the common "I ran `git worktree add` in a terminal" case. The fingerprint buys
the same reduction without that regression.
**Per-repo `fs.watch` on `<commonDir>/worktrees`.** Lower latency, but adds
persistent watcher handles per repo, inherits recursive-watch platform
differences, and would need its own dormancy/rearm story. The existing watcher
infrastructure is scoped to workspace files; extending it to Git admin dirs is a
larger change with a worse risk profile for the same steady-state win.
**Scope `resolveWorktreeSelector` to the owning repository.** The original brief
asks for this. `listTerminals` already avoids the fan-out for explicit worktree
ids via `buildResolvedWorktreeFromId` +
`listKnownResolvedWorktreesForExplicitTarget`. Extending that to
`resolveWorktreeSelector` means splitting the highest-fan-in method in the
runtime (38 call sites) and rebuilding lineage projection for a repo subset —
material regression risk. Once the fingerprint gate lands, the remaining
fan-out cost for a targeted call is a batch of stats, not a subprocess, so the
gain no longer justifies the risk. Tracked as follow-up, not in this change.
## Test plan
`src/main/runtime/repo-worktree-admin-fingerprint.test.ts` (real temp dirs, real
`git` binary — a mocked filesystem would only restate the assumptions):
- stable across repeated reads with no change
- changes after `worktree add`, `worktree remove`, `worktree move`,
`worktree lock`, `checkout` in a linked worktree, `checkout` in the main
worktree, a commit in either, and `rm -rf` of a worktree directory
- still tracks a tip whose loose ref has been packed away by `git pack-refs`
- a linked worktree path and the main repo path produce the same fingerprint
- a bare repo resolves through its own gitdir and still tracks `worktree add`
- `null` for a non-Git directory and for a missing path
`src/main/runtime/worktree-scan-admin-fingerprint-gate.test.ts`:
- unchanged fingerprint suppresses the rescan past the 30 s TTL and re-arms it
- changed fingerprint rescans at the 30 s TTL
- the 5-minute reconciliation forces a rescan while the fingerprint is unchanged
- `notifyBranchRenamed` (event invalidation) still forces an immediate rescan
- a `null` fingerprint (probe failure) falls back to scanning
- SSH repos never consult the probe
- a failed scan is never extended
- concurrent callers share one probe and one scan
- the 1 Hz / 10-repo workload measurement above
@@ -10,6 +10,11 @@ export default function MobileFilePreviewRoute() {
source?: string | string[]
absolutePath?: string | string[]
grantId?: string | string[]
terminal?: string | string[]
pathText?: string | string[]
cwd?: string | string[]
nativeChatTab?: string | string[]
nativeChatSession?: string | string[]
line?: string | string[]
column?: string | string[]
name?: string | string[]
+5 -5
View File
@@ -25,7 +25,7 @@ import { loadHosts, updateLastConnected } from '../../../src/transport/host-stor
import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle'
import {
useHostClient,
useCloseHost,
useForgetHostClient,
useForceReconnect
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
@@ -96,7 +96,7 @@ import {
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../../../src/worktree/workspace-list-picker-options'
import type { RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/worktree/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
function isErrorVerdict(v: ConnectionVerdict): boolean {
@@ -148,7 +148,7 @@ export function HostScreen({
const repoMetadataFetchedAtRef = useRef(0)
const newWorktreeModalRef = useRef<{ open: () => void }>(null)
const newWorktreeModalVisibleRef = useRef(false)
const closeHostClient = useCloseHost()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const [worktrees, setWorktrees] = useState<Worktree[]>(initialCache ?? [])
const [worktreesLoaded, setWorktreesLoaded] = useState(initialCache != null)
@@ -636,14 +636,14 @@ export function HostScreen({
return
}
try {
await removeHostAndCloseClient(hostId, closeHostClient)
await removeHostAndCloseClient(hostId, forgetHostClient)
leaveHost()
} catch {
// Why: removal can fail while still paired; re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
}, [hostId, leaveHost, closeHostClient])
}, [hostId, leaveHost, forgetHostClient])
const navigateFromHostList = useCallback(
(target: string) => {
+17 -1
View File
@@ -277,7 +277,8 @@ import {
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
import type { DiffComment } from '../../../../../src/shared/diff-comment-types'
import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types'
import type {
DiffCommentActions,
DiffNotesDelivery,
@@ -917,6 +918,9 @@ export default function SessionScreen() {
} | null>(null)
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
const subscribingHandlesRef = useRef<Set<string>>(new Set())
// Why: a lease-only subscribe never renders, so the reconciler needs to tell it apart
// from a stream that does — an uncovered handle holding one is a blank terminal.
const leaseOnlyHandlesRef = 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).
@@ -1233,6 +1237,7 @@ export default function SessionScreen() {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
leaseOnlyHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale.
@@ -1265,6 +1270,7 @@ export default function SessionScreen() {
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
leaseOnlyHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
@@ -1331,6 +1337,11 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.add(handle)
if (covered) {
leaseOnlyHandlesRef.current.add(handle)
} else {
leaseOnlyHandlesRef.current.delete(handle)
}
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
subscribeSeqRef.current.set(handle, seq)
diagnostics.streamArmed(handle, seq, viewportRef.current)
@@ -1519,6 +1530,7 @@ export default function SessionScreen() {
streamRevision: coveredStreamRevision,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
leaseOnlyRef: leaseOnlyHandlesRef,
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
@@ -3085,6 +3097,10 @@ export default function SessionScreen() {
hostId,
worktreeId,
worktreeName: routeWorktreeName,
nativeChatSessionId:
activeSessionTab?.type === 'terminal'
? (activeSessionTab.agentStatus?.providerSession?.id ?? null)
: null,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
+9 -9
View File
@@ -119,11 +119,11 @@ import {
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../../src/shared/github-project-group-sort'
} from '../../../../src/shared/github/project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
} from '../../../../src/shared/github/project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
@@ -149,19 +149,19 @@ import {
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
ProviderCheckSummary,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
} from '../../../../src/shared/types'
ProviderCheckSummary
} from '../../../../src/shared/github/pull-request-types'
import type { PersistedTrustedOrcaHooks } from '../../../../src/shared/orca-yaml-hook-types'
import type { BaseRefSearchResult } from '../../../../src/shared/repo-types'
import type { TuiAgent } from '../../../../src/shared/tui-agent'
import type { SparsePreset } from '../../../../src/shared/worktree/create-types'
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
import {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
} from '../../../../src/shared/github-project-identity'
} from '../../../../src/shared/github/project-identity'
type RepoSummary = {
id: string
+10 -4
View File
@@ -25,7 +25,12 @@ import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home
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 { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import {
useDisconnectHostClient,
useForceReconnect,
useForgetHostClient,
usePrimeHosts
} from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import {
resolveHomeHostConnectionState,
@@ -254,7 +259,8 @@ export default function HomeScreen() {
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
)
const closeHostClient = useCloseHost()
const disconnectHostClient = useDisconnectHostClient()
const forgetHostClient = useForgetHostClient()
const forceReconnectHost = useForceReconnect()
const primeHosts = usePrimeHosts()
// Why: prime the cache with loaded HostProfiles to avoid a second serialized Keychain pass (multi-second connect latency) on cold start.
@@ -643,7 +649,7 @@ export default function HomeScreen() {
}
const hostToRemove = confirmRemove
try {
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
await removeHostAndCloseClient(hostToRemove.id, forgetHostClient)
setConfirmRemove(null)
setHostCatalog(await loadHostCatalog())
} catch {
@@ -949,7 +955,7 @@ export default function HomeScreen() {
: false,
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onDisconnect: disconnectHostClient,
onEdit: openMobileHostEdit,
onRemove: (host) => setConfirmRemove(host)
})}
+4 -6
View File
@@ -9,7 +9,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import {
@@ -28,7 +28,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairConfirmScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [status, setStatus] = useState<Status>('awaiting-confirm')
@@ -112,10 +112,8 @@ export default function PairConfirmScreen() {
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
// Refresh any cached client from the newly persisted pairing profile.
refreshHostClient(hostId)
const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
+4 -6
View File
@@ -18,7 +18,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { useRefreshHostClient } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -47,7 +47,7 @@ function Step({ number, text }: { number: number; text: string }) {
export default function PairScanScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const refreshHostClient = useRefreshHostClient()
const insets = useSafeAreaInsets()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
@@ -156,10 +156,8 @@ export default function PairScanScreen() {
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
// Refresh any cached client from the newly persisted pairing profile.
refreshHostClient(hostId)
const onboardingSteps = await loadMobileOnboardingSteps()
if (!mountedRef.current) {
return
+1 -1
View File
@@ -7,7 +7,7 @@ import {
applyTerminalQuickCommandMutation,
type TerminalQuickCommandMutation
} from '../../src/shared/terminal-quick-commands'
import type { TerminalQuickCommand } from '../../src/shared/types'
import type { TerminalQuickCommand } from '../../src/shared/terminal-quick-command-types'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
@@ -7,11 +7,11 @@ import { colors } from '../theme/mobile-theme'
import { useHostClient } from '../transport/client-context'
import type { RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { readMobileRuntimeHostPlatform } from '../transport/mobile-runtime-host-platform'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
@@ -1,6 +1,6 @@
import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path'
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id'
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree/id'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import type { Worktree } from '../worktree/workspace-list-types'
@@ -0,0 +1,91 @@
import { Platform, StyleSheet, Text, TextInput, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { compactMobileBrowserFileAddress } from './browser-url'
type Props = {
disabled: boolean
focused: boolean
onBlur: () => void
onChangeText: (value: string) => void
onFocus: () => void
onSubmit: () => void
value: string
}
export function MobileBrowserAddressField({
disabled,
focused,
onBlur,
onChangeText,
onFocus,
onSubmit,
value
}: Props): React.JSX.Element {
const fileLabel = focused ? null : compactMobileBrowserFileAddress(value)
const selection = focused ? undefined : { start: 0, end: 0 }
return (
<View style={styles.field}>
<TextInput
style={styles.input}
value={value}
onChangeText={onChangeText}
onFocus={onFocus}
onBlur={onBlur}
onSubmitEditing={onSubmit}
selectTextOnFocus
selection={selection}
autoCapitalize="none"
autoCorrect={false}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
numberOfLines={1}
returnKeyType="go"
placeholder="URL"
placeholderTextColor={colors.textMuted}
editable={!disabled}
/>
{fileLabel ? (
<View pointerEvents="none" style={styles.fileLabelHost}>
<Text style={styles.fileLabel} numberOfLines={1} ellipsizeMode="middle">
{fileLabel}
</Text>
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
field: {
flex: 1,
minWidth: 0,
height: 28
},
input: {
flex: 1,
minWidth: 0,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
fontSize: 12,
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
fileLabelHost: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
paddingHorizontal: spacing.sm,
borderRadius: radii.input,
backgroundColor: colors.bgRaised
},
fileLabel: {
color: colors.textPrimary,
fontSize: 12,
lineHeight: 16,
fontFamily: typography.monoFamily
}
})
+11 -36
View File
@@ -9,7 +9,6 @@ import {
Image,
PanResponder,
PixelRatio,
Platform,
Pressable,
StyleSheet,
Text,
@@ -54,6 +53,7 @@ import {
type BrowserZoomState
} from './browser-touch-geometry'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
export type MobileBrowserTab = {
@@ -140,7 +140,7 @@ export function MobileBrowserPane({
onToast
}: MobileBrowserPaneProps) {
const [browserViewMode, setBrowserViewMode] = useState<MobileBrowserViewMode>(() =>
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
@@ -265,6 +265,10 @@ export function MobileBrowserPane({
resetBrowserZoomState()
}, [resetBrowserZoomState, tab.browserPageId, tab.url])
useEffect(() => {
setBrowserViewMode(getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId, tab.url))
}, [tab.browserPageId, tab.url, worktreeId])
const pageParams = useCallback(() => {
if (!tab.browserPageId) {
return null
@@ -1018,10 +1022,6 @@ export function MobileBrowserPane({
)
const controlsDisabled = !client || !tab.browserPageId || screencastSupported !== true
const addressSelection = useMemo(
() => (addressFocused ? undefined : { start: 0, end: 0 }),
[addressFocused]
)
const goBack = useCallback(() => {
if (controlsDisabled || !tab.canGoBack) {
return
@@ -1045,8 +1045,7 @@ export function MobileBrowserPane({
if (browserViewMode === mode) {
return
}
// Why: browser panes can remount during normal tab/workspace navigation;
// keep a page-scoped choice while new browser pages still default to Web.
// Why: preserve explicit page-scoped choices across normal browser pane remounts.
saveMobileBrowserViewMode(worktreeId, tab.browserPageId, mode)
setBrowserViewMode(mode)
resetBrowserZoomState()
@@ -1104,23 +1103,14 @@ export function MobileBrowserPane({
>
<RefreshCw size={15} color={buttonColor(!controlsDisabled)} />
</MobileBrowserToolbarIconButton>
<TextInput
style={styles.addressInput}
<MobileBrowserAddressField
value={addressValue}
onChangeText={setAddressValue}
onFocus={() => setAddressFocused(true)}
onBlur={() => setAddressFocused(false)}
onSubmitEditing={() => void navigateToAddress()}
selectTextOnFocus
selection={addressSelection}
autoCapitalize="none"
autoCorrect={false}
keyboardType={Platform.OS === 'ios' ? 'url' : 'default'}
numberOfLines={1}
returnKeyType="go"
placeholder="URL"
placeholderTextColor={colors.textMuted}
editable={!controlsDisabled}
onSubmit={() => void navigateToAddress()}
focused={addressFocused}
disabled={controlsDisabled}
/>
<MobileBrowserViewModeSwitch
disabled={controlsDisabled}
@@ -1494,21 +1484,6 @@ const styles = StyleSheet.create({
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
addressInput: {
flex: 1,
minWidth: 0,
height: 28,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
paddingHorizontal: spacing.sm,
paddingVertical: 0,
fontSize: 12,
lineHeight: 16,
includeFontPadding: false,
textAlignVertical: 'center',
fontFamily: typography.monoFamily
},
viewport: {
flex: 1,
minHeight: 0,
@@ -1,5 +1,6 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { Pressable, StyleSheet, View } from 'react-native'
import { Monitor, Smartphone, type LucideIcon } from 'lucide-react-native'
import { colors, radii } from '../theme/mobile-theme'
import type { MobileBrowserViewMode } from './browser-screencast-request'
type Props = {
@@ -8,9 +9,9 @@ type Props = {
onChange: (mode: MobileBrowserViewMode) => void
}
const VIEW_MODES: { id: MobileBrowserViewMode; label: string }[] = [
{ id: 'web', label: 'Web' },
{ id: 'mobile', label: 'Mobile' }
const VIEW_MODES: { id: MobileBrowserViewMode; label: string; icon: LucideIcon }[] = [
{ id: 'web', label: 'Web', icon: Monitor },
{ id: 'mobile', label: 'Mobile', icon: Smartphone }
]
export function MobileBrowserViewModeSwitch({
@@ -23,6 +24,7 @@ export function MobileBrowserViewModeSwitch({
{VIEW_MODES.map((mode) => (
<ViewModeButton
key={mode.id}
Icon={mode.icon}
label={mode.label}
selected={value === mode.id}
disabled={disabled}
@@ -34,11 +36,13 @@ export function MobileBrowserViewModeSwitch({
}
function ViewModeButton({
Icon,
disabled,
label,
onPress,
selected
}: {
Icon: LucideIcon
disabled?: boolean
label: string
onPress: () => void
@@ -58,7 +62,7 @@ function ViewModeButton({
accessibilityState={{ selected, disabled }}
accessibilityLabel={`Show ${label.toLowerCase()} website view`}
>
<Text style={[styles.buttonText, selected && styles.buttonTextSelected]}>{label}</Text>
<Icon size={14} color={selected ? colors.bgBase : colors.textSecondary} />
</Pressable>
)
}
@@ -74,11 +78,10 @@ const styles = StyleSheet.create({
},
button: {
minHeight: 24,
minWidth: 52,
width: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
paddingHorizontal: spacing.sm
borderRadius: radii.button
},
buttonPressed: {
backgroundColor: colors.borderSubtle
@@ -86,14 +89,6 @@ const styles = StyleSheet.create({
buttonSelected: {
backgroundColor: colors.textPrimary
},
buttonText: {
color: colors.textSecondary,
fontSize: 12,
fontWeight: '600'
},
buttonTextSelected: {
color: colors.bgBase
},
disabled: {
opacity: 0.35
}
+17 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { normalizeBrowserUrl } from './browser-url'
import { compactMobileBrowserFileAddress, normalizeBrowserUrl } from './browser-url'
describe('normalizeBrowserUrl', () => {
it('keeps localhost-style addresses as http URLs', () => {
@@ -14,3 +14,19 @@ describe('normalizeBrowserUrl', () => {
expect(normalizeBrowserUrl('github.com/stablyai/orca')).toBe('https://github.com/stablyai/orca')
})
})
describe('compactMobileBrowserFileAddress', () => {
it('shows the decoded filename for local and Windows-style file URLs', () => {
expect(compactMobileBrowserFileAddress('file:///Users/me/reports/status%20report.html')).toBe(
'file: …/status report.html'
)
expect(compactMobileBrowserFileAddress('file:///C:/Users/me/report.htm')).toBe(
'file: …/report.htm'
)
})
it('does not compact ordinary or malformed URLs', () => {
expect(compactMobileBrowserFileAddress('https://example.com/report.html')).toBeNull()
expect(compactMobileBrowserFileAddress('not a url')).toBeNull()
})
})
+21
View File
@@ -31,6 +31,27 @@ export function displayBrowserUrl(value: string | null | undefined): string {
return isBlankBrowserUrl(trimmed) ? 'about:blank' : trimmed
}
export function compactMobileBrowserFileAddress(value: string): string | null {
try {
const url = new URL(value)
if (url.protocol !== 'file:') {
return null
}
const segments = url.pathname.split('/').filter(Boolean)
const encodedFilename = segments.at(-1)
if (!encodedFilename) {
return 'file:'
}
try {
return `file: …/${decodeURIComponent(encodedFilename)}`
} catch {
return `file: …/${encodedFilename}`
}
} catch {
return null
}
}
export function isBlankBrowserUrl(value: string | null | undefined): boolean {
const trimmed = value?.trim() ?? ''
return !trimmed || trimmed === 'about:blank' || trimmed.startsWith('data:text/html')
@@ -15,6 +15,28 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', null)).toBe('web')
})
it('defaults local HTML reports to mobile view', () => {
expect(
getInitialMobileBrowserViewMode(
'folder:workspace-1',
'page-1',
'file:///Users/me/report%20one.HTML?generated=1'
)
).toBe('mobile')
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///C:/Users/me/report.htm')
).toBe('mobile')
})
it('keeps remote HTML URLs and non-HTML files in web view', () => {
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'https://example.com/report.html')
).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2', 'file:///repo/notes.txt')).toBe(
'web'
)
})
it('restores the last mode for the same browser page after remount', () => {
saveMobileBrowserViewMode('worktree-1', 'page-1', 'mobile')
@@ -22,4 +44,12 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
})
it('preserves an explicit web choice for a local HTML report', () => {
saveMobileBrowserViewMode('worktree-1', 'page-1', 'web')
expect(
getInitialMobileBrowserViewMode('worktree-1', 'page-1', 'file:///repo/report.html')
).toBe('web')
})
})
@@ -5,13 +5,23 @@ const browserViewModeByPageKey = new Map<string, MobileBrowserViewMode>()
export function getInitialMobileBrowserViewMode(
worktreeId: string,
browserPageId: string | null
browserPageId: string | null,
url = 'about:blank'
): MobileBrowserViewMode {
const pageKey = makeBrowserViewModePageKey(worktreeId, browserPageId)
if (!pageKey) {
return defaultMobileBrowserViewMode(url)
}
return browserViewModeByPageKey.get(pageKey) ?? defaultMobileBrowserViewMode(url)
}
function defaultMobileBrowserViewMode(url: string): MobileBrowserViewMode {
try {
const parsed = new URL(url)
return parsed.protocol === 'file:' && /\.html?$/i.test(parsed.pathname) ? 'mobile' : 'web'
} catch {
return 'web'
}
return browserViewModeByPageKey.get(pageKey) ?? 'web'
}
export function saveMobileBrowserViewMode(
+1 -1
View File
@@ -1,7 +1,7 @@
import { Image, StyleSheet, Text, View } from 'react-native'
import type { ImageSourcePropType } from 'react-native'
import { Terminal } from 'lucide-react-native'
import type { TuiAgent } from '../../../src/shared/types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
@@ -1,7 +1,7 @@
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native'
import { RefreshCw } from 'lucide-react-native'
import type { RefObject } from 'react'
import type { DiffComment } from '../../../src/shared/types'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import { colors } from '../theme/mobile-theme'
import { MobileDiffReviewLine } from './MobileDiffReviewLine'
import type {
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { KeyboardAvoidingView, Platform, Pressable, Text, TextInput, View } from 'react-native'
import { Check, Copy, FileText, Plus, Send, Trash2, X } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import { colors } from '../theme/mobile-theme'
import type { ActionSheetAction } from './ActionSheetModal'
import { ActionSheetModal } from './ActionSheetModal'
@@ -1,6 +1,6 @@
import { Pressable, Text, View } from 'react-native'
import { ArrowDown, ArrowUp } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import { colors } from '../theme/mobile-theme'
import type { MobileDiffReviewQueueItem } from '../session/mobile-diff-review-queue'
import { MOBILE_GIT_STATUS_LABELS } from '../source-control/mobile-git-status'
@@ -1,6 +1,6 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { MessageSquare } from 'lucide-react-native'
import type { DiffComment } from '../../../src/shared/types'
import type { DiffComment } from '../../../src/shared/diff-comment-types'
import type { MobileDiffLine } from '../session/mobile-diff-lines'
import type { MobileHighlightedDiffLine } from '../session/mobile-file-syntax'
import { mobileDiffLineNumber, mobileDiffLinePrefix } from '../source-control/mobile-diff-format'
@@ -44,18 +44,32 @@ vi.mock('./MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
vi.mock('./TaskProviderLogo', () => ({ TaskProviderLogo: 'TaskProviderLogo' }))
import { setCachedRepos } from '../cache/repo-cache'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import { NewWorktreeModal } from './NewWorktreeModal'
const repos = [{ id: 'repo-1', displayName: 'orca', path: '/src/orca', kind: 'git' }]
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
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)
const repos = [
{
id: 'repo-1',
displayName: 'orca',
path: '/src/orca',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
function pickerItems(
renderer: ReactTestRenderer,
title: string
): { label: string; detail: string }[] {
const pickers = renderer.root.findAll((node) => node.type === 'PickerListDrawer')
const picker = pickers.find((node) => node.props.title === title)
return picker?.props.items ?? []
}
describe('NewWorktreeModal repo list', () => {
let renderer: ReactTestRenderer | null = null
describe('NewWorktreeModal project targets', () => {
let renderer: ReactTestRenderer
beforeEach(() => {
setCachedRepos('host-1', repos)
@@ -63,7 +77,6 @@ describe('NewWorktreeModal repo list', () => {
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
@@ -71,6 +84,9 @@ describe('NewWorktreeModal repo list', () => {
if (method === 'repo.list') {
return Promise.reject(new Error('connection closed'))
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
@@ -92,6 +108,60 @@ describe('NewWorktreeModal repo list', () => {
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(repoPickerNames(renderer)).toEqual(['orca'])
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
])
})
it('groups same-name checkouts under one project with separate run targets', async () => {
const listedRepos = [
...repos,
{
id: 'repo-2',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
kind: 'git',
upstream: { owner: 'stablyai', repo: 'orca' }
}
]
const client = {
sendRequest: vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.resolve({ ok: true, result: { repos: listedRepos } })
}
if (method === 'status.get') {
return Promise.resolve({ ok: true, result: { hostPlatform: 'darwin' } })
}
return new Promise(() => {})
})
} as unknown as RpcClient
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
hostId: 'host-1',
onCreated: () => {},
onClose: () => {}
})
)
await Promise.resolve()
})
expect(pickerItems(renderer, 'Project')).toEqual([
expect.objectContaining({ label: 'orca', detail: 'stablyai/orca' })
])
expect(pickerItems(renderer, 'Run on')).toEqual([
expect.objectContaining({ label: LOCAL_HOST_LABEL, detail: '/src/orca' }),
expect.objectContaining({
label: 'SSH · build-server',
detail: '/home/dev/orca'
})
])
})
})
+67 -59
View File
@@ -10,7 +10,7 @@ import {
ActivityIndicator,
Keyboard
} from 'react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import { ChevronDown, ChevronUp, Monitor } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
@@ -29,8 +29,11 @@ import {
type SetupHookTrust
} from '../tasks/setup-hook-trust'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/orca-yaml-hook-types'
import type { Repo as SharedRepo } from '../../../src/shared/repo-types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import {
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
@@ -63,17 +66,26 @@ import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
import { NewWorktreeProjectTargetFields } from './NewWorktreeProjectTargetFields'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
type Repo = {
id: string
displayName: string
path: string
badgeColor?: string
connectionId?: string | null
kind?: 'git' | 'folder'
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}
type Repo = Pick<SharedRepo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<
SharedRepo,
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
| 'kind'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
>
>
type SetupDecision = 'inherit' | 'run' | 'skip'
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
@@ -200,10 +212,8 @@ function NewWorktreeModalContent({
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const { tasksSupported, getWorktreeCreateCutoverSupport } = useNewWorktreeRuntimeCapabilities(
client,
visible
)
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
useNewWorktreeRuntimeCapabilities(client, visible)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -694,10 +704,17 @@ function NewWorktreeModalContent({
isMobileTuiAgentEnabled(agent.id, runtimeSettings?.disabledTuiAgents)
)
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
const repoPickerItems = useMemo(
() => repos.map((repo) => ({ id: repo.id, label: repo.displayName, repo })),
[repos]
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
[hostPlatform, repos, selectedProjectId]
)
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
: null
function prepareSelectionPickerOpen(): void {
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
@@ -785,10 +802,7 @@ function NewWorktreeModalContent({
>
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
Pick a repository and agent to spin up a new workspace.
</Text>
<Text style={styles.title}>Create worktree</Text>
</View>
{loading ? (
@@ -797,33 +811,23 @@ function NewWorktreeModalContent({
</View>
) : repos.length === 0 ? (
<View style={styles.loadingContainer}>
<Text style={styles.emptyText}>No repositories found</Text>
<Text style={styles.emptyText}>No projects found</Text>
</View>
) : (
<>
<View style={styles.field}>
<Text style={styles.label}>Repository</Text>
<Pressable
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
transitionDrawer('repo')
}}
>
{selectedRepo ? (
<View
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
/>
) : null}
<Text
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selectedRepo?.displayName ?? 'Select a repository'}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<NewWorktreeProjectTargetFields
project={selectedProject}
runTarget={selectedRunTarget}
projectBadgeColor={selectedRepo ? repoBadgeColor(selectedRepo) : null}
onOpenProject={() => {
prepareSelectionPickerOpen()
transitionDrawer('project')
}}
onOpenRunTarget={() => {
prepareSelectionPickerOpen()
transitionDrawer('runTarget')
}}
/>
<SmartWorkspaceSourceField
composer={composer}
@@ -893,7 +897,7 @@ function NewWorktreeModalContent({
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
<Text style={styles.fieldButtonText} numberOfLines={1}>
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
{sshGate.requiresConnection ? 'Connect target first' : selectedAgent.label}
</Text>
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
@@ -995,7 +999,7 @@ function NewWorktreeModalContent({
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.createText}>
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
{sshGate.requiresConnection ? 'Connect target' : 'Create worktree'}
</Text>
)}
</Pressable>
@@ -1024,10 +1028,10 @@ function NewWorktreeModalContent({
/>
<PickerListDrawer
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
visible={visible && drawerView === 'project'}
title="Project"
items={projectPickerItems}
selectedId={selectedProjectId ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
@@ -1035,6 +1039,16 @@ function NewWorktreeModalContent({
}}
/>
<PickerListDrawer
visible={visible && drawerView === 'runTarget'}
title="Run on"
items={runTargetPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={() => <Monitor size={16} color={colors.textMuted} />}
/>
<PickerListDrawer
visible={visible && drawerView === 'agent'}
title="Agent"
@@ -1071,11 +1085,6 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: colors.textPrimary
},
subtitle: {
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
loadingContainer: {
paddingVertical: spacing.xl,
alignItems: 'center'
@@ -1109,7 +1118,6 @@ const styles = StyleSheet.create({
borderColor: colors.borderSubtle
},
fieldButtonText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
@@ -0,0 +1,111 @@
import { View, Text, Pressable, StyleSheet, Platform } from 'react-native'
import { ChevronDown, Monitor } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Selection = { label: string; detail?: string }
export function NewWorktreeProjectTargetFields({
project,
runTarget,
projectBadgeColor,
onOpenProject,
onOpenRunTarget
}: {
project: Selection | null
runTarget: Selection | null
projectBadgeColor: string | null
onOpenProject: () => void
onOpenRunTarget: () => void
}) {
return (
<>
<View style={styles.field}>
<Text style={styles.label}>Project</Text>
<Pressable style={styles.fieldButton} onPress={onOpenProject}>
{projectBadgeColor ? (
<View style={[styles.projectDot, { backgroundColor: projectBadgeColor }]} />
) : null}
<SelectionCopy selection={project} placeholder="Select a project" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
<View style={styles.field}>
<Text style={styles.label}>Run on</Text>
<Pressable style={styles.fieldButton} onPress={onOpenRunTarget}>
<Monitor size={14} color={colors.textMuted} />
<SelectionCopy selection={runTarget} placeholder="Select a run target" />
<ChevronDown size={14} color={colors.textMuted} />
</Pressable>
</View>
</>
)
}
function SelectionCopy({
selection,
placeholder
}: {
selection: Selection | null
placeholder: string
}) {
return (
<View style={styles.fieldButtonCopy}>
<Text
style={[styles.fieldButtonText, !selection && styles.fieldButtonPlaceholder]}
numberOfLines={1}
>
{selection?.label ?? placeholder}
</Text>
{selection?.detail ? (
<Text style={styles.fieldButtonDetail} numberOfLines={1}>
{selection.detail}
</Text>
) : null}
</View>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
fieldButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm
},
projectDot: {
width: 8,
height: 8,
borderRadius: 999
},
fieldButtonCopy: {
flex: 1,
minWidth: 0
},
fieldButtonText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
fieldButtonDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
fieldButtonPlaceholder: {
color: colors.textMuted
}
})
+26 -9
View File
@@ -6,7 +6,9 @@ import { colors, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
type Props<T extends { id: string; label: string }> = {
type PickerListItem = { id: string; label: string; detail?: string }
type Props<T extends PickerListItem> = {
visible: boolean
title: string
items: T[]
@@ -16,7 +18,7 @@ type Props<T extends { id: string; label: string }> = {
renderIcon?: (item: T) => ReactNode
}
export function PickerListDrawer<T extends { id: string; label: string }>({
export function PickerListDrawer<T extends PickerListItem>({
visible,
title,
items,
@@ -87,12 +89,19 @@ export function PickerListDrawer<T extends { id: string; label: string }>({
onPress={() => closeThenSelect(item)}
>
{renderIcon?.(item)}
<Text
style={[styles.itemText, selected && styles.itemTextSelected]}
numberOfLines={1}
>
{item.label}
</Text>
<View style={styles.itemCopy}>
<Text
style={[styles.itemText, selected && styles.itemTextSelected]}
numberOfLines={1}
>
{item.label}
</Text>
{item.detail ? (
<Text style={styles.itemDetail} numberOfLines={1}>
{item.detail}
</Text>
) : null}
</View>
{selected && <Check size={14} color={colors.textPrimary} />}
</Pressable>
)
@@ -142,10 +151,18 @@ const styles = StyleSheet.create({
backgroundColor: colors.bgRaised
},
itemText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
itemCopy: {
flex: 1,
minWidth: 0
},
itemDetail: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginTop: 1
},
itemTextSelected: {
fontWeight: '600'
}
@@ -3,6 +3,8 @@
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
import { parseFileLinkLocation } from '../../../src/shared/file-link-location'
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
@@ -101,8 +103,6 @@ function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
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.
@@ -112,14 +112,14 @@ export function splitFilePathLineSuffix(pathText: string): {
line: number | null
column: number | null
} {
const match = LINE_SUFFIX_PATTERN.exec(pathText)
if (!match) {
const parsed = parseFileLinkLocation(pathText)
if (!parsed || (parsed.line === null && parsed.column === null)) {
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
path: parsed.pathText,
line: parsed.line,
column: parsed.column
}
}
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { TuiAgent } from '../../../src/shared/types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents'
// Agents that render a hand-authored glyph in MobileAgentIcon and therefore
@@ -1,5 +1,5 @@
import type { ImageSourcePropType } from 'react-native'
import type { TuiAgent } from '../../../src/shared/types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
// Why: mobile previously rendered these agent icons from Google's favicon
// service, which is unreachable in some regions (e.g. mainland China) and
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { getLocalExecutionHostLabel } from '../../../src/shared/execution-host'
import {
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
} from './new-workspace-project-targets'
const LOCAL_HOST_LABEL = getLocalExecutionHostLabel('darwin')
describe('new workspace project targets', () => {
it('groups local and SSH checkouts of the same project', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const options = buildNewWorkspaceProjectOptions([
{ id: 'local', displayName: 'orca', path: '/src/orca', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
])
expect(options).toHaveLength(1)
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('shows the provider slug recovered from canonical git identity', () => {
const options = buildNewWorkspaceProjectOptions([
{
id: 'local',
displayName: 'orca',
path: '/src/orca',
gitRemoteIdentity: {
canonicalKey: 'github.com/stablyai/orca',
remoteName: 'origin',
remoteUrl: 'git@github.com:stablyai/orca.git'
}
}
])
expect(options[0]).toMatchObject({ label: 'orca', detail: 'stablyai/orca' })
})
it('labels local, SSH, and paired runtime targets', () => {
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: '/src/orca' }, 'darwin')
).toEqual({ label: LOCAL_HOST_LABEL, detail: '/src/orca' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' })
).toEqual({ label: 'This computer', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({ id: 'local', displayName: 'orca', path: 'C:\\src\\orca' }, 'win32')
).toEqual({ label: 'Local Windows', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'ssh',
displayName: 'orca',
path: 'C:\\src\\orca',
executionHostId: 'ssh:Windows%20VM'
})
).toEqual({ label: 'SSH · Windows VM', detail: 'C:\\src\\orca' })
expect(
getNewWorkspaceRunTarget({
id: 'runtime',
displayName: 'orca',
path: '/src/orca',
executionHostId: 'runtime:devbox'
})
).toEqual({ label: 'Remote · devbox', detail: '/src/orca' })
})
it('shows one target per host when the project has multiple local worktrees', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const repos = [
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
{ id: 'local-b', displayName: 'orca-b', path: '/src/orca-b', upstream },
{
id: 'ssh',
displayName: 'orca',
path: '/home/dev/orca',
connectionId: 'build-server',
upstream
}
]
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
expect.objectContaining({ id: 'local-a', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
expect.objectContaining({ id: 'ssh', label: 'SSH · build-server', detail: '/home/dev/orca' })
])
})
})
@@ -0,0 +1,106 @@
import type { Repo } from '../../../src/shared/repo-types'
import {
getExecutionHostLabel,
getLocalExecutionHostLabel,
getRepoExecutionHostId,
parseExecutionHostId
} from '../../../src/shared/execution-host'
import {
getProjectIdentityKey,
getProjectProviderIdentity
} from '../../../src/shared/project-host-setup-projection'
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
>
export type NewWorkspaceProjectOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail?: string
repo: TRepo
}
export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
id: string
label: string
detail: string
repo: TRepo
}
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[]
): NewWorkspaceProjectOption<TRepo>[] {
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
const hostIdsByProject = new Map<string, Set<string>>()
for (const repo of repos) {
const id = getProjectIdentityKey(repo)
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
hostIds.add(getRepoExecutionHostId(repo))
hostIdsByProject.set(id, hostIds)
if (!options.has(id)) {
options.set(id, {
id,
label: repo.displayName,
repo
})
}
}
return [...options.values()].map((option) => {
const providerIdentity = getProjectProviderIdentity(option.repo)
const providerDetail = providerIdentity
? `${providerIdentity.owner}/${providerIdentity.repo}`
: ''
const hostCount = hostIdsByProject.get(option.id)?.size ?? 0
const detail = providerDetail || (hostCount > 1 ? `${hostCount} hosts configured` : '')
return detail ? { ...option, detail } : option
})
}
export function getNewWorkspaceRunTarget(
repo: WorkspaceRepo,
localPlatform: NodeJS.Platform | null = null
): {
label: string
detail: string
} {
const hostId = getRepoExecutionHostId(repo)
const host = parseExecutionHostId(hostId)
const hostLabel = getExecutionHostLabel(hostId)
if (host?.kind === 'ssh') {
return { label: `SSH · ${hostLabel}`, detail: repo.path }
}
if (host?.kind === 'runtime') {
return { label: `Remote · ${hostLabel}`, detail: repo.path }
}
return {
label: localPlatform ? getLocalExecutionHostLabel(localPlatform) : 'This computer',
detail: repo.path
}
}
export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
}
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
for (const repo of repos) {
if (getProjectIdentityKey(repo) !== projectId) {
continue
}
const hostId = getRepoExecutionHostId(repo)
if (!options.has(hostId)) {
options.set(hostId, {
id: repo.id,
...getNewWorkspaceRunTarget(repo, localPlatform),
repo
})
}
}
return [...options.values()]
}
@@ -1,4 +1,4 @@
import type { TuiAgent } from '../../../src/shared/types'
import type { TuiAgent } from '../../../src/shared/tui-agent'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection'
@@ -30,7 +30,7 @@ describe('resolveNewWorktreeFormSheetVisible', () => {
expect(
resolveNewWorktreeFormSheetVisible({
modalVisible: true,
drawerView: 'repo',
drawerView: 'project',
formPinnedUnderSource: false
})
).toBe(false)
@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { GitMerge, Link2Off } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/types'
import type { GitHubPRMergeMethod, PRInfo } from '../../../../src/shared/github/pull-request-types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
@@ -1,6 +1,6 @@
import { ActivityIndicator, ScrollView, Text, View } from 'react-native'
import { colors } from '../../theme/mobile-theme'
import type { PRCheckRunDetails } from '../../../../src/shared/types'
import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types'
import { presentCheckDetail, type CheckDetailJob } from './pr-check-detail-content'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { ActivityIndicator, Linking, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight, ExternalLink, RotateCw, Sparkles } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { PRCheckDetail } from '../../../../src/shared/types'
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
import type { RpcClient } from '../../transport/rpc-client'
import { fetchPRCheckDetails, type GitHubPrRepoSlug } from '../../session/github-pr-rpc'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
@@ -1,7 +1,11 @@
import { memo, useState } from 'react'
import { Image, Linking, Pressable, Text, View } from 'react-native'
import { Check, CornerDownRight, ExternalLink, Pencil, Trash2, Undo2 } from 'lucide-react-native'
import type { GitHubReaction, GitHubReactionContent, PRComment } from '../../../../src/shared/types'
import type {
GitHubReaction,
GitHubReactionContent,
PRComment
} from '../../../../src/shared/github/comment-types'
import { colors } from '../../theme/mobile-theme'
import { canEditComment, isResolvableComment } from '../../session/pr-comment-actions'
import { ConfirmModal } from '../ConfirmModal'
@@ -1,7 +1,8 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types'
import type { PRComment } from '../../../../src/shared/github/comment-types'
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
import { PRCommentsSection } from './PRCommentsSection'
vi.mock('react-native', () => ({
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
import type { PRState } from '../../../../src/shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
import { colors } from '../../theme/mobile-theme'
import { canAddRootComment } from '../../session/pr-comment-actions'
@@ -3,7 +3,7 @@ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-nati
import * as Clipboard from 'expo-clipboard'
import { Check, Copy, FileWarning, Sparkles } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { PRInfo } from '../../../../src/shared/types'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import { PRSection } from './PRSection'
import { resolveConflictDisplay } from './pr-conflict-presentation'
import { prConflictStyles as styles } from './pr-conflict-styles'
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { UserPlus, X } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
@@ -2,7 +2,8 @@ import { useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import type { GitHubWorkItemDetails } from '../../../../src/shared/github/work-item-types'
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
import { prStateBadge } from './pr-checks-presentation'
import { statusColor } from './pr-sidebar-status-color'
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubAssignableUser } from '../../../../src/shared/types'
import type { GitHubAssignableUser } from '../../../../src/shared/github/pull-request-types'
import type { RpcClient } from '../../transport/rpc-client'
import { fetchAssignableUsers } from '../../session/github-pr-rpc'
import { BottomDrawer } from '../BottomDrawer'
@@ -1,4 +1,4 @@
import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/types'
import type { GitHubPRMergeMethod, PRState } from '../../../../src/shared/github/pull-request-types'
// Which actions the PR actions section may offer for a given PR state. Merged PRs
// expose only unlink (+ open-on-host elsewhere); closed PRs add reopen; open/draft
@@ -1,4 +1,4 @@
import type { PRInfo } from '../../../../src/shared/types'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
type MobilePRAutoMergeAvailabilityInput = Pick<
PRInfo,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { PRCheckRunDetails } from '../../../../src/shared/types'
import type { PRCheckRunDetails } from '../../../../src/shared/github/check-types'
import { presentCheckDetail } from './pr-check-detail-content'
function details(over: Partial<PRCheckRunDetails>): PRCheckRunDetails {
@@ -3,7 +3,7 @@ import type {
PRCheckJob,
PRCheckRunDetails,
PRCheckStep
} from '../../../../src/shared/types'
} from '../../../../src/shared/github/check-types'
// Pure mapping from the github.prCheckDetails payload to the rows the mobile
// expanded check detail renders. No React/native imports so it stays unit-testable
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { PRCheckDetail } from '../../../../src/shared/types'
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
import {
checkOutcome,
firstFailingCheckKey,
@@ -1,4 +1,8 @@
import type { PRCheckDetail, PRState, ProviderCheckSummary } from '../../../../src/shared/types'
import type { PRCheckDetail } from '../../../../src/shared/github/check-types'
import type {
PRState,
ProviderCheckSummary
} from '../../../../src/shared/github/pull-request-types'
import {
classifyCheckOutcome,
summarizeProviderChecks,
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import type { PRInfo } from '../../../../src/shared/types'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
import {
buildMergeabilityRefreshCommands,
hasMergeConflicts,
@@ -1,4 +1,4 @@
import type { PRInfo } from '../../../../src/shared/types'
import type { PRInfo } from '../../../../src/shared/github/pull-request-types'
// Pure presentation logic for the PR sidebar's conflicting-files section. No
// React/native imports so it is unit-testable under the node Vitest config (KTD5).
@@ -2,7 +2,14 @@ import { useEffect, useRef, useState } from 'react'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
export type NewWorktreeDrawerView =
| 'form'
| 'transition'
| 'source'
| 'project'
| 'runTarget'
| 'agent'
| 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.
@@ -39,6 +39,21 @@ describe('MobileFilePreviewScreen', () => {
).toBe(false)
})
it('treats native-chat artifact grants as read-only', () => {
expect(
isEditableMobileTerminalArtifactPreview(
{
status: 'ready',
kind: 'text',
content: '<h1>Result</h1>',
truncated: false,
byteLength: 15
},
true
)
).toBe(false)
})
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
const sourceKey = sourceKeyForPreview({
source: 'terminalArtifact',
+2 -1
View File
@@ -181,7 +181,8 @@ export function MobileFilePreviewScreen({ route }: Props) {
)
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
const isEditableTerminalArtifact =
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
previewSource?.source === 'terminalArtifact' &&
isEditableMobileTerminalArtifactPreview(preview, previewSource.readOnly === true)
const canSaveArtifact =
isEditableTerminalArtifact &&
draftSourceKeyRef.current === previewSourceKey &&
@@ -3,7 +3,13 @@ import type {
MobileFilePreviewSource
} from './mobile-file-preview-request'
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
export function isEditableMobileTerminalArtifactPreview(
preview: MobileFilePreviewResult,
readOnly = false
): boolean {
if (readOnly) {
return false
}
return (
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
preview.status === 'empty'
@@ -179,6 +179,47 @@ describe('mobile-file-preview-request', () => {
})
})
it('refreshes a native-chat artifact grant with the same transcript provenance', async () => {
const client = clientWithResponses([
fail('terminal_file_grant_expired'),
ok({
exists: true,
isDirectory: false,
openTarget: {
kind: 'absolute-file',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-2',
readOnly: true
}
}),
ok({ content: '<h1>Result</h1>', truncated: false, byteLength: 15 })
])
const onTerminalArtifactSourceRefreshed = vi.fn()
await loadMobileFilePreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
},
undefined,
{ onTerminalArtifactSourceRefreshed }
)
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
worktree: 'id:wt-1',
pathText: '~/orca-plans/result.html',
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' }
})
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith(
expect.objectContaining({ grantId: 'grant-2', readOnly: true })
)
})
it.each([
['terminal_file_grant_expired'],
['terminal_file_grant_mismatch'],
@@ -242,6 +283,25 @@ describe('mobile-file-preview-request', () => {
})
})
it('does not send writes for read-only native-chat artifacts', async () => {
const client = clientWith(ok({ ok: true }))
await expect(
saveMobileTerminalArtifactPreview(
client,
{
source: 'terminalArtifact',
worktreeId: 'wt-1',
absolutePath: '/Users/ada/orca-plans/result.html',
grantId: 'grant-1',
readOnly: true
},
'<h1>Changed</h1>'
)
).resolves.toMatchObject({ status: 'error' })
expect(client.sendRequest).not.toHaveBeenCalled()
})
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
@@ -116,6 +116,9 @@ export async function saveMobileTerminalArtifactPreview(
content: string,
options: TerminalArtifactSaveOptions = {}
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
if (source.readOnly) {
return previewError('This file is read-only')
}
let writeSource = source
if (typeof options.baseContent === 'string') {
const verified = await verifyTerminalArtifactBaseContent(
@@ -65,6 +65,8 @@ describe('mobile-file-preview-route', () => {
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1',
line: '12',
column: '3'
})
@@ -80,6 +82,8 @@ describe('mobile-file-preview-route', () => {
terminal: 'term-1',
pathText: 'result.json',
cwd: '/tmp/run',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1',
line: '12',
column: '3'
}
@@ -10,6 +10,8 @@ export type MobileFilePreviewRouteParams = {
terminal?: string
pathText?: string
cwd?: string
nativeChatTab?: string
nativeChatSession?: string
line?: string
column?: string
name?: string
@@ -35,6 +37,8 @@ type RawPreviewRouteParams = {
terminal?: MobileFilePreviewParamValue
pathText?: MobileFilePreviewParamValue
cwd?: MobileFilePreviewParamValue
nativeChatTab?: MobileFilePreviewParamValue
nativeChatSession?: MobileFilePreviewParamValue
line?: MobileFilePreviewParamValue
column?: MobileFilePreviewParamValue
name?: MobileFilePreviewParamValue
@@ -76,6 +80,8 @@ export function normalizeMobileFilePreviewRouteParams(
terminal: optionalSingleParam(params.terminal),
pathText: optionalSingleParam(params.pathText),
cwd: optionalSingleParam(params.cwd),
nativeChatTab: optionalSingleParam(params.nativeChatTab),
nativeChatSession: optionalSingleParam(params.nativeChatSession),
line: optionalSingleParam(params.line),
column: optionalSingleParam(params.column),
name: optionalSingleParam(params.name),
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { sourceKeyForPreview } from './mobile-file-preview-source'
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
describe('mobile-file-preview-source', () => {
it('uses structured preview keys so colons in paths cannot collide', () => {
@@ -25,4 +25,21 @@ describe('mobile-file-preview-source', () => {
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
})
it('makes native-chat artifact previews read-only', () => {
expect(
previewSourceFromRoute({
hostId: 'host-1',
worktreeId: 'wt-1',
source: 'terminalArtifact',
absolutePath: '/tmp/result.html',
grantId: 'grant-1',
nativeChatTab: 'tab-1',
nativeChatSession: 'session-1'
})
).toMatchObject({
nativeChatContext: { tabId: 'tab-1', sessionId: 'session-1' },
readOnly: true
})
})
})
+18 -9
View File
@@ -15,7 +15,16 @@ export function previewSourceFromRoute(
grantId: params.grantId,
...(params.terminal ? { terminalHandle: params.terminal } : {}),
...(params.pathText ? { pathText: params.pathText } : {}),
...(params.cwd ? { cwd: params.cwd } : {})
...(params.cwd ? { cwd: params.cwd } : {}),
...(params.nativeChatTab && params.nativeChatSession
? {
nativeChatContext: {
tabId: params.nativeChatTab,
sessionId: params.nativeChatSession
},
readOnly: true as const
}
: {})
}
}
if (!params.relativePath) {
@@ -28,12 +37,12 @@ export function sourceKeyForPreview(source: MobileFilePreviewSource | null): str
if (!source) {
return null
}
return source.source === 'terminalArtifact'
? JSON.stringify([
'terminal',
source.worktreeId,
source.absolutePath,
source.terminalHandle ?? ''
])
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
if (source.source !== 'terminalArtifact') {
return JSON.stringify(['worktree', source.worktreeId, source.relativePath])
}
const key = ['terminal', source.worktreeId, source.absolutePath, source.terminalHandle ?? '']
if (source.nativeChatContext) {
key.push(source.nativeChatContext.tabId, source.nativeChatContext.sessionId)
}
return JSON.stringify(key)
}
@@ -138,6 +138,17 @@ describe('resolveMobileFileTabDoc', () => {
})
})
// Why: the host now caps oversized diffs with an error envelope, and a client that knows nothing
// about the code must still surface the message instead of rendering an empty diff.
it('propagates a host diff_too_large failure instead of rendering an empty diff', async () => {
const client = clientOf({
'git.diff': fail('diff_too_large', 'This diff is too large to open over a remote connection.')
})
await expect(
resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.ts', diffSource: 'staged' })
).rejects.toThrow('This diff is too large to open over a remote connection.')
})
it('propagates the RPC error message when a read fails', async () => {
const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') })
await expect(

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