mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
Merge branch 'main' into brennanb2025/session-write-debounce
Resolve App.tsx import conflict (drop refs already removed on main, keep session-write subscriber wiring) and add the new lastVisitedAtByWorktreeId field to SESSION_RELEVANT_FIELDS — the compile-time exhaustiveness check correctly flagged that main had added it to WorkspaceSessionSnapshot. Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -80,7 +80,7 @@ All releases are cut from the **Cut Release** GitHub Actions workflow. There is
|
||||
- **ref**: the branch, tag, or SHA to build from. Defaults to `main`.
|
||||
3. Run it.
|
||||
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and kicks off the multi-platform build via `release.yml`.
|
||||
The workflow resolves the next version from GitHub Releases, bumps `package.json`, tags, pushes, and runs the multi-platform build + publish inline.
|
||||
|
||||
**How the next version is chosen:**
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Computer-use e2e
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/computer-e2e.yml'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/scripts/build-computer-macos.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
- 'src/cli/**'
|
||||
- 'src/main/computer/**'
|
||||
- 'src/main/runtime/rpc/methods/computer.ts'
|
||||
- 'src/shared/runtime-types.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
- 'tests/e2e/computer-windows.e2e.ts'
|
||||
- 'tests/e2e/helpers/computer-driver.ts'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 6 * * *'
|
||||
|
||||
jobs:
|
||||
native-smoke:
|
||||
if: github.event_name == 'pull_request'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
- if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3 python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
|
||||
mac:
|
||||
# macOS Accessibility and Screen Recording require user-granted TCC entries.
|
||||
# Keep this on manual/scheduled permission-bearing runners instead of PR CI.
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: macos-14
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build:computer-macos
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-mac.e2e.ts
|
||||
|
||||
linux:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
ACCESSIBILITY_ENABLED: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
- run: sudo apt-get update && sudo apt-get install -y build-essential python3 python3-gi gir1.2-atspi-2.0 gedit at-spi2-core xvfb xclip xdotool
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-linux.e2e.ts
|
||||
|
||||
windows:
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm verify:computer-native
|
||||
- run: pnpm build:cli
|
||||
- run: pnpm test:e2e:computer -- --reporter=verbose tests/e2e/computer-windows.e2e.ts
|
||||
@@ -2,15 +2,21 @@ name: Homebrew Cask Bump
|
||||
|
||||
# Why: keep the stablyai/homebrew-orca tap in sync with GitHub Releases.
|
||||
# Triggers:
|
||||
# - release.published with !prerelease → release workflow just finalized a
|
||||
# stable tag (see publish-release step in release.yml)
|
||||
# - workflow_call → release.yml finished publishing a stable tag
|
||||
# - workflow_call → release-cut.yml finished publishing a stable tag
|
||||
# - workflow_dispatch → manual backfill for a specific tag
|
||||
#
|
||||
# The job computes the sha256 of orca-macos-arm64.dmg + orca-macos-x64.dmg
|
||||
# from the release, templates Casks/orca.rb in this repo with the new
|
||||
# version and hashes, then PRs the result into stablyai/homebrew-orca. It
|
||||
# does NOT run for -rc.* tags: the stable cask should only track GA.
|
||||
#
|
||||
# Why no `release.published` trigger: other products shipped from this repo
|
||||
# (e.g. mobile under `mobile-v*`) also publish GitHub Releases. A blanket
|
||||
# `release.published` trigger would fire this workflow for any of them and
|
||||
# attempt to download `orca-macos-*.dmg` from a non-desktop release, at
|
||||
# best 404ing and at worst (if the filter were weaker) rewriting the cask
|
||||
# to a non-desktop version. The tap is only ever bumped by the desktop
|
||||
# release pipeline calling this workflow directly.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -19,8 +25,6 @@ on:
|
||||
description: Release tag to sync (e.g. v1.3.25)
|
||||
required: true
|
||||
type: string
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -31,12 +35,15 @@ on:
|
||||
jobs:
|
||||
bump-cask:
|
||||
runs-on: ubuntu-latest
|
||||
# Why: skip prereleases and RC tags. The stable cask tracks GA only; a
|
||||
# beta channel would be a separate cask in a separate job.
|
||||
# Why: defense in depth. The desktop release pipeline already refuses
|
||||
# to invoke this workflow for `-rc.*` tags, but a manual backfill could
|
||||
# still pass one in — reject it here so the stable cask can never point
|
||||
# at an RC. Also requires the desktop tag shape `v<digit>...` so a
|
||||
# mis-typed mobile tag like `mobile-v0.0.1` is rejected up front.
|
||||
if: >
|
||||
inputs.tag != '' ||
|
||||
(github.event.release.prerelease == false &&
|
||||
!contains(github.event.release.tag_name, '-rc.'))
|
||||
inputs.tag != '' &&
|
||||
!contains(inputs.tag, '-rc.') &&
|
||||
startsWith(inputs.tag, 'v')
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Mobile Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mobile-v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Why: the "Create GitHub Release" step below uses the default GITHUB_TOKEN
|
||||
# to call `gh release create`, which requires contents:write. Without this,
|
||||
# the job builds the APK fine but fails at release time with
|
||||
# "HTTP 403: Resource not accessible by integration".
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 17
|
||||
|
||||
- name: Expo prebuild
|
||||
run: npx expo prebuild --platform android --no-install
|
||||
|
||||
- name: Build Android release APK
|
||||
run: cd android && ./gradlew assembleRelease
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: orca-mobile-apk
|
||||
path: mobile/android/app/build/outputs/apk/release/*.apk
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/mobile-v')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
gh release create "$tag" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "Orca Mobile $tag" \
|
||||
--prerelease \
|
||||
--latest=false \
|
||||
--generate-notes \
|
||||
android/app/build/outputs/apk/release/*.apk
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Mobile Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
@@ -183,20 +183,28 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Latest stable release tag, picked by *tag shape* (no `-rc.`)
|
||||
# rather than the GitHub `isPrerelease` flag. electron-builder's
|
||||
# publish step has flipped that flag back to `false` on RC
|
||||
# releases in the past (issue surfaced 2026-04-27 when
|
||||
# v1.3.22-rc.2 became "latest" on GitHub and poisoned the
|
||||
# compute-next-version math here). Filtering by tag format is
|
||||
# the authoritative signal — an `-rc.N` tag is never stable,
|
||||
# regardless of what the release metadata says.
|
||||
# Latest stable release tag, picked by *tag shape*:
|
||||
# - must start with `v<digit>` (desktop convention, e.g. v1.3.32)
|
||||
# - must NOT contain `-rc.` (not a prerelease)
|
||||
#
|
||||
# Why not the GitHub `isPrerelease` flag: electron-builder's publish
|
||||
# step has flipped that flag back to `false` on RC releases before
|
||||
# (v1.3.22-rc.2 on 2026-04-27 briefly became "latest" on GitHub and
|
||||
# poisoned the math here). Tag format is authoritative.
|
||||
#
|
||||
# Why the `^v[0-9]` prefix: other products shipped from this repo
|
||||
# use their own prefixes (e.g. `mobile-v0.0.1`). Without the prefix
|
||||
# gate the latest mobile release would be selected as "latest
|
||||
# stable", then `strip_pre()` would reduce `mobile-v0.0.1` to
|
||||
# `mobile`, `Number("mobile")` → NaN → 0, and a patch-bump would
|
||||
# produce `0.0.1` — exactly the wedge on 2026-05-04 (run
|
||||
# 25304336767). Any non-desktop tag shape must be excluded here.
|
||||
latest_stable="$(gh release list \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--exclude-drafts \
|
||||
--limit 50 \
|
||||
--json tagName \
|
||||
--jq '[.[] | .tagName | select(test("-rc\\.") | not)] | .[0] // ""')"
|
||||
--jq '[.[] | .tagName | select(test("^v[0-9]") and (test("-rc\\.") | not))] | .[0] // ""')"
|
||||
latest_stable="${latest_stable#v}"
|
||||
echo "Latest stable: ${latest_stable:-<none>}"
|
||||
|
||||
@@ -302,13 +310,16 @@ jobs:
|
||||
# already exist — that's a real conflict (someone tagged manually
|
||||
# over a shipped version) and needs human attention.
|
||||
if git rev-parse "v$new" >/dev/null 2>&1; then
|
||||
# Use the REST API, not `gh release view`: the latter returns
|
||||
# "release not found" for drafts *and* for absent releases,
|
||||
# which would cause us to "recover" over a draft that a human
|
||||
# is preparing. The API distinguishes them.
|
||||
release_state="$(gh api \
|
||||
# Why: use the REST API, not `gh release view`. `gh api` returns
|
||||
# `true`/`false` for a real release, while a 404 means the tag is
|
||||
# orphaned and can be safely re-dispatched. Capture failures
|
||||
# explicitly so the 404 payload never leaks into the state check.
|
||||
release_state="missing"
|
||||
if gh api \
|
||||
"repos/$GITHUB_REPOSITORY/releases/tags/v$new" \
|
||||
--jq '.draft' 2>/dev/null || echo "missing")"
|
||||
--jq '.draft' >"$RUNNER_TEMP/release-state" 2>/dev/null; then
|
||||
release_state="$(cat "$RUNNER_TEMP/release-state")"
|
||||
fi
|
||||
case "$release_state" in
|
||||
missing|true)
|
||||
echo "::warning::Tag v$new already exists but no published release is attached — recovering by re-dispatching release.yml against the existing tag."
|
||||
@@ -369,10 +380,292 @@ jobs:
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
|
||||
release:
|
||||
create-release:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/release.yml
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Create draft release with auto-generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
is_rc=false
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
is_rc=true
|
||||
fi
|
||||
|
||||
gh release create "$TAG" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease="$is_rc"
|
||||
|
||||
# Why: E2E runs alongside the release for visibility (failures surface as a
|
||||
# red check on the tag), but is NOT in `publish-release`'s needs list.
|
||||
# Releases already take a while and the suite is already a required check
|
||||
# on PRs, so gating here would mostly delay shipping without adding signal.
|
||||
e2e:
|
||||
needs: cut
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
build:
|
||||
needs:
|
||||
- cut
|
||||
- create-release
|
||||
if: needs.cut.outputs.should_release == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
eb_cache_path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
|
||||
eb_cache_path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: release builds hit the same native-module postinstall path as
|
||||
# PR CI, so keep the pinned node-gyp override here too instead of
|
||||
# relying on pnpm's bundled copy. Scoped to Linux via runner.os (not
|
||||
# a specific matrix image) because the failing postinstall has only
|
||||
# been observed on Linux runners — see run 25081763129. The macOS
|
||||
# and Windows release jobs exercise the same pnpm install path and
|
||||
# have not reproduced it, so keep the gate narrow until we know why.
|
||||
# Using runner.os instead of matrix.os == 'ubuntu-latest' means the
|
||||
# gate still works if another Linux matrix entry is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.eb_cache_path }}
|
||||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-${{ matrix.platform }}-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why: `pnpm build:release` verifies the Linux computer-use provider by
|
||||
# importing AT-SPI bindings, which are runtime package deps but are not
|
||||
# present on stock GitHub Ubuntu release runners.
|
||||
- name: Install Linux computer-use provider dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y python3-gi gir1.2-atspi-2.0 at-spi2-core xclip xdotool
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
if: matrix.platform == 'mac'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Why: telemetry's transport gate (`src/main/telemetry/client.ts:IS_OFFICIAL_BUILD`)
|
||||
# requires the build identity to be the literal string `stable` or `rc`,
|
||||
# substituted by electron-vite's `define` block at build time. Derive
|
||||
# that identity from the release tag here — `stable` for plain semver
|
||||
# (`vX.Y.Z`), `rc` for prerelease (`vX.Y.Z-rc.N`). The strict regex is
|
||||
# a safety net: this workflow only fires on cut-tags that already match
|
||||
# one of those shapes, but if a future change ever loosens that, we
|
||||
# refuse to ship rather than let an unclassified build go out with
|
||||
# `BUILD_IDENTITY = null`.
|
||||
- name: Classify release tag for telemetry build identity
|
||||
id: tag-classify
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
else
|
||||
echo "::error::Tag $TAG does not match stable or rc pattern; refusing to build official artifact"
|
||||
exit 1
|
||||
fi
|
||||
echo "identity=$identity" >>"$GITHUB_OUTPUT"
|
||||
echo "Classified $TAG as $identity"
|
||||
|
||||
# Why ORCA_POSTHOG_WRITE_KEY here: this is the only build that
|
||||
# produces a published binary, so this is the only place the secret
|
||||
# needs to be in scope. The key is a PostHog *project* API key, not
|
||||
# a server secret — it ships in every official binary's app.asar
|
||||
# and is therefore extractable from any release. We still keep it
|
||||
# in GitHub Actions secrets so the literal stays out of the repo
|
||||
# (and out of fork CI runs / log scrapers / casual greps).
|
||||
# Why ORCA_BUILD_IDENTITY here (not in env at the job level): the
|
||||
# value comes from the per-tag classification above and electron-vite
|
||||
# reads it from `process.env` during `pnpm build:release` only.
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
env:
|
||||
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
|
||||
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
|
||||
|
||||
# Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be
|
||||
# passed to non-macOS builds. electron-builder uses CSC_LINK as the
|
||||
# code-signing certificate on any platform, so leaking the Apple
|
||||
# Developer ID cert to the Windows build causes the NSIS installer to
|
||||
# be signed with an Apple cert whose chain Windows cannot validate,
|
||||
# breaking the auto-updater with "certificate chain could not be built
|
||||
# to a trusted root authority" (issue #631).
|
||||
#
|
||||
# Why retry: electron-builder downloads NSIS/winCodeSign/squirrel
|
||||
# binaries and the Electron runtime from GitHub release assets during
|
||||
# publish. GitHub's download CDN occasionally returns 504s that fail
|
||||
# the whole release. Retry on failure so transient network errors
|
||||
# don't require a manual re-run.
|
||||
- name: Publish release artifacts (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: matrix.platform != 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Why post-publish (not pre-publish): electron-builder packs and
|
||||
# uploads in a single `--publish always` invocation, so there is no
|
||||
# cheap insertion point between pack and upload without splitting
|
||||
# that step. Running verify last still blocks the bad release: the
|
||||
# binary is uploaded to the draft, but a failed matrix job blocks
|
||||
# the `publish-release` job (which depends on `build`) from flipping
|
||||
# the release from draft → published, so users never see it. A human
|
||||
# then deletes the draft and re-cuts.
|
||||
#
|
||||
# Why this guards against: a misconfigured CI run where
|
||||
# `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify
|
||||
# would otherwise produce a binary with `BUILD_IDENTITY = null` and
|
||||
# `WRITE_KEY = null`, which silently disables transport
|
||||
# (`IS_OFFICIAL_BUILD === false`) — the exact failure mode flagged
|
||||
# in PR #1385's deferred follow-up.
|
||||
- name: Verify telemetry constants present in app.asar
|
||||
run: node config/scripts/verify-telemetry-constants.mjs
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- cut
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish release
|
||||
# Why: derive `--prerelease` from the tag shape (not from whatever
|
||||
# electron-builder left the release flagged as). On 2026-04-27,
|
||||
# electron-builder's publish step flipped `prerelease` back to
|
||||
# `false` on -rc.N releases, which caused an RC to be marked as
|
||||
# GitHub's "latest" release and broke release-cut.yml's math.
|
||||
# Re-asserting here means the final release state is determined
|
||||
# by the tag — a ground truth electron-builder can't rewrite.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
prerelease=true
|
||||
else
|
||||
prerelease=false
|
||||
fi
|
||||
gh release edit "$TAG" \
|
||||
--draft=false \
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
homebrew-bump:
|
||||
needs:
|
||||
- cut
|
||||
- publish-release
|
||||
if: ${{ !contains(needs.cut.outputs.tag, '-rc.') }}
|
||||
uses: ./.github/workflows/homebrew-bump.yml
|
||||
with:
|
||||
tag: ${{ needs.cut.outputs.tag }}
|
||||
secrets: inherit
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: Tag to release, for example v1.2.3-rc.0
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing tag to release, for example v1.2.3-rc.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
resolve-release:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag: ${{ steps.resolve.outputs.tag }}
|
||||
ref: ${{ steps.resolve.outputs.ref }}
|
||||
steps:
|
||||
- name: Resolve release tag and ref
|
||||
id: resolve
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" ]]; then
|
||||
release_tag="${{ github.ref_name }}"
|
||||
release_ref="${{ github.ref }}"
|
||||
else
|
||||
release_tag="${{ inputs.tag }}"
|
||||
release_ref="refs/tags/${{ inputs.tag }}"
|
||||
fi
|
||||
|
||||
echo "tag=$release_tag" >>"$GITHUB_OUTPUT"
|
||||
echo "ref=$release_ref" >>"$GITHUB_OUTPUT"
|
||||
|
||||
create-release:
|
||||
needs: resolve-release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
- name: Create draft release with auto-generated notes
|
||||
run: |
|
||||
if gh release view "${{ needs.resolve-release.outputs.tag }}" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "Release ${{ needs.resolve-release.outputs.tag }} already exists."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh release create "${{ needs.resolve-release.outputs.tag }}" \
|
||||
--draft \
|
||||
--generate-notes \
|
||||
--prerelease=${{ contains(needs.resolve-release.outputs.tag, 'rc') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Why: E2E runs alongside the release for visibility (failures surface as a
|
||||
# red check on the tag), but it is NOT in `release`'s needs list. Releases
|
||||
# already take a while and the suite is already a required check on PRs, so
|
||||
# gating here would mostly just delay shipping without adding much signal.
|
||||
# Matches the pattern used by noqa's app deploy, which runs E2E with
|
||||
# continue-on-error so failures are visible but don't block the deploy.
|
||||
e2e:
|
||||
needs: resolve-release
|
||||
uses: ./.github/workflows/e2e.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
release:
|
||||
needs:
|
||||
- resolve-release
|
||||
- create-release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-15
|
||||
platform: mac
|
||||
release_command: ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
eb_cache_path: |
|
||||
~/Library/Caches/electron
|
||||
~/Library/Caches/electron-builder
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish always
|
||||
eb_cache_path: |
|
||||
~\AppData\Local\electron\Cache
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
release_command: pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.resolve-release.outputs.ref }}
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
# Why: release builds hit the same native-module postinstall path as
|
||||
# PR CI, so keep the pinned node-gyp override here too instead of
|
||||
# relying on pnpm's bundled copy. Scoped to Linux via runner.os (not
|
||||
# a specific matrix image) because the failing postinstall has only
|
||||
# been observed on Linux runners — see run 25081763129. The macOS
|
||||
# and Windows release jobs exercise the same pnpm install path and
|
||||
# have not reproduced it, so keep the gate narrow until we know why.
|
||||
# Using runner.os instead of matrix.os == 'ubuntu-latest' means the
|
||||
# gate still works if another Linux matrix entry is added later.
|
||||
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.eb_cache_path }}
|
||||
key: electron-builder-${{ matrix.platform }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
electron-builder-${{ matrix.platform }}-
|
||||
|
||||
# Why: pnpm install triggers electron's postinstall, which downloads the
|
||||
# Electron binary from GitHub release assets. GitHub's download CDN
|
||||
# occasionally returns 504s that fail the whole release. Retry on
|
||||
# failure so transient network errors don't require a manual re-run.
|
||||
- name: Install dependencies
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
if: matrix.platform == 'mac'
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Build app
|
||||
run: pnpm build:release
|
||||
|
||||
# Why: macOS signing secrets (CSC_LINK, CSC_KEY_PASSWORD) must NOT be
|
||||
# passed to non-macOS builds. electron-builder uses CSC_LINK as the
|
||||
# code-signing certificate on any platform, so leaking the Apple
|
||||
# Developer ID cert to the Windows build causes the NSIS installer to
|
||||
# be signed with an Apple cert whose chain Windows cannot validate,
|
||||
# breaking the auto-updater with "certificate chain could not be built
|
||||
# to a trusted root authority" (issue #631).
|
||||
#
|
||||
# Why retry: electron-builder downloads NSIS/winCodeSign/squirrel
|
||||
# binaries and the Electron runtime from GitHub release assets during
|
||||
# publish. GitHub's download CDN occasionally returns 504s that fail
|
||||
# the whole release. Retry on failure so transient network errors
|
||||
# don't require a manual re-run.
|
||||
- name: Publish release artifacts (macOS)
|
||||
if: matrix.platform == 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Publish release artifacts
|
||||
if: matrix.platform != 'mac'
|
||||
uses: nick-fields/retry@v3
|
||||
with:
|
||||
timeout_minutes: 30
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: ${{ matrix.release_command }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
publish-release:
|
||||
needs:
|
||||
- resolve-release
|
||||
- release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Publish release
|
||||
# Why: derive `--prerelease` from the tag shape (not from whatever
|
||||
# electron-builder left the release flagged as). On 2026-04-27,
|
||||
# electron-builder's publish step flipped `prerelease` back to
|
||||
# `false` on -rc.N releases, which caused an RC to be marked as
|
||||
# GitHub's "latest" release and broke release-cut.yml's math.
|
||||
# Re-asserting here means the final release state is determined
|
||||
# by the tag — a ground truth electron-builder can't rewrite.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.resolve-release.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" == *"-rc."* ]]; then
|
||||
prerelease=true
|
||||
else
|
||||
prerelease=false
|
||||
fi
|
||||
gh release edit "$TAG" \
|
||||
--draft=false \
|
||||
--prerelease="$prerelease" \
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
|
||||
homebrew-bump:
|
||||
needs:
|
||||
- resolve-release
|
||||
- publish-release
|
||||
if: ${{ !contains(needs.resolve-release.outputs.tag, '-rc.') }}
|
||||
uses: ./.github/workflows/homebrew-bump.yml
|
||||
with:
|
||||
tag: ${{ needs.resolve-release.outputs.tag }}
|
||||
secrets: inherit
|
||||
+3
-3
@@ -24,6 +24,7 @@ dist-electron/
|
||||
out/
|
||||
/build/
|
||||
release/
|
||||
native/**/.build/
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
@@ -67,9 +68,8 @@ design-docs/
|
||||
.atl/
|
||||
|
||||
# Local-only design/planning docs (not checked in)
|
||||
docs/*-design.md
|
||||
docs/*-plan.md
|
||||
docs/design-*.md
|
||||
docs/*.md
|
||||
!docs/README*.md
|
||||
|
||||
# Stably CLI (only docs/ are tracked)
|
||||
.stably/*
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
"rules": {
|
||||
"react/jsx-no-duplicate-props": "error",
|
||||
"react/jsx-no-undef": "error",
|
||||
"react/jsx-uses-vars": "error",
|
||||
"react/no-children-prop": "error",
|
||||
"react/no-danger-with-children": "error",
|
||||
"react/no-direct-mutation-state": "error",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Design System
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
|
||||
## Code Comments: Document the "Why", Briefly
|
||||
|
||||
When writing or modifying code driven by a design doc or non-obvious constraint, add a comment explaining **why** the code behaves the way it does.
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See also: [AGENTS.md](./AGENTS.md) for agent-specific rules.
|
||||
|
||||
## Code Comments: Document the "Why"
|
||||
|
||||
When implementing behavior driven by a design doc, spec, or non-obvious constraint, **add a comment explaining why** the code does what it does — not just what it does. This is especially important for:
|
||||
|
||||
- **Safety constraints** — e.g., suppressing an action because it could silently erase a signal, re-create a conflict, or mislead the user.
|
||||
- **Fallback/error-handling choices** — e.g., defaulting to `'modified'` on fs error because it's the least misleading option.
|
||||
- **Architectural boundaries** — e.g., why state lives in the renderer and never crosses IPC, or why a feature belongs to Source Control and not Checks.
|
||||
- **Compatibility shims** — e.g., when a field exists purely for downstream plumbing and does not carry semantic meaning.
|
||||
- **Intentional omissions** — e.g., skipping submodule conflicts or not providing rename metadata because the data source doesn't support it.
|
||||
|
||||
A future maintainer who hasn't read the design doc should be able to understand from the comment alone why the code must not be changed casually.
|
||||
|
||||
## Package Manager
|
||||
|
||||
This project uses **pnpm**. Never use `npm` or `yarn`.
|
||||
|
||||
- Use `pnpm install` (not `npm install`)
|
||||
- Use `pnpm add <pkg>` (not `npm install <pkg>`)
|
||||
- Use `pnpm run <script>` (not `npm run <script>`)
|
||||
- The lock file is `pnpm-lock.yaml`. Do not generate `package-lock.json` or `yarn.lock`.
|
||||
@AGENTS.md
|
||||
|
||||
-90
@@ -1,90 +0,0 @@
|
||||
# Orca Privacy Notice
|
||||
|
||||
Version: 1.0 — Last updated: 2026-05-02
|
||||
|
||||
Orca is a local desktop application for running CLI coding agents across git worktrees. This document describes the anonymous product-usage telemetry we collect in packaged Orca builds, what we never collect, and how to opt out.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Anonymous.** Telemetry events are keyed by a locally-generated UUID (`install_id`). No account, email, IP, or user name is collected.
|
||||
- **No content.** We never transmit file contents, prompts, agent output, terminal output, repo names, branch names, URLs, paths, or commit messages. The validator fails closed on anything outside the typed event schema.
|
||||
- **Cohort-aware defaults.** New installs ship with telemetry on and a dismissible first-run disclosure. If you upgraded into the first telemetry release, telemetry starts off and a banner asks for explicit opt-in.
|
||||
- **Always off when:** `DO_NOT_TRACK=1`, `ORCA_TELEMETRY_DISABLED=1`, or any common CI environment variable is set (e.g. `CI`, `GITHUB_ACTIONS`).
|
||||
- **Dev builds never transmit.** `pnpm dev`, contributor checkouts, and third-party forks do not carry the build-identity constant that gates transmission, so events only appear in a local console mirror.
|
||||
|
||||
## What we collect
|
||||
|
||||
Every event carries these common properties:
|
||||
|
||||
- `app_version` — the Orca version string.
|
||||
- `platform` — `darwin` / `win32` / `linux`.
|
||||
- `arch` — CPU architecture (`arm64`, `x64`, …).
|
||||
- `os_release` — coarse OS release string (e.g. `25.3.0`). No hostname.
|
||||
- `install_id` — anonymous UUID v4. Stable across launches, regenerable from Settings → Privacy.
|
||||
- `session_id` — new UUID per app launch; does not persist.
|
||||
- `orca_channel` — `stable` or `rc`. Present only on official release builds; dev builds never transmit.
|
||||
|
||||
The events we send (7 in total — 8 literal names, counting `telemetry_opted_in` / `telemetry_opted_out` as one matched pair):
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- `app_opened` — fires when the main window finishes loading. No custom properties. Daily/weekly/monthly active users are derived server-side from distinct `install_id`s with an `app_opened` in the window; we do not emit a dedicated `daily_active_user` event.
|
||||
|
||||
### Repos and workspaces
|
||||
|
||||
- `repo_added` — `method`: `folder_picker` / `clone_url` / `drag_drop`. Never the repo URL, repo name, or path.
|
||||
- `workspace_created` — `source`: entry-point enum (`command_palette` / `sidebar` / `shortcut` / `drag_drop` / `unknown`); `from_existing_branch` (bool). Never the branch name or base branch.
|
||||
|
||||
### Agents
|
||||
|
||||
- `agent_started` — `initial_agent_kind` (enum — `claude-code` / `codex` / `gemini` / `copilot` / `cursor` / `opencode` / `aider` / `amp` / `other`); `launch_source` (`command_palette` / `sidebar` / `tab_bar_quick_launch` / `task_page` / `new_workspace_composer` / `workspace_jump_palette` / `shortcut` / `unknown`); `request_kind` (`new` / `resume` / `followup`). This event means "what Orca launched after confirmed session creation," not a later shell-title inference. No model details, no prompt content.
|
||||
- `agent_error` — `error_class` (closed enum of known error types); `agent_kind`; optional `error_name` drawn from a closed whitelist of error class names. Enum-only: no raw error message, no stack trace, no free-form identifiers. Per-incident error context lives only in a local diagnostic trace file on your machine; it reaches Orca only if you explicitly share a diagnostic bundle.
|
||||
|
||||
### Settings
|
||||
|
||||
- `settings_changed` — `setting_key` (whitelisted enum, scoped to a small set of feature-flag and UX-preference toggles); `value_kind`: `bool` / `enum`. Never the raw value of a free-form setting.
|
||||
|
||||
### Privacy controls
|
||||
|
||||
- `telemetry_opted_in` / `telemetry_opted_out` — fires exactly once at the moment of the change. `via`: `first_launch_banner` / `first_launch_notice` / `settings`. Environment-variable and CI overrides do not fire these events — they disable transmission at runtime without changing your stored preference.
|
||||
|
||||
## What we never send
|
||||
|
||||
- No file paths, repo names, branch names, URLs, commit messages, or current working directory.
|
||||
- No agent prompts, responses, or terminal contents.
|
||||
- No raw error messages, no stack frames, and no free-form identifiers. `agent_error` is enum-only (`error_class` + `agent_kind`, plus an optional whitelisted `error_name`). Per-incident error context stays in a local diagnostic trace file and reaches Orca only if you explicitly share a diagnostic bundle.
|
||||
- No user account information (Orca has no account system).
|
||||
- No precise geoip. PostHog's project-level "Discard client IP data" is on; country is the only geographic signal derived from the request, and we do not populate `$ip` ourselves.
|
||||
- No person-profile materialization on the vendor side. Every event is captured with `$process_person_profile: false` so no profile is created against the anonymous `install_id`.
|
||||
- No free-form strings from any UI input. Every transmitted string property is either an enum, a UUID, or a bucketed/versioned constant.
|
||||
|
||||
Runtime enforcement: a single `track(event, props)` wrapper with a TypeScript-typed event map and a runtime Zod validator. Events not in the map never compile; properties outside the declared shape are dropped at runtime with a warning.
|
||||
|
||||
## How to opt out
|
||||
|
||||
You can disable telemetry in three ways. Any one of them is sufficient; they compose.
|
||||
|
||||
1. **In the app.** Settings → Privacy → toggle "Share anonymous usage data" off. The change is immediate and persistent.
|
||||
2. **`DO_NOT_TRACK=1`** — community-standard environment variable. Disables transmission for that launch. Unsetting it restores your stored preference on the next launch.
|
||||
3. **`ORCA_TELEMETRY_DISABLED=1`** — Orca-specific kill switch with the same semantics as `DO_NOT_TRACK`.
|
||||
|
||||
CI environments are auto-detected (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `CIRCLECI`, `TRAVIS`, `BUILDKITE`, `JENKINS_URL`, `TEAMCITY_VERSION`) and do not transmit.
|
||||
|
||||
### Residual flush on opt-out
|
||||
|
||||
When you flip telemetry off, events captured up to roughly 10 seconds before the toggle may still be transmitted in the final SDK batch (this is PostHog Node's flush interval). Subsequent events will not.
|
||||
|
||||
## Where the data goes
|
||||
|
||||
- **Vendor:** PostHog Cloud (`us.i.posthog.com`), **United States** region.
|
||||
- **Project configuration:** session recordings disabled; precise geoip disabled (country-only); person-profile creation suppressed per event (`$process_person_profile: false`) so no profile is materialized for an anonymous `install_id`.
|
||||
- **Retention:** PostHog Cloud's plan-level default. At the time of this document, the free tier retains event data for 1 year, with cold-storage thereafter per PostHog's pricing page. Paid tiers extend this. We do not set a custom retention window.
|
||||
- **Access:** project membership is restricted to the telemetry owner and a single backup.
|
||||
|
||||
## Resetting or stopping your anonymous data
|
||||
|
||||
Three paths are available to you directly:
|
||||
|
||||
- **Reset anonymous ID** in Settings → Privacy rotates your `install_id` prospectively. Subsequent events carry a fresh UUID; events emitted before the rotation remain associated with the old UUID and are not linkable to the new one.
|
||||
- **Opt out** (Settings → Privacy, `DO_NOT_TRACK=1`, or `ORCA_TELEMETRY_DISABLED=1`) stops transmission entirely from the next event onward.
|
||||
- **Retention** is PostHog Cloud's plan-level default (see above); old events age out on that schedule without any action on your part.
|
||||
@@ -31,7 +31,7 @@
|
||||
Orca supports any CLI agent (*not just this list*).
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -40,15 +40,16 @@ Orca supports any CLI agent (*not just this list*).
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="docs/assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -96,6 +97,18 @@ yay -S stably-orca-git
|
||||
|
||||
---
|
||||
|
||||
## [New] View Live Status of All Agents
|
||||
|
||||
**See what every agent is doing — at a glance.**
|
||||
|
||||
Track the live status of every agent across every worktree from one place. Spot which agents are working, waiting on input, or done — without clicking through tabs.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/agent-statuses.gif" alt="Orca Agent Statuses — view live status of all agents across worktrees" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [New] Annotate AI Diff
|
||||
|
||||
**Comment directly on AI-generated diffs.**
|
||||
@@ -149,7 +162,7 @@ npx skills add https://github.com/stablyai/orca --skill orca-cli
|
||||
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
|
||||
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
|
||||
- **Feedback & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See [PRIVACY.md](PRIVACY.md) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
- **Show Support:** Star this repo to follow along with our daily ships.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Agent nesting — design options</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 8px; height: 8px;
|
||||
border: 1.5px solid #eab308;
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.spinner-sm {
|
||||
width: 7px; height: 7px;
|
||||
border: 1.5px solid #eab308;
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-950 p-8 text-neutral-200">
|
||||
<div class="mx-auto grid max-w-[1400px] grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION A: Rail + smaller icons -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">A. Vertical rail</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Single left rail anchors agents to the parent. No inner card, no divider. Icons 12px.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<!-- Parent task -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<!-- Agents section -->
|
||||
<button class="mt-2 flex items-center gap-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500 hover:text-neutral-300">
|
||||
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
|
||||
Agents · 2
|
||||
</button>
|
||||
|
||||
<!-- Rail container -->
|
||||
<div class="relative mt-1 pl-3">
|
||||
<span class="absolute left-1 top-1 bottom-1 w-px bg-neutral-800"></span>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] font-medium text-neutral-100">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="pl-5 -mt-0.5 pb-1 truncate text-[10.5px] leading-snug text-neutral-500">Who runs into the behavior change</div>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">just now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION B: Flush + status dot only -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">B. Flush rows, dot-only status</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Drop the agent avatar entirely. Status dot (6px) + title is enough. Indented 12px.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<div class="mt-2 border-t border-neutral-800/80 pt-1.5">
|
||||
<div class="mb-0.5 flex items-center justify-between px-1.5">
|
||||
<span class="text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500">Agents</span>
|
||||
<span class="text-[9.5px] text-neutral-600">2</span>
|
||||
</div>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">Implement docs/peek</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION C: Inset panel -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">C. Inset panel</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Agents live in a slightly darker recessed block — feels like a drawer, not a card-in-card.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<div class="mt-2 rounded-md bg-neutral-950/60 px-2 py-1.5 ring-1 ring-inset ring-neutral-800/60">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500">Agents · 2</span>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" class="text-neutral-600"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</div>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1 py-0.5 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
|
||||
<div class="group flex items-center gap-2 rounded px-1 py-0.5 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION D: Inline summary (collapsed by default) -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">D. Inline summary row</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Default state is a compact one-line summary. Click to expand. Reduces visual noise when scrolling.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<!-- collapsed -->
|
||||
<button class="mt-2 flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-neutral-800/60">
|
||||
<div class="flex -space-x-0.5">
|
||||
<span class="h-2 w-2 rounded-full bg-amber-400 ring-2 ring-neutral-900"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-neutral-900"></span>
|
||||
</div>
|
||||
<span class="text-[10.5px] text-neutral-400">2 agents · <span class="text-neutral-200">1 running</span></span>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" class="ml-auto text-neutral-500"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
|
||||
<!-- example expanded below -->
|
||||
<div class="mt-3 border-t border-dashed border-neutral-800 pt-3">
|
||||
<p class="mb-1 text-[10px] uppercase tracking-wide text-neutral-600">Expanded state</p>
|
||||
<div class="space-y-px">
|
||||
<div class="flex items-center gap-2 rounded px-1.5 py-1">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded px-1.5 py-1">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION E: Tree-branch connector -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">E. Tree branch</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Explicit hierarchy via L-connectors. Works well when nesting could grow deeper.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<div class="mt-2">
|
||||
<button class="flex items-center gap-1 px-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500 hover:text-neutral-300">
|
||||
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
|
||||
Agents · 2
|
||||
</button>
|
||||
|
||||
<div class="relative mt-0.5">
|
||||
<!-- first row -->
|
||||
<div class="relative flex items-center gap-2 rounded py-1 pl-6 pr-1.5 hover:bg-neutral-800/60">
|
||||
<svg class="absolute left-1.5 top-0 text-neutral-800" width="12" height="18" viewBox="0 0 12 18" fill="none" stroke="currentColor" stroke-width="1"><path d="M1 0 V9 H11"/></svg>
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<!-- last row -->
|
||||
<div class="relative flex items-center gap-2 rounded py-1 pl-6 pr-1.5 hover:bg-neutral-800/60">
|
||||
<svg class="absolute left-1.5 top-0 text-neutral-800" width="12" height="18" viewBox="0 0 12 18" fill="none" stroke="currentColor" stroke-width="1"><path d="M1 0 V9 H11"/></svg>
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- OPTION F: Stacked pills -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">F. Stacked pills</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Agents feel like chips anchored to the task. Good for 1–3 agents. Compact vertical footprint.</p>
|
||||
|
||||
<div class="w-[300px] rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably
|
||||
</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html</p>
|
||||
|
||||
<div class="mt-2 flex flex-col gap-1">
|
||||
<div class="flex items-center gap-1.5 rounded-md bg-neutral-800/60 py-1 pl-1.5 pr-1 hover:bg-neutral-800">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11px] text-neutral-100">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 rounded-md bg-neutral-800/60 py-1 pl-1.5 pr-1 hover:bg-neutral-800">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ================ Notes ================ -->
|
||||
<div class="mx-auto mt-10 max-w-[900px] rounded-lg bg-neutral-900 p-5 text-[12.5px] leading-6 text-neutral-300 ring-1 ring-neutral-800">
|
||||
<h3 class="mb-2 text-[13px] font-semibold text-neutral-100">What changed across every option</h3>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<li>Agent avatar shrunk: 14px sparkle → 11–12px status glyph. In most options the vendor sparkle is removed entirely; the state glyph carries the signal.</li>
|
||||
<li>No card-in-card. The inner "AGENTS" box with its own border is replaced by a rail, divider, inset panel, tree, or bare list.</li>
|
||||
<li>Indent aligned under the task title, not offset between icons.</li>
|
||||
<li>Row height: 22–24px instead of 30+.</li>
|
||||
</ul>
|
||||
|
||||
<h3 class="mt-4 mb-2 text-[13px] font-semibold text-neutral-100">My pick</h3>
|
||||
<p><strong>A (rail)</strong> or <strong>B (flush + dot-only)</strong>. A still communicates "these belong to the parent" visually. B is the leanest — best if users rarely need to distinguish agent vendor at the child level.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,499 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Worktree cards — stacked list</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner-sm {
|
||||
width: 6px; height: 6px;
|
||||
border: 1.5px solid #eab308;
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-950 p-8 text-neutral-200">
|
||||
|
||||
<div class="mx-auto grid max-w-[1400px] grid-cols-1 gap-8 lg:grid-cols-4">
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- A. Vertical rail -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">A. Vertical rail</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Card breaks appear where agents exist.</p>
|
||||
|
||||
<div class="w-[300px] space-y-2">
|
||||
|
||||
<!-- Card: master (primary, pinned) -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: master w/ agents -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="ml-auto text-amber-400"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">switched x-automation home-timeline fetch to @JinjingLiang…</p>
|
||||
|
||||
<button class="mt-2 ml-[18px] flex items-center gap-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500 hover:text-neutral-300">
|
||||
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
|
||||
Agents · 1
|
||||
</button>
|
||||
<div class="relative mt-1 ml-[18px] pl-3">
|
||||
<span class="absolute left-1 top-1 bottom-1 w-px bg-neutral-800"></span>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2.5" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">rebase remote and…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">3m</span>
|
||||
</div>
|
||||
<div class="pl-5 -mt-0.5 pb-1 truncate text-[10.5px] leading-snug text-neutral-500">Rebased onto origin/master (resolved…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: main, no agents -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">main</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-neutral-400"></span>jinjing-work</span>
|
||||
<span class="truncate text-neutral-500">main</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">finished email triage (processed 35 new emails)…</p>
|
||||
</div>
|
||||
|
||||
<!-- Card: fix-notification-suites w/ 2 agents -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
|
||||
<button class="mt-2 ml-[18px] flex items-center gap-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500 hover:text-neutral-300">
|
||||
<svg viewBox="0 0 24 24" width="9" height="9" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 9l6 6 6-6"/></svg>
|
||||
Agents · 2
|
||||
</button>
|
||||
<div class="relative mt-1 ml-[18px] pl-3">
|
||||
<span class="absolute left-1 top-1 bottom-1 w-px bg-neutral-800"></span>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] font-medium text-neutral-100">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2.5" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: improve-agent-status, 1 agent, no description -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">improve-agent-status-style</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca</span>
|
||||
<span class="truncate text-neutral-500">improve-agent-status-style</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html…</p>
|
||||
</div>
|
||||
|
||||
<!-- Card: feat/full-suite-name -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">feat/full-suite-name-…</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">feat/full-suite-name-and-o…</span>
|
||||
</div>
|
||||
<p class="mt-1.5 flex items-center gap-1 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0 text-emerald-500"><path d="M6 3v12a3 3 0 0 0 3 3h6M9 18l-3-3 3-3"/><circle cx="6" cy="3" r="2"/><circle cx="15" cy="18" r="2"/></svg>
|
||||
PR #153 feat(reporter): remove run…
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- B. Flush, dot-only -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">B. Flush + dot-only</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">No sparkle icons; status dot is enough.</p>
|
||||
|
||||
<div class="w-[300px] space-y-2">
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="ml-auto text-amber-400"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/></svg>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">switched x-automation home-timeline fetch to @JinjingLiang…</p>
|
||||
<div class="mt-2 ml-[18px] border-t border-neutral-800/80 pt-1.5">
|
||||
<div class="mb-0.5 flex items-center justify-between px-1.5">
|
||||
<span class="text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500">Agents</span>
|
||||
<span class="text-[9.5px] text-neutral-600">1</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">rebase remote and…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">3m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">main</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-neutral-400"></span>jinjing-work</span>
|
||||
<span class="truncate text-neutral-500">main</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">finished email triage (processed 35 new emails)…</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-2 ml-[18px] border-t border-neutral-800/80 pt-1.5">
|
||||
<div class="mb-0.5 flex items-center justify-between px-1.5">
|
||||
<span class="text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500">Agents</span>
|
||||
<span class="text-[9.5px] text-neutral-600">2</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500"></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">improve-agent-status-style</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca</span>
|
||||
<span class="truncate text-neutral-500">improve-agent-status-style</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html…</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">feat/full-suite-name-…</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">feat/full-suite-name-and-o…</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">PR #153 feat(reporter): remove run…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- D. Inline summary (collapsed by default) -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">D. Inline summary</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Agents collapse to one line; click to expand.</p>
|
||||
|
||||
<div class="w-[300px] space-y-2">
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">switched x-automation home-timeline fetch to @JinjingLiang…</p>
|
||||
<button class="mt-2 ml-[18px] flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-neutral-800/60">
|
||||
<div class="flex -space-x-0.5">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-neutral-900"></span>
|
||||
</div>
|
||||
<span class="text-[10.5px] text-neutral-400">1 agent · <span class="text-neutral-300">done</span></span>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" class="ml-auto text-neutral-500"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">main</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-neutral-400"></span>jinjing-work</span>
|
||||
<span class="truncate text-neutral-500">main</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">finished email triage (processed 35 new emails)…</p>
|
||||
</div>
|
||||
|
||||
<!-- expanded -->
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<button class="mt-2 ml-[18px] flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-neutral-300 hover:bg-neutral-800/60">
|
||||
<div class="flex -space-x-0.5">
|
||||
<span class="h-2 w-2 rounded-full bg-amber-400 ring-2 ring-neutral-900"></span>
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-neutral-900"></span>
|
||||
</div>
|
||||
<span class="text-[10.5px] text-neutral-400">2 agents · <span class="text-neutral-200">1 running</span></span>
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" class="ml-auto rotate-90 text-neutral-500"><path d="M9 6l6 6-6 6"/></svg>
|
||||
</button>
|
||||
<div class="ml-[18px] space-y-px">
|
||||
<div class="flex items-center gap-2 rounded px-1.5 py-1">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-200">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded px-1.5 py-1">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2.5" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">improve-agent-status-style</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca</span>
|
||||
<span class="truncate text-neutral-500">improve-agent-status-style</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html…</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">feat/full-suite-name-…</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">feat/full-suite-name-and-o…</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">PR #153 feat(reporter): remove run…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- =================================================== -->
|
||||
<!-- G. Merged — no internal separation -->
|
||||
<!-- =================================================== -->
|
||||
<section>
|
||||
<h2 class="mb-1 text-[13px] font-semibold text-neutral-100">G. Merged card</h2>
|
||||
<p class="mb-3 text-[11px] text-neutral-500">Agent rows become peer children of the card. No label, no divider.</p>
|
||||
|
||||
<div class="w-[300px] space-y-2">
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 pb-1.5 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">master</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca-internal</span>
|
||||
<span class="truncate text-neutral-500">master</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">switched x-automation home-timeline fetch to @JinjingLiang…</p>
|
||||
<div class="mt-1 ml-[18px] -mr-1">
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2.5" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">rebase remote and…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">3m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100">main</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-neutral-400"></span>jinjing-work</span>
|
||||
<span class="truncate text-neutral-500">main</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">finished email triage (processed 35 new emails)…</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 pb-1.5 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">fix-notification-suites</span>
|
||||
</div>
|
||||
<div class="mt-1 ml-[18px] -mr-1">
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center"><span class="spinner-sm"></span></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] font-medium text-neutral-100">how likely it is to aff…</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">8m</span>
|
||||
</div>
|
||||
<div class="group flex items-center gap-2 rounded px-1.5 py-1 hover:bg-neutral-800/60">
|
||||
<span class="inline-flex h-2 w-2 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="8" height="8" fill="none" stroke="currentColor" stroke-width="2.5" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[11.5px] text-neutral-400">fix low-severity spam</span>
|
||||
<span class="text-[10px] tabular-nums text-neutral-500">now</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">improve-agent-status-style</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-pink-400"></span>orca</span>
|
||||
<span class="truncate text-neutral-500">improve-agent-status-style</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">drafted agent-row redesign mockup at agent-row-design.html…</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-neutral-900 p-3 ring-1 ring-neutral-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-neutral-600"></span>
|
||||
<span class="text-[13px] font-semibold text-neutral-100 truncate">feat/full-suite-name-…</span>
|
||||
<span class="rounded bg-neutral-800 px-1.5 py-px text-[9.5px] font-medium text-neutral-300">primary</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-1.5 pl-[18px] text-[11px]">
|
||||
<span class="inline-flex h-4 items-center gap-1 rounded bg-neutral-800 px-1.5 text-[10px] text-neutral-300"><span class="h-1.5 w-1.5 rounded-full bg-amber-400"></span>stably</span>
|
||||
<span class="truncate text-neutral-500">feat/full-suite-name-and-o…</span>
|
||||
</div>
|
||||
<p class="mt-1.5 truncate pl-[18px] text-[11px] leading-snug text-neutral-500">PR #153 feat(reporter): remove run…</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mx-auto mt-10 max-w-[900px] rounded-lg bg-neutral-900 p-5 text-[12.5px] leading-6 text-neutral-300 ring-1 ring-neutral-800">
|
||||
<h3 class="mb-2 text-[13px] font-semibold text-neutral-100">Scan test</h3>
|
||||
<p>With a stack of 6 cards: <strong>B</strong> and <strong>G</strong> scan fastest — uniform card height, agents feel like part of the task. <strong>A</strong> adds one extra visual feature (rail) that reads as deliberate hierarchy. <strong>D</strong> wins when lists get long (20+ worktrees) — agents stay one line until you care.</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,224 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Agent row — spacing polish only</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
width: 10px; height: 10px;
|
||||
border: 2px solid #eab308;
|
||||
border-top-color: transparent;
|
||||
border-radius: 9999px;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
.claude { color: #d97706; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-100 p-10 text-neutral-900">
|
||||
<div class="mx-auto max-w-xl space-y-8">
|
||||
|
||||
<!-- ================ BEFORE ================ -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold text-neutral-600">Before</h2>
|
||||
<div class="rounded-md border border-neutral-200 bg-white p-3 shadow-sm">
|
||||
<div class="mb-1 flex items-center gap-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-400">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
AGENTS (3)
|
||||
</div>
|
||||
<div class="divide-y divide-neutral-200/60">
|
||||
<div class="flex flex-col py-0.5">
|
||||
<div class="flex items-center gap-1.5 px-1">
|
||||
<span class="inline-flex h-2.5 w-2.5 items-center justify-center">
|
||||
<span class="spinner"></span>
|
||||
</span>
|
||||
<span class="claude"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg></span>
|
||||
<span class="text-[11px] font-semibold truncate flex-1">yes</span>
|
||||
<span class="text-[10px] text-neutral-400">1m</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-neutral-400"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-neutral-400"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</div>
|
||||
<div class="pl-5 text-[10px] text-neutral-500 truncate">Revert pushed as 610ffbadb2. Gate the migration behind flag.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ================ AFTER — spacing only ================ -->
|
||||
<section>
|
||||
<h2 class="mb-3 text-sm font-semibold text-neutral-600">After — spacing only</h2>
|
||||
|
||||
<!-- Same glyphs as today. Only changed:
|
||||
• row padding py-1.5 px-2 (was py-0.5 px-1)
|
||||
• gap-2 between state / agent / text (was 1.5)
|
||||
• title leading-[18px], subtitle leading-[16px] (was leading-snug/tight)
|
||||
• subtitle indent matches agent icon (pl-[26px])
|
||||
• right cluster gap-1 with time → hover swap, no always-on icons
|
||||
• title normal+muted when visited, semibold+fg when unvisited -->
|
||||
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-2 shadow-sm">
|
||||
|
||||
<button class="group mb-0.5 flex w-full items-center gap-1 rounded px-1.5 py-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-400 hover:text-neutral-600">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
<span>Agents (5)</span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col">
|
||||
|
||||
<!-- WORKING, unvisited -->
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-100/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<span class="spinner"></span>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 claude">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-semibold leading-[18px] text-neutral-900">implement retry logic for websocket disconnects</span>
|
||||
<span class="ml-1 flex shrink-0 items-center gap-1">
|
||||
<span class="text-[10.5px] tabular-nums text-neutral-400 group-hover:hidden">12s</span>
|
||||
<button class="hidden rounded p-0.5 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 group-hover:inline-flex">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
<button class="rounded p-0.5 text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1 pl-[26px] text-[11px] leading-[16px] text-neutral-500">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<span class="shrink-0 font-mono text-[10.5px] text-neutral-600">Bash</span>
|
||||
<span class="truncate text-neutral-400">pnpm test src/ws/*.test.ts --watch</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DONE, visited — existing CircleCheck glyph kept as-is -->
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-100/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 claude">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] leading-[18px] text-neutral-600">Revert pushed as 610ffbadb2. Gate the migration.</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-400">2m</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate pl-[26px] text-[11px] leading-[16px] text-neutral-400">Post-deploy health check passed; ready for review.</div>
|
||||
</div>
|
||||
|
||||
<!-- WAITING / needs attention -->
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-100/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<span class="block h-2 w-2 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 claude">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-semibold leading-[18px] text-neutral-900">Approve running <code class="rounded bg-neutral-100 px-1 font-mono text-[10.5px]">rm -rf dist/</code>?</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-400">just now</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate pl-[26px] text-[11px] leading-[16px] text-red-500/90">Waiting for your input</div>
|
||||
</div>
|
||||
|
||||
<!-- DONE + interrupted tag -->
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-100/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 claude">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] leading-[18px] text-neutral-600">local.env points to staging</span>
|
||||
<span class="shrink-0 rounded bg-rose-500/10 px-1.5 py-px text-[10px] font-medium leading-4 text-rose-600">interrupted</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-400">8m</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate pl-[26px] text-[11px] leading-[16px] text-neutral-400">Post-deploy health check (last 45 minutes)</div>
|
||||
</div>
|
||||
|
||||
<!-- IDLE -->
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-100/80">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<span class="block h-1.5 w-1.5 rounded-full bg-neutral-400/50"></span>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 claude">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg>
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] leading-[18px] text-neutral-400 italic">Idle</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-400">1h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark -->
|
||||
<h2 class="mt-10 mb-3 text-sm font-semibold text-neutral-600">Dark</h2>
|
||||
<div class="rounded-lg border border-neutral-800 bg-neutral-900 p-2 shadow-sm">
|
||||
<div class="mb-0.5 flex items-center gap-1 px-1.5 py-1 text-[9px] font-semibold uppercase tracking-[0.08em] text-neutral-500">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 9l6 6 6-6"/></svg>
|
||||
<span>Agents (3)</span>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-800/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="spinner"></span></span>
|
||||
<span class="inline-flex shrink-0 text-amber-400"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-semibold leading-[18px] text-neutral-100">implement retry logic for websocket disconnects</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-500">12s</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex min-w-0 items-center gap-1 pl-[26px] text-[11px] leading-[16px] text-neutral-500">
|
||||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" class="shrink-0"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
||||
<span class="shrink-0 font-mono text-[10.5px] text-neutral-400">Bash</span>
|
||||
<span class="truncate text-neutral-500">pnpm test src/ws/*.test.ts --watch</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-800/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" class="text-emerald-500"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 text-amber-400"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] leading-[18px] text-neutral-400">Revert pushed as 610ffbadb2. Gate the migration.</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-500">2m</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate pl-[26px] text-[11px] leading-[16px] text-neutral-500">Post-deploy health check passed; ready for review.</div>
|
||||
</div>
|
||||
|
||||
<div class="group flex flex-col rounded-md px-2 py-1.5 hover:bg-neutral-800/60">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex h-3 w-3 shrink-0 items-center justify-center"><span class="block h-2 w-2 rounded-full bg-red-500"></span></span>
|
||||
<span class="inline-flex shrink-0 text-amber-400"><svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2l2.4 7.6L22 12l-7.6 2.4L12 22l-2.4-7.6L2 12l7.6-2.4z"/></svg></span>
|
||||
<span class="min-w-0 flex-1 truncate text-[12px] font-semibold leading-[18px] text-neutral-100">Approve running <code class="rounded bg-neutral-800 px-1 font-mono text-[10.5px]">rm -rf dist/</code>?</span>
|
||||
<span class="ml-1 shrink-0 text-[10.5px] tabular-nums text-neutral-500">just now</span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate pl-[26px] text-[11px] leading-[16px] text-red-400">Waiting for your input</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-lg border border-neutral-200 bg-white p-4 text-[13px] leading-6 text-neutral-700">
|
||||
<h3 class="mb-2 text-sm font-semibold text-neutral-900">Spacing diff</h3>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<li>Row padding <code>py-0.5 px-1</code> → <code>py-1.5 px-2</code>. Rows breathe.</li>
|
||||
<li>Inner gap <code>gap-1.5</code> → <code>gap-2</code>. State dot and agent icon no longer collide.</li>
|
||||
<li>State slot normalized to <code>h-3 w-3</code>; agent icon stays 14px — consistent left edge.</li>
|
||||
<li>Subtitle indent <code>pl-5</code> → <code>pl-[26px]</code> to line up under the agent icon, not between the two glyphs.</li>
|
||||
<li>Title <code>leading-[18px]</code>, subtitle <code>leading-[16px]</code>. Fixes cramped <code>leading-snug</code> on 10–11px text.</li>
|
||||
<li>Dividers removed; hover background separates rows.</li>
|
||||
<li>Always-on dismiss/expand glyphs → hover-only, with 2px chip padding for a real 24×24 hit target.</li>
|
||||
<li>Title weight: <code>font-semibold text-foreground</code> when unvisited or needs-attention, <code>text-muted-foreground</code> normal when read — stops read rows from shouting.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,6 @@
|
||||
const { chmodSync, existsSync, readdirSync } = require('node:fs')
|
||||
const { join } = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
|
||||
|
||||
@@ -35,6 +36,7 @@ module.exports = {
|
||||
'out/cli/**',
|
||||
'out/shared/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/chunks/**',
|
||||
'resources/**',
|
||||
'node_modules/zod/**'
|
||||
@@ -42,7 +44,12 @@ module.exports = {
|
||||
afterPack: async (context) => {
|
||||
const resourcesDir =
|
||||
context.electronPlatformName === 'darwin'
|
||||
? join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
|
||||
? join(
|
||||
context.appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
'Contents',
|
||||
'Resources'
|
||||
)
|
||||
: join(context.appOutDir, 'resources')
|
||||
if (!existsSync(resourcesDir)) {
|
||||
return
|
||||
@@ -56,6 +63,9 @@ module.exports = {
|
||||
// the copied binary to be executable in packaged apps.
|
||||
chmodSync(join(resourcesDir, filename), 0o755)
|
||||
}
|
||||
if (context.electronPlatformName === 'darwin') {
|
||||
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
|
||||
}
|
||||
},
|
||||
win: {
|
||||
executableName: 'Orca',
|
||||
@@ -67,6 +77,10 @@ module.exports = {
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
|
||||
to: 'agent-browser-win32-x64.exe'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-windows/runtime.ps1',
|
||||
to: 'computer-use-windows/runtime.ps1'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -115,6 +129,10 @@ module.exports = {
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-darwin-${arch}',
|
||||
to: 'agent-browser-darwin-${arch}'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
|
||||
to: 'Orca Computer Use.app'
|
||||
}
|
||||
],
|
||||
target: [
|
||||
@@ -134,6 +152,9 @@ module.exports = {
|
||||
dmg: {
|
||||
artifactName: 'orca-macos-${arch}.${ext}'
|
||||
},
|
||||
deb: {
|
||||
depends: ['python3', 'python3-gi', 'gir1.2-atspi-2.0', 'at-spi2-core', 'xdotool', 'xclip']
|
||||
},
|
||||
linux: {
|
||||
extraResources: [
|
||||
{
|
||||
@@ -143,6 +164,10 @@ module.exports = {
|
||||
{
|
||||
from: 'node_modules/agent-browser/bin/agent-browser-linux-${arch}',
|
||||
to: 'agent-browser-linux-${arch}'
|
||||
},
|
||||
{
|
||||
from: 'native/computer-use-linux/runtime.py',
|
||||
to: 'computer-use-linux/runtime.py'
|
||||
}
|
||||
],
|
||||
target: ['AppImage', 'deb'],
|
||||
@@ -165,3 +190,67 @@ module.exports = {
|
||||
releaseType: 'release'
|
||||
}
|
||||
}
|
||||
|
||||
async function signMacComputerUseHelper(helperAppPath, packager) {
|
||||
if (!existsSync(helperAppPath)) {
|
||||
if (isMacRelease) {
|
||||
throw new Error(`Missing Orca Computer Use helper app at ${helperAppPath}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const codeSigningInfo =
|
||||
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
|
||||
? await packager.codeSigningInfo.value
|
||||
: null
|
||||
const identity =
|
||||
process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ??
|
||||
process.env.CSC_NAME ??
|
||||
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
|
||||
(isMacRelease ? null : '-')
|
||||
if (!identity) {
|
||||
throw new Error('Missing signing identity for Orca Computer Use helper app')
|
||||
}
|
||||
// Why: TCC grants attach to this nested app's code identity. Sign it before
|
||||
// the outer Orca.app is sealed so production builds preserve that identity.
|
||||
execFileSync('codesign', codesignArgs(identity, helperAppPath), { stdio: 'inherit' })
|
||||
execFileSync('codesign', ['--verify', '--deep', '--strict', helperAppPath], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
}
|
||||
|
||||
function codesignArgs(identity, targetPath) {
|
||||
const args = ['--force', '--deep', '--sign', identity]
|
||||
if (isMacRelease) {
|
||||
args.push(
|
||||
'--options',
|
||||
'runtime',
|
||||
'--timestamp',
|
||||
'--entitlements',
|
||||
resolve(__dirname, '../resources/build/entitlements.computer-use.mac.plist')
|
||||
)
|
||||
}
|
||||
args.push(targetPath)
|
||||
return args
|
||||
}
|
||||
|
||||
function findInstalledMacSigningIdentity(keychainFile) {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'security',
|
||||
['find-identity', '-v', '-p', 'codesigning', ...(keychainFile ? [keychainFile] : [])],
|
||||
{
|
||||
encoding: 'utf8'
|
||||
}
|
||||
)
|
||||
const releaseMatch =
|
||||
output.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
|
||||
output.match(/"([^"]*Apple Distribution:[^"]+)"/)
|
||||
if (releaseMatch?.[1]) {
|
||||
return releaseMatch[1]
|
||||
}
|
||||
if (!isMacRelease) {
|
||||
return output.match(/"([^"]*Apple Development:[^"]+)"/)?.[1] ?? null
|
||||
}
|
||||
} catch {}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -74,17 +74,276 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..cec8b67aef02f8199e5606a0d257088b
|
||||
var DEFAULT_NAME = 'xterm';
|
||||
var DESTROY_SOCKET_TIMEOUT_MS = 200;
|
||||
diff --git a/src/unix/pty.cc b/src/unix/pty.cc
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..c17decbb7bc06f10d7b5d26317bd68fdc8c4e339 100644
|
||||
index 7b4b9e1f990fbf95b51528bb56dc9717f5b87532..61f39f0cbb91faa2c515f35d2ca850564e6368d9 100644
|
||||
--- a/src/unix/pty.cc
|
||||
+++ b/src/unix/pty.cc
|
||||
@@ -778,8 +778,8 @@ done:
|
||||
posix_spawn_file_actions_destroy(&acts);
|
||||
posix_spawnattr_destroy(&attrs);
|
||||
@@ -23,7 +23,9 @@
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
+#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
+#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <sys/types.h>
|
||||
@@ -237,13 +239,23 @@ pty_getproc(int, char *);
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) || defined(__OpenBSD__)
|
||||
+struct pty_spawn_error {
|
||||
+ const char* step;
|
||||
+ int errnum;
|
||||
+ std::string detail_name;
|
||||
+ std::string detail_value;
|
||||
+};
|
||||
+
|
||||
+static std::string
|
||||
+pty_format_spawn_error(const pty_spawn_error&);
|
||||
+
|
||||
static void
|
||||
pty_posix_spawn(char** argv, char** env,
|
||||
const struct termios *termp,
|
||||
const struct winsize *winp,
|
||||
int* master,
|
||||
pid_t* pid,
|
||||
- int* err);
|
||||
+ pty_spawn_error* err);
|
||||
#endif
|
||||
|
||||
struct DelBuf {
|
||||
@@ -367,10 +379,11 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) {
|
||||
argv[i + 3] = strdup(arg.c_str());
|
||||
}
|
||||
|
||||
- int err = -1;
|
||||
- pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err);
|
||||
- if (err != 0) {
|
||||
- throw Napi::Error::New(napiEnv, "posix_spawnp failed.");
|
||||
+ pty_spawn_error spawn_error = { NULL, 0, "", "" };
|
||||
+ pty_posix_spawn(argv, env, term, &winp, &master, &pid, &spawn_error);
|
||||
+ if (spawn_error.errnum != 0) {
|
||||
+ std::string spawn_message = pty_format_spawn_error(spawn_error);
|
||||
+ throw Napi::Error::New(napiEnv, spawn_message);
|
||||
}
|
||||
if (pty_nonblock(master) == -1) {
|
||||
throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking.");
|
||||
@@ -684,15 +697,73 @@ pty_getproc(int fd, char *tty) {
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__)
|
||||
+static const char*
|
||||
+pty_errno_name(int errnum) {
|
||||
+ switch (errnum) {
|
||||
+ case E2BIG: return "E2BIG";
|
||||
+ case EACCES: return "EACCES";
|
||||
+ case EAGAIN: return "EAGAIN";
|
||||
+ case EMFILE: return "EMFILE";
|
||||
+ case ENFILE: return "ENFILE";
|
||||
+ case ENOENT: return "ENOENT";
|
||||
+ case ENOMEM: return "ENOMEM";
|
||||
+ default: return "errno";
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
+static void
|
||||
+pty_set_spawn_error(pty_spawn_error* err,
|
||||
+ const char* step,
|
||||
+ int errnum,
|
||||
+ const char* detail_name = NULL,
|
||||
+ const char* detail_value = NULL) {
|
||||
+ err->step = step;
|
||||
+ err->errnum = errnum;
|
||||
+ err->detail_name = detail_name ? detail_name : "";
|
||||
+ err->detail_value = detail_value ? detail_value : "";
|
||||
+}
|
||||
+
|
||||
+static std::string
|
||||
+pty_format_spawn_error(const pty_spawn_error& err) {
|
||||
+ char errno_buf[64];
|
||||
+ snprintf(errno_buf, sizeof(errno_buf), "%d", err.errnum);
|
||||
+
|
||||
+ std::string message = "node-pty: ";
|
||||
+ message += err.step ? err.step : "unknown";
|
||||
+ message += " failed: ";
|
||||
+ message += pty_errno_name(err.errnum);
|
||||
+ message += " (errno ";
|
||||
+ message += errno_buf;
|
||||
+ message += ", ";
|
||||
+ message += strerror(err.errnum);
|
||||
+ message += ")";
|
||||
+
|
||||
+ if (!err.detail_name.empty()) {
|
||||
+ message += " - ";
|
||||
+ message += err.detail_name;
|
||||
+ message += "='";
|
||||
+ message += err.detail_value;
|
||||
+ message += "'";
|
||||
+ }
|
||||
+
|
||||
+ return message;
|
||||
+}
|
||||
+
|
||||
static void
|
||||
pty_posix_spawn(char** argv, char** env,
|
||||
const struct termios *termp,
|
||||
const struct winsize *winp,
|
||||
int* master,
|
||||
pid_t* pid,
|
||||
- int* err) {
|
||||
- int low_fds[3];
|
||||
+ pty_spawn_error* err) {
|
||||
+ int low_fds[3] = {-1, -1, -1};
|
||||
size_t count = 0;
|
||||
+ int res = -1;
|
||||
+ int slave = -1;
|
||||
+ posix_spawn_file_actions_t acts;
|
||||
+ bool acts_initialized = false;
|
||||
+ posix_spawnattr_t attrs;
|
||||
+ bool attrs_initialized = false;
|
||||
|
||||
for (; count < 3; count++) {
|
||||
low_fds[count] = posix_openpt(O_RDWR);
|
||||
@@ -706,80 +777,118 @@ pty_posix_spawn(char** argv, char** env,
|
||||
POSIX_SPAWN_SETSID;
|
||||
*master = posix_openpt(O_RDWR);
|
||||
if (*master == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "posix_openpt", errno);
|
||||
+ goto done;
|
||||
+ }
|
||||
+
|
||||
+ res = grantpt(*master);
|
||||
+ if (res == -1) {
|
||||
+ pty_set_spawn_error(err, "grantpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
- int res = grantpt(*master) || unlockpt(*master);
|
||||
+ res = unlockpt(*master);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "unlockpt", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
// Use TIOCPTYGNAME instead of ptsname() to avoid threading problems.
|
||||
- int slave;
|
||||
char slave_pty_name[128];
|
||||
res = ioctl(*master, TIOCPTYGNAME, slave_pty_name);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "ioctl_TIOCPTYGNAME", errno);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
slave = open(slave_pty_name, O_RDWR | O_NOCTTY);
|
||||
if (slave == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "open_slave", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
}
|
||||
|
||||
if (termp) {
|
||||
res = tcsetattr(slave, TCSANOW, termp);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "tcsetattr", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
};
|
||||
}
|
||||
|
||||
if (winp) {
|
||||
res = ioctl(slave, TIOCSWINSZ, winp);
|
||||
if (res == -1) {
|
||||
- return;
|
||||
+ pty_set_spawn_error(err, "ioctl_TIOCSWINSZ", errno, "slave", slave_pty_name);
|
||||
+ goto done;
|
||||
}
|
||||
}
|
||||
|
||||
- posix_spawn_file_actions_t acts;
|
||||
- posix_spawn_file_actions_init(&acts);
|
||||
+ res = posix_spawn_file_actions_init(&acts);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawn_file_actions_init", res);
|
||||
+ goto done;
|
||||
+ }
|
||||
+ acts_initialized = true;
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO);
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO);
|
||||
posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO);
|
||||
posix_spawn_file_actions_addclose(&acts, slave);
|
||||
posix_spawn_file_actions_addclose(&acts, *master);
|
||||
|
||||
- posix_spawnattr_t attrs;
|
||||
- posix_spawnattr_init(&attrs);
|
||||
- *err = posix_spawnattr_setflags(&attrs, flags);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_init(&attrs);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_init", res);
|
||||
+ goto done;
|
||||
+ }
|
||||
+ attrs_initialized = true;
|
||||
+ res = posix_spawnattr_setflags(&attrs, flags);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setflags", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
sigset_t signal_set;
|
||||
/* Reset all signal the child to their default behavior */
|
||||
sigfillset(&signal_set);
|
||||
- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_setsigdefault(&attrs, &signal_set);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigdefault", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
/* Reset the signal mask for all signals */
|
||||
sigemptyset(&signal_set);
|
||||
- *err = posix_spawnattr_setsigmask(&attrs, &signal_set);
|
||||
- if (*err != 0) {
|
||||
+ res = posix_spawnattr_setsigmask(&attrs, &signal_set);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawnattr_setsigmask", res);
|
||||
goto done;
|
||||
}
|
||||
|
||||
do
|
||||
- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
|
||||
- while (*err == EINTR);
|
||||
+ res = posix_spawn(pid, argv[0], &acts, &attrs, argv, env);
|
||||
+ while (res == EINTR);
|
||||
+ if (res != 0) {
|
||||
+ pty_set_spawn_error(err, "posix_spawn", res, "helper", argv[0]);
|
||||
+ }
|
||||
done:
|
||||
- posix_spawn_file_actions_destroy(&acts);
|
||||
- posix_spawnattr_destroy(&attrs);
|
||||
+ if (acts_initialized) {
|
||||
+ posix_spawn_file_actions_destroy(&acts);
|
||||
+ }
|
||||
+ if (attrs_initialized) {
|
||||
+ posix_spawnattr_destroy(&attrs);
|
||||
+ }
|
||||
+ if (slave != -1) {
|
||||
+ close(slave);
|
||||
+ }
|
||||
+ if (err->errnum != 0 && *master != -1) {
|
||||
+ close(*master);
|
||||
+ *master = -1;
|
||||
+ }
|
||||
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
+ close(low_fds[i]);
|
||||
+ if (low_fds[i] != -1) {
|
||||
+ close(low_fds[i]);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { chmodSync, copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const packagePath = path.join(repoRoot, 'native', 'computer-use-macos')
|
||||
const binaryPath = path.join(packagePath, '.build', 'release', 'orca-computer-use-macos')
|
||||
const appPath = path.join(packagePath, '.build', 'release', 'Orca Computer Use.app')
|
||||
const appExecutablePath = path.join(appPath, 'Contents', 'MacOS', 'orca-computer-use-macos')
|
||||
const appIconPath = path.join(appPath, 'Contents', 'Resources', 'AppIcon.icns')
|
||||
const entitlementsPath = path.join(
|
||||
repoRoot,
|
||||
'resources',
|
||||
'build',
|
||||
'entitlements.computer-use.mac.plist'
|
||||
)
|
||||
const bundleId = process.env.ORCA_COMPUTER_MACOS_BUNDLE_ID ?? 'com.stablyai.orca.computer-use'
|
||||
const displayName = 'Orca Computer Use'
|
||||
const signingIdentity = resolveSigningIdentity()
|
||||
const universalTriples = ['arm64-apple-macosx', 'x86_64-apple-macosx']
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
buildUniversalBinary()
|
||||
chmodSync(binaryPath, 0o755)
|
||||
createHelperApp()
|
||||
|
||||
function buildUniversalBinary() {
|
||||
const builtBinaries = universalTriples.map((triple) => {
|
||||
run('swift', ['build', '-c', 'release', '--package-path', packagePath, '--triple', triple])
|
||||
return path.join(packagePath, '.build', triple, 'release', 'orca-computer-use-macos')
|
||||
})
|
||||
mkdirSync(path.dirname(binaryPath), { recursive: true })
|
||||
run('lipo', ['-create', ...builtBinaries, '-output', binaryPath])
|
||||
}
|
||||
|
||||
function createHelperApp() {
|
||||
rmSync(appPath, { recursive: true, force: true })
|
||||
mkdirSync(path.dirname(appExecutablePath), { recursive: true })
|
||||
mkdirSync(path.join(appPath, 'Contents', 'Resources'), { recursive: true })
|
||||
copyFileSync(binaryPath, appExecutablePath)
|
||||
copyFileSync(path.join(repoRoot, 'resources', 'build', 'icon.icns'), appIconPath)
|
||||
chmodSync(appExecutablePath, 0o755)
|
||||
writeFileSync(path.join(appPath, 'Contents', 'Info.plist'), infoPlist(), 'utf8')
|
||||
const signer = spawnSync('codesign', codesignArgs(signingIdentity, appPath), { stdio: 'inherit' })
|
||||
if (signer.signal) {
|
||||
process.kill(process.pid, signer.signal)
|
||||
}
|
||||
if (signer.status !== 0) {
|
||||
process.exit(signer.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function codesignArgs(identity, targetPath) {
|
||||
const args = ['--force', '--deep', '--sign', identity]
|
||||
if (process.env.ORCA_MAC_RELEASE === '1' && identity !== '-') {
|
||||
args.push('--options', 'runtime', '--timestamp', '--entitlements', entitlementsPath)
|
||||
}
|
||||
args.push(targetPath)
|
||||
return args
|
||||
}
|
||||
|
||||
function resolveSigningIdentity() {
|
||||
const explicitIdentity = process.env.ORCA_COMPUTER_MACOS_SIGN_IDENTITY ?? process.env.CSC_NAME
|
||||
if (explicitIdentity) {
|
||||
return explicitIdentity
|
||||
}
|
||||
const identities = spawnSync('security', ['find-identity', '-v', '-p', 'codesigning'], {
|
||||
encoding: 'utf8'
|
||||
})
|
||||
if (identities.status !== 0 || !identities.stdout) {
|
||||
return '-'
|
||||
}
|
||||
const developmentMatch = identities.stdout.match(/"([^"]*Apple Development:[^"]+)"/)
|
||||
if (process.env.ORCA_MAC_RELEASE !== '1' && developmentMatch) {
|
||||
return developmentMatch[1]
|
||||
}
|
||||
const releaseMatch =
|
||||
identities.stdout.match(/"([^"]*Developer ID Application:[^"]+)"/) ??
|
||||
identities.stdout.match(/"([^"]*Apple Distribution:[^"]+)"/)
|
||||
return releaseMatch?.[1] ?? developmentMatch?.[1] ?? '-'
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
const result = spawnSync(command, args, { stdio: 'inherit' })
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal)
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1)
|
||||
}
|
||||
}
|
||||
|
||||
function infoPlist() {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>orca-computer-use-macos</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${escapePlist(bundleId)}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>${escapePlist(displayName)}</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>${escapePlist(displayName)}</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSAccessibilityUsageDescription</key>
|
||||
<string>Orca Computer Use needs Accessibility permission to read and interact with app interfaces when you ask Orca to use apps.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>Orca Computer Use needs Screen Recording permission to capture app windows when you ask Orca to inspect your screen.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
}
|
||||
|
||||
function escapePlist(value) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const cliPath = resolve(repoRoot, 'out', 'cli', 'index.js')
|
||||
const args = new Set(process.argv.slice(2))
|
||||
const requestedApps = valueFlag('--apps')
|
||||
const includeScreenshot = args.has('--screenshot')
|
||||
const session = valueFlag('--session') ?? `computer-smoke-${process.pid}`
|
||||
const preferredApps = (requestedApps ?? process.env.ORCA_COMPUTER_SMOKE_APPS ?? 'Finder,TextEdit,Spotify,Slack,Microsoft Edge')
|
||||
.split(',')
|
||||
.map((app) => app.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (!existsSync(cliPath)) {
|
||||
fail(`Missing built CLI at ${cliPath}. Run pnpm build:cli first.`)
|
||||
}
|
||||
|
||||
const list = unwrapResult(runCli(['computer', 'list-apps', '--json']))
|
||||
const apps = Array.isArray(list.apps) ? list.apps : []
|
||||
const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase()))
|
||||
const availableBundles = new Set(apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean))
|
||||
const targets = preferredApps.filter((app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase()))
|
||||
|
||||
console.log(`computer-use smoke: ${apps.length} apps listed`)
|
||||
if (targets.length === 0) {
|
||||
console.log(`computer-use smoke: no preferred apps are running (${preferredApps.join(', ')})`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
for (const app of targets) {
|
||||
const result = runCli([
|
||||
'computer',
|
||||
'get-app-state',
|
||||
'--session',
|
||||
session,
|
||||
'--app',
|
||||
app,
|
||||
...(includeScreenshot ? [] : ['--no-screenshot']),
|
||||
'--json'
|
||||
], { allowFailure: true })
|
||||
|
||||
if (!result.ok) {
|
||||
failures += 1
|
||||
console.log(`computer-use smoke: ${app}: failed: ${result.error}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const state = unwrapResult(result.value)
|
||||
const snapshot = state.snapshot
|
||||
const treeText = String(snapshot.treeText ?? '')
|
||||
const lineCount = treeText.split('\n').filter(Boolean).length
|
||||
const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length
|
||||
const settable = (treeText.match(/\bsettable\b/g) ?? []).length
|
||||
const screenshotState = state.screenshot ? `${state.screenshot.width}x${state.screenshot.height}` : 'missing'
|
||||
console.log([
|
||||
`computer-use smoke: ${snapshot.app.name}`,
|
||||
`${snapshot.elementCount} elements`,
|
||||
`${lineCount} lines`,
|
||||
`${secondaryActions} secondary-action lines`,
|
||||
`${settable} settable elements`,
|
||||
`screenshot=${screenshotState}`
|
||||
].join(' | '))
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
fail(`${failures} app snapshot smoke check(s) failed`)
|
||||
}
|
||||
|
||||
function valueFlag(name) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index === -1) {
|
||||
return null
|
||||
}
|
||||
return process.argv[index + 1] ?? null
|
||||
}
|
||||
|
||||
function runCli(cliArgs, options = {}) {
|
||||
const child = spawnSync(process.execPath, [cliPath, ...cliArgs], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_USER_DATA_PATH: process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
|
||||
}
|
||||
})
|
||||
if (child.status !== 0) {
|
||||
const error = (child.stderr || child.stdout || `exit ${child.status}`).trim()
|
||||
if (options.allowFailure) {
|
||||
return { ok: false, error }
|
||||
}
|
||||
fail(error)
|
||||
}
|
||||
try {
|
||||
return options.allowFailure
|
||||
? { ok: true, value: JSON.parse(child.stdout) }
|
||||
: JSON.parse(child.stdout)
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
fail(`Could not parse CLI JSON for ${cliArgs.join(' ')}: ${detail}\n${child.stdout}`)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultDevUserDataPath() {
|
||||
if (process.platform === 'darwin') {
|
||||
return resolve(homedir(), 'Library', 'Application Support', 'orca-dev')
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return resolve(process.env.APPDATA ?? resolve(homedir(), 'AppData', 'Roaming'), 'orca-dev')
|
||||
}
|
||||
return resolve(process.env.XDG_CONFIG_HOME ?? resolve(homedir(), '.config'), 'orca-dev')
|
||||
}
|
||||
|
||||
function unwrapResult(value) {
|
||||
if (value && typeof value === 'object' && 'result' in value) {
|
||||
return value.result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
console.error(`computer-use smoke: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# Launch `pn dev` with a fresh, isolated userData profile so the app behaves
|
||||
# like a first-time install (onboarding overlay paints, no persisted repos,
|
||||
# no saved sessions). Your real `orca-dev` profile is left untouched.
|
||||
#
|
||||
# Usage:
|
||||
# ./config/scripts/dev-fresh-profile.sh # ephemeral temp profile, deleted on exit
|
||||
# ./config/scripts/dev-fresh-profile.sh --keep # keep the profile dir after exit
|
||||
# ORCA_FRESH_PROFILE_DIR=/some/path ./config/scripts/dev-fresh-profile.sh # use a fixed dir
|
||||
set -euo pipefail
|
||||
|
||||
KEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep) KEEP=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: $0 [--keep] [--help]" >&2
|
||||
exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PROFILE_DIR="${ORCA_FRESH_PROFILE_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/orca-fresh-profile.XXXXXXXX")}"
|
||||
mkdir -p "$PROFILE_DIR"
|
||||
|
||||
cleanup() {
|
||||
if [[ "$KEEP" -eq 0 && -z "${ORCA_FRESH_PROFILE_DIR:-}" ]]; then
|
||||
# Guard rm -rf against accidental empty/unrelated PROFILE_DIR.
|
||||
[[ -n "${PROFILE_DIR:-}" && -d "$PROFILE_DIR" && "$PROFILE_DIR" == */orca-fresh-profile* ]] || return 0
|
||||
rm -rf "$PROFILE_DIR"
|
||||
echo "[dev-fresh-profile] removed $PROFILE_DIR"
|
||||
else
|
||||
echo "[dev-fresh-profile] kept $PROFILE_DIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "[dev-fresh-profile] using userData=$PROFILE_DIR"
|
||||
# Don't exec — we need the EXIT trap to fire so the temp profile gets cleaned up.
|
||||
ORCA_DEV_USER_DATA_PATH="$PROFILE_DIR" pnpm dev
|
||||
@@ -20,7 +20,8 @@
|
||||
*/
|
||||
|
||||
import { rebuild } from '@electron/rebuild'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const projectDir = process.cwd()
|
||||
@@ -41,6 +42,40 @@ if (ignoreModules.length > 0) {
|
||||
const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
|
||||
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
|
||||
|
||||
// Why: cpu-features ships without `buildcheck.gypi`; its own `install` script
|
||||
// generates it by running `node buildcheck.js > buildcheck.gypi` before
|
||||
// node-gyp. @electron/rebuild with `force: true` invokes node-gyp directly
|
||||
// and bypasses that install hook, so if the file is missing (fresh install,
|
||||
// store prune, or a prior failed run) node-gyp aborts with
|
||||
// "buildcheck.gypi not found". Regenerate it here before rebuilding.
|
||||
if (!ignoreModules.includes('cpu-features')) {
|
||||
const cpuFeatureDirs = globSync(
|
||||
'node_modules/.pnpm/cpu-features@*/node_modules/cpu-features',
|
||||
{ cwd: projectDir },
|
||||
)
|
||||
for (const relDir of cpuFeatureDirs) {
|
||||
const dir = resolve(projectDir, relDir)
|
||||
const gypiPath = resolve(dir, 'buildcheck.gypi')
|
||||
if (existsSync(gypiPath)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const out = execFileSync(process.execPath, ['buildcheck.js'], {
|
||||
cwd: dir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
writeFileSync(gypiPath, out)
|
||||
console.log(`[rebuild] Generated ${relDir}/buildcheck.gypi`)
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error(
|
||||
`[rebuild] Failed to generate ${relDir}/buildcheck.gypi:`,
|
||||
err?.message ?? err,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await rebuild({
|
||||
buildPath: projectDir,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -61,7 +63,83 @@ if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
|
||||
const electronViteCli =
|
||||
process.env.ORCA_ELECTRON_VITE_CLI ||
|
||||
path.join(path.dirname(require.resolve('electron-vite/package.json')), 'bin', 'electron-vite.js')
|
||||
const forwardedArgs = ['dev', ...process.argv.slice(2)]
|
||||
|
||||
// Why: every `pn dev` should be attachable from agent-browser/playwright-cli
|
||||
// without manual port juggling. Pick a best-effort deterministic port per
|
||||
// worktree; falls back to a probe sweep if the deterministic pick or its
|
||||
// neighbors are busy (multiple worktrees may share a machine).
|
||||
const forwardedRaw = process.argv.slice(2)
|
||||
function isPortFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer()
|
||||
srv.once('error', () => {
|
||||
// Why: error fires before listen binds; close() may throw — swallow it
|
||||
// so the handle is released without leaking listeners across 64 probes.
|
||||
try {
|
||||
srv.close()
|
||||
} catch {}
|
||||
resolve(false)
|
||||
})
|
||||
srv.once('listening', () => srv.close(() => resolve(true)))
|
||||
srv.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
async function pickDebugPort() {
|
||||
// Why: 32 bits of SHA1 (vs 16) reduces truncation bias; modulo 200 still
|
||||
// collides routinely across many worktrees, hence the probe sweep below.
|
||||
const seed = parseInt(createHash('sha1').update(repoRoot).digest('hex').slice(0, 8), 16)
|
||||
const base = 9333 + (seed % 200) // deterministic base in 9333..9532; probe sweeps up to base+63
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const p = base + i
|
||||
if (await isPortFree(p)) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
function parseDebugPortEnv(raw) {
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535 || String(n) !== raw.trim()) {
|
||||
return null
|
||||
}
|
||||
return n
|
||||
}
|
||||
// Why: exact match (or `=` form) avoids false positives on hypothetical
|
||||
// `--remote-debugging-port-*` flags; the bare flag also covers the
|
||||
// space-separated form. `--remote-debugging-pipe` opts into pipe-based
|
||||
// debugging — don't fight the user's choice by injecting a port.
|
||||
const userPassedPort = forwardedRaw.some(
|
||||
(a) => a === '--remote-debugging-port' || a.startsWith('--remote-debugging-port=') || a === '--remote-debugging-pipe'
|
||||
)
|
||||
// Why: --help/--version exit immediately; binding a probe socket and printing
|
||||
// a debug-port line would be noise.
|
||||
const isHelpOrVersion = forwardedRaw.some((a) => a === '--help' || a === '-h' || a === '--version')
|
||||
let forwardedExtras = []
|
||||
if (!userPassedPort && !isHelpOrVersion) {
|
||||
const envPortRaw = process.env.REMOTE_DEBUGGING_PORT
|
||||
let port = null
|
||||
if (envPortRaw) {
|
||||
port = parseDebugPortEnv(envPortRaw)
|
||||
if (port === null) {
|
||||
console.error(
|
||||
`[orca-dev] Ignoring invalid REMOTE_DEBUGGING_PORT=${JSON.stringify(envPortRaw)}; falling back to probe.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (port === null) {
|
||||
port = await pickDebugPort()
|
||||
}
|
||||
if (port !== null) {
|
||||
forwardedExtras = [`--remote-debugging-port=${port}`]
|
||||
// Why: stderr keeps stdout clean for downstream parsing; log uses
|
||||
// 127.0.0.1 to match the interface we actually probed (localhost may
|
||||
// resolve to ::1 on IPv6-first hosts).
|
||||
console.error(`[orca-dev] Remote debugging on http://127.0.0.1:${port}`)
|
||||
} else {
|
||||
console.error('[orca-dev] No free debug port found in sweep; starting without --remote-debugging-port.')
|
||||
}
|
||||
}
|
||||
const forwardedArgs = ['dev', ...forwardedRaw, ...forwardedExtras]
|
||||
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..', '..')
|
||||
const checks = [
|
||||
{
|
||||
name: 'macOS Swift renderer/provider tests',
|
||||
command: 'swift',
|
||||
args: ['test', '--package-path', 'native/computer-use-macos'],
|
||||
enabled: process.platform === 'darwin'
|
||||
},
|
||||
{
|
||||
name: 'Linux provider Python syntax',
|
||||
command: 'python3',
|
||||
args: ['-m', 'py_compile', 'native/computer-use-linux/runtime.py'],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'Linux provider imports',
|
||||
command: 'python3',
|
||||
args: [
|
||||
'-c',
|
||||
[
|
||||
'import importlib.util',
|
||||
'spec=importlib.util.spec_from_file_location("orca_linux","native/computer-use-linux/runtime.py")',
|
||||
'module=importlib.util.module_from_spec(spec)',
|
||||
'spec.loader.exec_module(module)',
|
||||
'print("import-ok")'
|
||||
].join(';')
|
||||
],
|
||||
enabled: process.platform === 'linux'
|
||||
},
|
||||
{
|
||||
name: 'Windows provider PowerShell parse',
|
||||
command: process.platform === 'win32' ? 'powershell.exe' : 'pwsh',
|
||||
args: [
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
[
|
||||
'$errors=$null',
|
||||
'$tokens=$null',
|
||||
'[System.Management.Automation.Language.Parser]::ParseFile("native/computer-use-windows/runtime.ps1",[ref]$tokens,[ref]$errors) > $null',
|
||||
'if ($errors.Count) { $errors | Format-List *; exit 1 }',
|
||||
'"parse-ok"'
|
||||
].join('; ')
|
||||
],
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
name: 'Windows provider handshake',
|
||||
run: verifyWindowsProviderHandshake,
|
||||
enabled: process.platform === 'win32'
|
||||
},
|
||||
{
|
||||
name: 'macOS helper app bundle and signature',
|
||||
run: verifyMacOSHelperApp,
|
||||
enabled: process.platform === 'darwin'
|
||||
}
|
||||
]
|
||||
|
||||
let failed = false
|
||||
for (const check of checks) {
|
||||
if (!check.enabled) {
|
||||
console.log(`[computer-native] skip ${check.name}`)
|
||||
continue
|
||||
}
|
||||
if (check.run) {
|
||||
console.log(`[computer-native] ${check.name}`)
|
||||
if (!check.run()) {
|
||||
failed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!hasCommand(check.command)) {
|
||||
console.log(`[computer-native] skip ${check.name}: ${check.command} not found`)
|
||||
continue
|
||||
}
|
||||
console.log(`[computer-native] ${check.name}`)
|
||||
const result = spawnSync(check.command, check.args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
if (result.status !== 0 || result.error) {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function hasCommand(command) {
|
||||
if (process.platform === 'win32') {
|
||||
const result = spawnSync('where.exe', [command], { stdio: 'ignore' })
|
||||
return result.status === 0
|
||||
}
|
||||
if (existsSync(command)) {
|
||||
return true
|
||||
}
|
||||
const result = spawnSync('/bin/sh', ['-lc', `command -v ${quoteShell(command)}`], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
return result.status === 0
|
||||
}
|
||||
|
||||
function verifyMacOSHelperApp() {
|
||||
const appPath = join(
|
||||
repoRoot,
|
||||
'native',
|
||||
'computer-use-macos',
|
||||
'.build',
|
||||
'release',
|
||||
'Orca Computer Use.app'
|
||||
)
|
||||
if (!existsSync(appPath)) {
|
||||
console.error(
|
||||
`[computer-native] missing helper app at ${appPath}; run pnpm build:computer-macos`
|
||||
)
|
||||
return false
|
||||
}
|
||||
return run('codesign', ['--verify', '--deep', '--strict', appPath])
|
||||
}
|
||||
|
||||
function verifyWindowsProviderHandshake() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-computer-use-verify-'))
|
||||
const operationPath = join(dir, 'operation.json')
|
||||
try {
|
||||
writeFileSync(operationPath, JSON.stringify({ tool: 'handshake' }), { mode: 0o600 })
|
||||
const result = spawnSync(
|
||||
'powershell.exe',
|
||||
[
|
||||
'-NoLogo',
|
||||
'-NoProfile',
|
||||
'-ExecutionPolicy',
|
||||
'Bypass',
|
||||
'-File',
|
||||
'native/computer-use-windows/runtime.ps1',
|
||||
operationPath
|
||||
],
|
||||
{ cwd: repoRoot, encoding: 'utf8' }
|
||||
)
|
||||
if (result.status !== 0 || result.error) {
|
||||
process.stderr.write(result.stderr ?? '')
|
||||
return false
|
||||
}
|
||||
const response = JSON.parse(result.stdout.trim())
|
||||
if (response.ok === true && response.capabilities?.protocolVersion === 1) {
|
||||
console.log('[computer-native] windows-handshake-ok')
|
||||
return true
|
||||
}
|
||||
console.error(`[computer-native] invalid Windows handshake response: ${result.stdout}`)
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
return false
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
if (!hasCommand(command)) {
|
||||
console.log(`[computer-native] skip ${command}: command not found`)
|
||||
return true
|
||||
}
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
return result.status === 0 && !result.error
|
||||
}
|
||||
|
||||
function quoteShell(value) {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Asserts that the telemetry compile-time constants — `ORCA_BUILD_IDENTITY`
|
||||
// and `ORCA_POSTHOG_WRITE_KEY` — were substituted into the shipped binary.
|
||||
// Closes the gap PR #1385 flagged: a release built without those env vars
|
||||
// produces an `IS_OFFICIAL_BUILD === false` binary that ships silently and
|
||||
// transmits nothing. This script runs after `electron-builder` has packed
|
||||
// the app and BEFORE the GitHub release is flipped from draft → published.
|
||||
// A failure here fails the matrix job; the draft release stays in place
|
||||
// until a human resolves it.
|
||||
//
|
||||
// We grep the packed `app.asar`'s `out/main/*.js` files (the same files
|
||||
// that the runtime loads) rather than the unpacked `out/`. asar is a
|
||||
// tar-like archive that doesn't transform contents, so the two are
|
||||
// byte-equivalent today — but verifying the asar protects against any
|
||||
// future config change that excludes the main bundle from the package.
|
||||
//
|
||||
// Forward-compat: while `TELEMETRY_ENABLED` is `false` in the source, the
|
||||
// bundler dead-code-eliminates the entire transport block, so the
|
||||
// `BUILD_IDENTITY = "..."` and `WRITE_KEY = "phc_..."` constants do not
|
||||
// appear in the binary even when env vars are correctly injected. This
|
||||
// script reads the source flag at build time and skips the assertion in
|
||||
// that case — the moment the flag flips to `true` (PR #1385), every
|
||||
// subsequent release is enforced.
|
||||
//
|
||||
// Cross-platform note: written in Node so it runs identically on the Mac,
|
||||
// Linux, and Windows release runners. Locating `app.asar` via `fs.readdir`
|
||||
// (instead of POSIX `find`) avoids depending on Git Bash on Windows.
|
||||
// Reading the asar via the programmatic `@electron/asar` API (instead of
|
||||
// shelling out to `npx asar`) avoids both a network fetch on every run
|
||||
// and the Windows `.cmd`-shim/`shell: true` workaround.
|
||||
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
// Why @electron/asar: canonical replacement for the deprecated `asar` package.
|
||||
// It's transitively available via electron-builder (and pnpm's
|
||||
// `shamefully-hoist=true` in `.npmrc` flattens it into the root
|
||||
// `node_modules`). If electron-builder ever drops it, promote this to a
|
||||
// direct devDependency in package.json.
|
||||
import { extractFile, listPackage } from '@electron/asar'
|
||||
|
||||
// Why resolve from import.meta.url instead of cwd: a release runner (or a
|
||||
// developer debugging locally) may invoke this script from a non-root cwd.
|
||||
// Resolving relative to the script's own location turns a misleading
|
||||
// "could not parse TELEMETRY_ENABLED flag" parse error into a clear
|
||||
// file-not-found error, and decouples the script from the caller's cwd.
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
|
||||
function findAsar(rootDir) {
|
||||
// Why: electron-builder produces one `app.asar` per platform-arch combo.
|
||||
// Linux/Windows targets ship one (`dist/linux-unpacked/resources/app.asar`,
|
||||
// `dist/win-unpacked/resources/app.asar`); macOS dual-arch ships two
|
||||
// (`dist/mac/Orca.app/Contents/Resources/app.asar` for x64,
|
||||
// `dist/mac-arm64/Orca.app/Contents/Resources/app.asar` for arm64) because
|
||||
// `electron-builder.config.cjs` declares `arch: ['x64', 'arm64']`. Both
|
||||
// arches share the same JS bundle through electron-vite's single `main`
|
||||
// build, so the constants are identical across them — but verifying every
|
||||
// match catches the regression where one arch's pack drifts (e.g. a future
|
||||
// arch-specific bundle split that forgets to thread the `define` block).
|
||||
const matches = []
|
||||
const stack = [rootDir]
|
||||
while (stack.length > 0) {
|
||||
const dir = stack.pop()
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
stack.push(fullPath)
|
||||
} else if (entry.isFile() && entry.name === 'app.asar') {
|
||||
matches.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
const distDir = process.argv[2] ?? 'dist'
|
||||
if (!existsSync(distDir) || !statSync(distDir).isDirectory()) {
|
||||
console.error(`::error::dist directory not found at ${distDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Why: with `TELEMETRY_ENABLED = false` in source, Rollup eliminates the
|
||||
// transport block and the substituted constants vanish from the binary.
|
||||
// Verifying in that state would always fail. Read the flag from source
|
||||
// instead of inferring it from the build, so the gate cannot drift.
|
||||
const clientSrcPath = join(repoRoot, 'src/main/telemetry/client.ts')
|
||||
const clientSrc = readFileSync(clientSrcPath, 'utf8')
|
||||
const enabledMatch = /^const\s+TELEMETRY_ENABLED\s*=\s*(true|false)/m.exec(clientSrc)
|
||||
if (!enabledMatch) {
|
||||
console.error(`::error::could not parse TELEMETRY_ENABLED flag from ${clientSrcPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
if (enabledMatch[1] === 'false') {
|
||||
console.log(
|
||||
'TELEMETRY_ENABLED is false in source — transport is dead-code-eliminated, ' +
|
||||
'so the BUILD_IDENTITY/WRITE_KEY constants are not expected in the binary. ' +
|
||||
'Skipping asar grep. (Once the flag flips to true, this verify becomes enforcing.)'
|
||||
)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const asarMatches = findAsar(distDir)
|
||||
if (asarMatches.length === 0) {
|
||||
console.error(`::error::could not locate app.asar under ${distDir}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Found ${asarMatches.length} app.asar payload(s) under ${distDir}:`)
|
||||
for (const m of asarMatches) {
|
||||
console.log(` - ${m}`)
|
||||
}
|
||||
|
||||
// Why these regexes: electron-vite's `define` block substitutes the bare
|
||||
// identifiers `ORCA_BUILD_IDENTITY` and `ORCA_POSTHOG_WRITE_KEY` with their
|
||||
// JSON-stringified values at build time. `src/main/telemetry/client.ts`
|
||||
// then assigns those into module-local consts named `BUILD_IDENTITY` and
|
||||
// `WRITE_KEY`. electron-vite's main config is not minified (Vite default for
|
||||
// Electron main builds), so Rollup emits the substituted constants verbatim
|
||||
// as `const BUILD_IDENTITY = "stable";`. Match that exact emitted shape so a
|
||||
// regression — e.g. the env var unset and the substitution falling back to
|
||||
// literal `null` — fails the grep instead of slipping through as a falsy-
|
||||
// but-stringy value. NOTE: if `build.minify` is ever enabled on the main
|
||||
// bundle, esbuild/terser will rename top-level consts and this regex must
|
||||
// be revisited (or replaced with a value-based assertion).
|
||||
//
|
||||
// WRITE_KEY char class includes `_` and `-` because PostHog project API
|
||||
// keys use URL-safe base64 alphabet beyond `phc_`.
|
||||
const BUILD_IDENTITY_RE = /const\s+BUILD_IDENTITY\s*=\s*"(rc|stable)"/
|
||||
const WRITE_KEY_RE = /const\s+WRITE_KEY\s*=\s*"(phc_[A-Za-z0-9_-]+)"/
|
||||
|
||||
function verifyAsar(asarPath) {
|
||||
console.log(`Verifying ${asarPath}`)
|
||||
|
||||
// Why list-then-extract (not a hardcoded path): future electron-vite
|
||||
// chunking (e.g. `manualChunks`) could move the constants out of
|
||||
// `out/main/index.js` into `out/main/chunks/telemetry-XYZ.js`. Enumerate
|
||||
// every `.js` under `out/main/` and concatenate before grepping so the
|
||||
// verify is resilient to chunking. listPackage builds entries with the
|
||||
// host `path` module, so on Windows runners separators are backslashes
|
||||
// (`\out\main\index.js`); normalize to forward slashes for the filter
|
||||
// and strip the leading separator (of either kind) before passing the
|
||||
// entry to extractFile.
|
||||
const allEntries = listPackage(asarPath)
|
||||
const mainJsEntries = allEntries.filter((p) => {
|
||||
const normalized = p.replace(/\\/g, '/').replace(/^\/+/, '')
|
||||
return normalized.startsWith('out/main/') && normalized.endsWith('.js')
|
||||
})
|
||||
|
||||
if (mainJsEntries.length === 0) {
|
||||
console.error(`::error::no .js files found under out/main/ in ${asarPath}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const indexJs = mainJsEntries
|
||||
.map((entry) => {
|
||||
// extractFile uses the host path module internally; pass the
|
||||
// host-separator path with the leading separator stripped.
|
||||
const internal = entry.replace(/^[\\/]+/, '')
|
||||
return extractFile(asarPath, internal).toString('utf8')
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const buildIdentityMatch = BUILD_IDENTITY_RE.exec(indexJs)
|
||||
const writeKeyMatch = WRITE_KEY_RE.exec(indexJs)
|
||||
|
||||
if (!buildIdentityMatch) {
|
||||
console.error(
|
||||
`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`
|
||||
)
|
||||
const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? []
|
||||
for (const line of sample) {
|
||||
console.error(` ${line.slice(0, 200)}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (!writeKeyMatch) {
|
||||
console.error(`::error::PostHog WRITE_KEY missing from ${asarPath}`)
|
||||
const sample = indexJs.match(/.{0,80}WRITE_KEY.{0,80}/g)?.slice(0, 5) ?? []
|
||||
for (const line of sample) {
|
||||
console.error(` ${line.slice(0, 200)}`)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return { asarPath, buildIdentity: buildIdentityMatch[1], writeKey: writeKeyMatch[1] }
|
||||
}
|
||||
|
||||
// Why verify every match (not just the first): macOS dual-arch produces one
|
||||
// asar per arch from the same `out/main` bundle, so identical constants are
|
||||
// expected — but if a future change introduces an arch-specific bundle split
|
||||
// (or a per-arch `electron-vite build` invocation) that forgets to thread
|
||||
// the `define` values, only one of the asars would carry the constants and
|
||||
// the broken arch would ship transmitting nothing. Looping is cheap insurance
|
||||
// against that class of regression. We additionally require every asar to
|
||||
// agree on the BUILD_IDENTITY and WRITE_KEY values — a mismatch means the
|
||||
// matrix shipped inconsistent build-identity claims, or (worse) per-arch
|
||||
// PostHog keys that would split events across projects, both of which are
|
||||
// release bugs.
|
||||
const results = []
|
||||
for (const asarPath of asarMatches) {
|
||||
const result = verifyAsar(asarPath)
|
||||
if (!result) {
|
||||
process.exit(1)
|
||||
}
|
||||
results.push(result)
|
||||
}
|
||||
|
||||
const distinctIdentities = new Set(results.map((r) => r.buildIdentity))
|
||||
if (distinctIdentities.size > 1) {
|
||||
console.error(
|
||||
`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`
|
||||
)
|
||||
for (const r of results) {
|
||||
console.error(` - ${r.asarPath}: ${r.buildIdentity}`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const distinctWriteKeys = new Set(results.map((r) => r.writeKey))
|
||||
if (distinctWriteKeys.size > 1) {
|
||||
console.error(`::error::asars disagree on WRITE_KEY across arches`)
|
||||
for (const r of results) {
|
||||
console.error(` - ${r.asarPath}: ${r.writeKey.slice(0, 8)}... (length=${r.writeKey.length})`)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const [first] = results
|
||||
console.log(
|
||||
`Telemetry constants verified across ${results.length} asar(s): ` +
|
||||
`BUILD_IDENTITY="${first.buildIdentity}", ` +
|
||||
`WRITE_KEY="${first.writeKey.slice(0, 8)}..." (length=${first.writeKey.length})`
|
||||
)
|
||||
@@ -10,6 +10,6 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts']
|
||||
include: ['src/**/*.test.ts', 'src/**/*.test.tsx']
|
||||
}
|
||||
})
|
||||
|
||||
+16
-3
@@ -31,7 +31,7 @@
|
||||
Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -40,15 +40,16 @@ Orca es compatible con cualquier agente CLI (*no solo los de esta lista*).
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -96,6 +97,18 @@ yay -S stably-orca-git
|
||||
|
||||
---
|
||||
|
||||
## [Nuevo] Ver el estado en vivo de todos los agents
|
||||
|
||||
**Ve qué hace cada agent — de un vistazo.**
|
||||
|
||||
Sigue el estado en vivo de cada agent en cada worktree desde un solo lugar. Detecta qué agents están trabajando, esperando entrada o terminados — sin tener que ir clicando por las pestañas.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/agent-statuses.gif" alt="Orca Agent Statuses — ve el estado en vivo de todos los agents en los worktrees" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [Nuevo] Anotar diff de IA
|
||||
|
||||
**Comenta directamente sobre los diffs generados por IA.**
|
||||
|
||||
+16
-3
@@ -31,7 +31,7 @@
|
||||
Orca は任意の CLI エージェントに対応しています(*このリストに限定されません*)。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -40,15 +40,16 @@ Orca は任意の CLI エージェントに対応しています(*このリス
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -96,6 +97,18 @@ yay -S stably-orca-git
|
||||
|
||||
---
|
||||
|
||||
## [新機能] すべての agent のライブステータスを表示
|
||||
|
||||
**各 agent が何をしているか、ひと目で把握。**
|
||||
|
||||
すべての worktree にまたがる各 agent のライブステータスを 1 か所で追跡できます。タブを切り替えなくても、作業中・入力待ち・完了済みの agent を一目で把握できます。
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/agent-statuses.gif" alt="Orca Agent Statuses — worktree をまたいだ agent のライブステータスを表示" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [新機能] AI Diff への注釈
|
||||
|
||||
**AI が生成した Diff に直接コメントできます。**
|
||||
|
||||
+16
-3
@@ -31,7 +31,7 @@
|
||||
Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
|
||||
|
||||
<p>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="https://www.google.com/s2/favicons?domain=anthropic.com&sz=64" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a>
|
||||
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a>
|
||||
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a>
|
||||
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" width="16" valign="middle" /> Pi</kbd></a>
|
||||
@@ -40,15 +40,16 @@ Orca 支持任何 CLI 智能体(*不仅限于以下列表*)。
|
||||
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" width="16" valign="middle" /> Goose</kbd></a>
|
||||
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" width="16" valign="middle" /> Amp</kbd></a>
|
||||
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" width="16" valign="middle" /> Auggie</kbd></a>
|
||||
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" width="16" valign="middle" /> Autohand Code</kbd></a>
|
||||
<a href="https://github.com/charmbracelet/crush"><kbd><img src="https://www.google.com/s2/favicons?domain=charm.sh&sz=64" width="16" valign="middle" /> Charm</kbd></a>
|
||||
<a href="https://docs.cline.bot/cline-cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=cline.bot&sz=64" width="16" valign="middle" /> Cline</kbd></a>
|
||||
<a href="https://www.codebuff.com/docs/help/quick-start"><kbd><img src="https://www.google.com/s2/favicons?domain=codebuff.com&sz=64" width="16" valign="middle" /> Codebuff</kbd></a>
|
||||
<a href="https://docs.continue.dev/guides/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=continue.dev&sz=64" width="16" valign="middle" /> Continue</kbd></a>
|
||||
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" width="16" valign="middle" /> Cursor</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="https://www.google.com/s2/favicons?domain=factory.ai&sz=64" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.factory.ai/cli/getting-started/quickstart"><kbd><img src="assets/droid-logo.svg" width="16" valign="middle" /> Droid</kbd></a>
|
||||
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" width="16" valign="middle" /> GitHub Copilot</kbd></a>
|
||||
<a href="https://kilo.ai/docs/cli"><kbd><img src="https://raw.githubusercontent.com/Kilo-Org/kilocode/main/packages/kilo-vscode/assets/icons/kilo-light.svg" width="16" valign="middle" /> Kilocode</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-cli/guides/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://www.kimi.com/code/docs/en/kimi-code-cli/getting-started.html"><kbd><img src="https://www.google.com/s2/favicons?domain=moonshot.cn&sz=64" width="16" valign="middle" /> Kimi</kbd></a>
|
||||
<a href="https://kiro.dev/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=kiro.dev&sz=64" width="16" valign="middle" /> Kiro</kbd></a>
|
||||
<a href="https://github.com/mistralai/mistral-vibe"><kbd><img src="https://www.google.com/s2/favicons?domain=mistral.ai&sz=64" width="16" valign="middle" /> Mistral Vibe</kbd></a>
|
||||
<a href="https://github.com/QwenLM/qwen-code"><kbd><img src="https://www.google.com/s2/favicons?domain=qwenlm.github.io&sz=64" width="16" valign="middle" /> Qwen Code</kbd></a>
|
||||
@@ -96,6 +97,18 @@ yay -S stably-orca-git
|
||||
|
||||
---
|
||||
|
||||
## [全新] 查看所有 agent 的实时状态
|
||||
|
||||
**一眼看清每个 agent 在做什么。**
|
||||
|
||||
在一个地方追踪所有 worktree 中每个 agent 的实时状态。无需点开各个标签页,即可发现哪些 agent 在工作、等待输入或已完成。
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/agent-statuses.gif" alt="Orca Agent Statuses — 查看所有 worktree 中 agent 的实时状态" width="800" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## [全新] 标注 AI Diff
|
||||
|
||||
**直接在 AI 生成的 diff 上评论。**
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
# Orca UI Style Guide
|
||||
|
||||
This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is *not* an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the *roles and rules* for using them.
|
||||
|
||||
## Overview
|
||||
|
||||
Orca is an Electron desktop app for orchestrating coding agents across git worktrees. The visual identity is **monochrome and quiet** — neutral grays carry the chrome, color is reserved for state (selection ring, destructive, git decorations). The product spends most of its time hosting other people's tools (Monaco, xterm, Markdown previews), so Orca's own UI should recede and frame.
|
||||
|
||||
When in doubt:
|
||||
|
||||
- Reach for **muted/accent/border** before reaching for color.
|
||||
- Reach for **CSS variables** before hardcoding hex.
|
||||
- Match the nearest **shadcn primitive** before writing custom CSS.
|
||||
|
||||
## Source of truth
|
||||
|
||||
| Concern | Canonical location |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) |
|
||||
| Tailwind theme bindings | Same file, `@theme inline { … }` block |
|
||||
| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) |
|
||||
| App typography / scrollbars / titlebar chrome | Same `main.css` |
|
||||
|
||||
Never hardcode a hex value in component code if a variable already covers it. If a new token is needed, add it to `main.css` (both `:root` and `.dark`), expose it in the `@theme inline` block, then use it.
|
||||
|
||||
## Color roles
|
||||
|
||||
Tokens come in pairs: a **surface** and a **foreground** that meets contrast on it. Always use them together.
|
||||
|
||||
| Role | Use it for | Don't use it for |
|
||||
| ---------------------------------- | ------------------------------------------------------------- | --------------------------------------------- |
|
||||
| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) |
|
||||
| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself |
|
||||
| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI |
|
||||
| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions |
|
||||
| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action |
|
||||
| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions |
|
||||
| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) |
|
||||
| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) |
|
||||
| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` |
|
||||
| `input` | Form field background only | Anywhere outside form fields |
|
||||
| `ring` | Focus-visible outlines, active selection halos | Persistent decoration |
|
||||
| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels |
|
||||
| `editor-surface` | Background of Monaco / markdown editor panes | App chrome |
|
||||
|
||||
The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, and `--sidebar-ring` — use them inside the worktree sidebar so its hover/selected/focus states stay consistent and don't bleed into other panels. `editor-surface` is its own token (not just `background`) because Monaco and the markdown editor have a slightly darker surface in dark mode to match VS Code conventions; reach for it whenever you're rendering an editor pane.
|
||||
|
||||
### Git decoration colors
|
||||
|
||||
For diff status, file-tree decorations, and the changes view, use the git decoration tokens (mirroring VS Code's palette so users transferring from VS Code aren't surprised):
|
||||
|
||||
| Token | State |
|
||||
| ------------------------------ | ------------------------------ |
|
||||
| `--git-decoration-added` | Added / new |
|
||||
| `--git-decoration-modified` | Modified |
|
||||
| `--git-decoration-deleted` | Deleted |
|
||||
| `--git-decoration-renamed` | Renamed |
|
||||
| `--git-decoration-untracked` | Untracked |
|
||||
| `--git-decoration-copied` | Copied |
|
||||
|
||||
Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention.
|
||||
|
||||
### List rows: hover, selected, current
|
||||
|
||||
A common point of drift. Use these conventions for any list-style row (worktrees, command palette items, settings nav):
|
||||
|
||||
- **Idle:** transparent background.
|
||||
- **Hover:** `bg-accent` (in the worktree sidebar, `bg-sidebar-accent`).
|
||||
- **Keyboard-selected (cmdk highlight):** `data-[selected=true]:bg-accent` plus a `border-border` outline so the active row stays visible while the user types. The `data-selected` attribute is set by `cmdk` automatically.
|
||||
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, *plus* a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
|
||||
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
|
||||
|
||||
### Color mixing
|
||||
|
||||
When you need a tint (e.g. a 12% primary wash on hover), use `color-mix` against the existing token, not a new hex:
|
||||
|
||||
```css
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--background));
|
||||
```
|
||||
|
||||
This keeps light/dark parity automatic.
|
||||
|
||||
## Typography
|
||||
|
||||
- **Family:** `Geist` is loaded as a single variable woff2 (weight range 100–900). Always reach for `Geist` for sans, never `Inter` or system sans.
|
||||
- **Mono:** `var(--font-mono)` — used for paths, terminal-adjacent UI, code, and anywhere monospace conveys "this is literal."
|
||||
- **Body letter-spacing:** `0.01em` (set globally on `body`). Don't override per component.
|
||||
- **Sizes:** Tailwind's default scale. Common sizes in this repo:
|
||||
- 11px (uppercase meta, sidebar headers, captions) — pair with `font-weight: 600` and `text-transform: uppercase` and `letter-spacing: 0.05em` for category labels.
|
||||
- 12px (sub-text, paths, secondary content)
|
||||
- 13px (sidebar items, dense list rows)
|
||||
- 14px (default body, button text in `default` size)
|
||||
|
||||
## Radius
|
||||
|
||||
`--radius: 0.625rem` (10px) is the base; the rest are computed (`--radius-sm` = 0.6×, `--radius-md` = 0.8×, `--radius-lg` = 1×, `--radius-xl` = 1.4×, etc.). Buttons and inputs use `rounded-md`; the `Card` primitive uses `rounded-xl`; badges use `rounded-full`. Match the existing primitive's radius rather than introducing a new one.
|
||||
|
||||
## Elevation & shadows
|
||||
|
||||
Orca uses shadows sparingly. Three levels in practice:
|
||||
|
||||
1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level.
|
||||
2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards.
|
||||
3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved.
|
||||
|
||||
Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead.
|
||||
|
||||
## Components
|
||||
|
||||
Use the shadcn primitives in `src/renderer/src/components/ui/` before writing anything custom. The shadcn-style wrappers in this folder follow a consistent pattern:
|
||||
|
||||
- Most carry a `data-slot="<name>"` attribute on their root for CSS targeting — do not strip it. (The non-shadcn helpers in this folder — `sonner`, `repo-multi-combobox`, `team-multi-combobox` — don't follow this pattern and shouldn't be modeled when adding new primitives that should.)
|
||||
- Use `cn()` for class merging. Pass user `className` last so callers can override.
|
||||
- Use `class-variance-authority` (CVA) for variants when there are multiple.
|
||||
|
||||
### Buttons (`button.tsx`)
|
||||
|
||||
Variants in priority order:
|
||||
|
||||
| Variant | Use case |
|
||||
| ------------- | ---------------------------------------------------------------- |
|
||||
| `default` | The single affirmative action in a flow. |
|
||||
| `secondary` | Lower-emphasis sibling next to a `default`. |
|
||||
| `outline` | Toolbar / standalone actions where a filled button feels heavy. |
|
||||
| `ghost` | Icon buttons, list-row triggers, anywhere chrome should disappear. |
|
||||
| `link` | Inline text actions inside paragraphs. |
|
||||
| `destructive` | Delete, discard, irreversible. Never for Cancel. |
|
||||
|
||||
Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `icon-xs`, `icon-sm`, `icon-lg`. Match the size to the surrounding row height — don't drop a `default` button into a 28px toolbar.
|
||||
|
||||
### Other primitives in this repo
|
||||
|
||||
Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI primitive — exceptions are `command` (wraps `cmdk`), `sonner` (wraps `sonner`), and the visual-only wrappers (`badge`, `button-group`, `card`, `input`) which apply tokens and Tailwind utilities directly. Never reimplement headless behavior; extend the existing wrapper.
|
||||
|
||||
### Picking the right primitive
|
||||
|
||||
When a control has multiple plausible primitives, use this fork:
|
||||
|
||||
| You want… | Reach for | Don't use |
|
||||
| -------------------------------------------------------- | -------------------------- | ---------------------------------------- |
|
||||
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
|
||||
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
|
||||
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
|
||||
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
|
||||
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
|
||||
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
|
||||
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
|
||||
| Single choice from a known list | `Select` | Custom listbox |
|
||||
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
|
||||
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
|
||||
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
|
||||
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
|
||||
|
||||
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
|
||||
|
||||
### Tooltips
|
||||
|
||||
Tooltips exist to *name* a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI.
|
||||
|
||||
- **Use a tooltip when:** an icon-only button or compact chip needs a label. Toolbar icons, badges with abbreviations, truncated paths.
|
||||
- **Don't use a tooltip when:** the control already has a visible label, the content is interactive (links, buttons), or the message is critical (errors, blocking warnings — those go inline).
|
||||
- **Mounting:** the global `<TooltipProvider delayDuration={400}>` lives at the App root. Don't nest a second `TooltipProvider` unless you need a different delay for a tightly-scoped surface.
|
||||
- **Trigger pattern:** wrap the trigger element with `<TooltipTrigger asChild>` so the tooltip's accessibility props attach to the button (not a wrapper span). This is required for keyboard focus to surface the tooltip.
|
||||
- **Placement:** default `side="top" sideOffset={4}` — match the toolbar pattern in `sidebar/SidebarToolbar.tsx`. Pick a different side only when the default would clip against the viewport.
|
||||
- **Shortcut chips inside tooltips:** if the action has a keyboard shortcut, append `<ShortcutKeyCombo />` rather than baking the keys into the label string. The chips render correctly per platform; baked-in strings drift.
|
||||
|
||||
```tsx
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon-sm" onClick={openSettings}>
|
||||
<Settings />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>Settings</TooltipContent>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
### Icons
|
||||
|
||||
Icons come from **`lucide-react`**. Don't import a second icon library.
|
||||
|
||||
- **Default size:** `size-4` (16px). `Button` auto-applies this to any `<svg>` it contains via `[&_svg:not([class*='size-'])]:size-4`, so most call sites don't need to set a size on the icon.
|
||||
- **`size-3` / `size-3.5`:** for metadata, captions, and dense list rows where 16px is too loud.
|
||||
- **`size-7`+:** for featured/empty-state hero icons only.
|
||||
- **Stroke width:** lucide's default 2px. Don't override per-icon.
|
||||
- **Color:** inherit from surrounding text — `text-muted-foreground` for secondary, `text-destructive` for destructive, etc. Don't apply a token to the SVG directly when the parent already carries the right color.
|
||||
- **Spinner:** the canonical loading icon is `<Loader2 className="size-4 animate-spin" />`. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See *UX rule 1*.
|
||||
|
||||
### Keyboard shortcut chips
|
||||
|
||||
Use **`<ShortcutKeyCombo />`** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the *caller* picks the platform-appropriate labels and passes them in:
|
||||
|
||||
```tsx
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const mod = isMac ? '⌘' : 'Ctrl'
|
||||
const shift = isMac ? '⇧' : 'Shift'
|
||||
<ShortcutKeyCombo keys={[mod, shift, 'N']} />
|
||||
```
|
||||
|
||||
See `Landing.tsx` for the canonical pattern. Don't roll a one-off `<kbd>` — kbd chips drift in shape and color across the app fast if everyone styles their own.
|
||||
|
||||
**Where shortcuts surface in the UI:**
|
||||
|
||||
- **Tooltips on icon buttons** — append the chip after the label, trailing.
|
||||
- **Dropdown / context-menu items** — use `<DropdownMenuShortcut>` (or its context-menu equivalent) for the right-aligned chip; don't position one yourself.
|
||||
- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see *UX rule 3*.
|
||||
|
||||
**The label MUST match the actual binding for the platform.** If the keyboard handler reads `metaKey` on Mac and `ctrlKey` elsewhere, the chip must show `⌘` on Mac and `Ctrl` elsewhere. Mismatched chips are worse than no chip.
|
||||
|
||||
### Form anatomy
|
||||
|
||||
The pattern in `src/renderer/src/components/settings/SettingsFormControls.tsx` is the house style for any label + control + helper text. Match it for new forms:
|
||||
|
||||
- **Outer stack:** `space-y-3` for full-section forms (`ThemePicker`); `space-y-2` for compact single-control fields (`ColorField`, `NumberField`). Pick by density, not preference.
|
||||
- **Label group:** `space-y-1` containing `<Label>` and a description in `text-xs text-muted-foreground`.
|
||||
- **Control:** the shadcn primitive (`<Input>`, `<Select>`, etc.). Errors surface via `aria-invalid`; the input primitive already maps that to a destructive ring — don't paint your own.
|
||||
- **Trailing metadata:** `text-[11px] text-muted-foreground` below the control (e.g., "Current: 14px · Default: 13px"), not next to the label.
|
||||
|
||||
### Scrollbars
|
||||
|
||||
Three scrollbar classes are defined globally in `main.css`:
|
||||
|
||||
- **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover.
|
||||
- **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces.
|
||||
- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still.
|
||||
|
||||
Apply one of these to overflow containers; don't write a fourth style.
|
||||
|
||||
## UX rules
|
||||
|
||||
These are the rules a contributor will most often get wrong if they're working in isolation. They apply to every UI change.
|
||||
|
||||
### 1. Match in-flight feedback to perceived duration
|
||||
|
||||
The right question isn't *"should this control change while it's working?"* — it's *"how long does the action take, and what does the user need to know during that time?"*
|
||||
|
||||
| Duration | Feedback |
|
||||
| ------------------- | ------------------------------------------------------- |
|
||||
| 0–100 ms | None. Anything visible reads as a glitch. |
|
||||
| 100 ms–1 s | Disabled state only. |
|
||||
| 1 s–3 s | Disabled + spinner or label swap. |
|
||||
| 3 s+ or multi-step | Stage labels, progress, optional reassurance. |
|
||||
|
||||
Two corollaries:
|
||||
|
||||
- **Pre-reserve any space you'll later occupy.** If a control may swap to a longer label or grow an icon, fix its footprint up front (use `width`, not `min-width`). A control that resizes mid-action looks broken even when the action succeeded.
|
||||
- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the *disabled* state immediately (so double-clicks don't double-submit) and the *visible* state on a timer.
|
||||
|
||||
### 2. Look for sibling components before designing in isolation
|
||||
|
||||
If your component has a sibling — same domain, overlapping behavior, often visible at adjacent moments in the same flow — the two should read as one design. Same icons, same shortcut conventions, same submit semantics. A user moving between them shouldn't perceive a seam.
|
||||
|
||||
This is *not* "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about *adjacent* components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it).
|
||||
|
||||
When there's no sibling, match the surrounding chrome — button sizes, icon weights, copy tone — and don't manufacture a sibling from a screen the user will never correlate with this one.
|
||||
|
||||
### 3. Don't overload the back-out path
|
||||
|
||||
`destructive` is for actions that lose data or can't be undone. **Cancel, Dismiss, Close, and Discard are not destructive** — they back the user out of an in-progress action and should stay quiet (default ghost button, no color, no keyboard chip, no animated affordance). Save the visual weight for the affirmative action so the two don't compete. Keyboard handlers can still honor Esc; the visible decoration is what stays minimal.
|
||||
|
||||
## Cross-platform
|
||||
|
||||
Orca runs on macOS, Linux, and Windows. Every UI change must hold up on all three, in both light and dark mode.
|
||||
|
||||
- **Modifier keys:** Never hardcode `e.metaKey`. Use `navigator.userAgent.includes('Mac')` to choose `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
|
||||
- **Shortcut labels:** Display `⌘` / `⇧` on Mac; display `Ctrl+` / `Shift+` on other platforms. The label must reflect the actual binding for that platform.
|
||||
- **Window chrome:** macOS shows traffic lights; the titlebar reserves an 80px gutter (`titlebar-traffic-light-pad`) so they don't overlap content. Don't put hit targets in that band on Mac.
|
||||
- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See *UX rules → 1*.
|
||||
|
||||
## When this guide is silent
|
||||
|
||||
If you have a UI question this doc doesn't answer:
|
||||
|
||||
1. Look at adjacent code in `src/renderer/src/components/` for the closest sibling, and follow its lead.
|
||||
2. Check `src/renderer/src/components/ui/` for a primitive that already encodes the pattern.
|
||||
3. If it's a token question, `main.css` is canonical — use what's there, or add a new one in both light and dark.
|
||||
4. If none of those resolve it, ask the user before inventing.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
|
||||
<!-- Claude starburst glyph — matches the in-app ClaudeIcon in src/renderer/src/components/status-bar/icons.tsx so README and app agree. -->
|
||||
<path fill="#D97757" fill-rule="nonzero" d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1,138 +0,0 @@
|
||||
# Back/Forward support for the Tasks page
|
||||
|
||||
## Goal
|
||||
|
||||
Make the titlebar back/forward buttons and the `Cmd/Ctrl+Alt+←/→` shortcut
|
||||
traverse Tasks visits in addition to worktree activations. Today both are
|
||||
no-ops outside `activeView === 'terminal'` (see `App.tsx:616`, `App.tsx:900`).
|
||||
|
||||
## Current shape
|
||||
|
||||
- History lives in `src/renderer/src/store/slices/worktree-nav-history.ts`.
|
||||
- Entries are `string[]` (worktree IDs), recorded from
|
||||
`worktree-activation.ts:96` via `recordWorktreeVisit`.
|
||||
- `goBackWorktree`/`goForwardWorktree` skip dead worktrees via
|
||||
`findPrev/NextLiveWorktreeHistoryIndex`, gated by an `activator` ref and
|
||||
`isNavigatingHistory` to prevent re-recording.
|
||||
- UI (pre-change): titlebar buttons hidden unless
|
||||
`activeView === 'terminal'`; shortcut ignored outside terminal view.
|
||||
|
||||
## Minimal implementation (downscoped)
|
||||
|
||||
1. **History entry type** → `string | 'tasks'`.
|
||||
- `findPrev/NextLiveWorktreeHistoryIndex` must short-circuit on `'tasks'`
|
||||
before calling `findWorktreeById` (which takes a worktree id, not a
|
||||
view tag) — Tasks entries are unconditionally live.
|
||||
- Keep `recordWorktreeVisit(worktreeId: string)` signature to avoid churn
|
||||
in `terminals-hydration.test.ts`. Add a sibling
|
||||
`recordViewVisit(entry: 'tasks')` that shares the dedupe/truncate/cap
|
||||
logic.
|
||||
- Keep existing names (`worktreeNavHistory`, `goBackWorktree`,
|
||||
`canGoBackWorktreeHistory`). They're now slight misnomers since
|
||||
entries may be `'tasks'`, but renaming touches ~20 call sites for no
|
||||
behavioral win. Add a one-line comment at the top of the slice
|
||||
stating the entry type and the intentional keeping of the name, so
|
||||
future readers don't assume it's worktree-only.
|
||||
|
||||
2. **Record Tasks visits** → call `recordViewVisit('tasks')` from
|
||||
`openTaskPage` (`ui.ts:189`). No `isNavigatingHistory` guard needed:
|
||||
back-to-Tasks routes through `setActiveView('tasks')` per step 3, which
|
||||
never touches `openTaskPage`. The slice's existing adjacent-entry dedupe
|
||||
covers any other re-entry. Note: `openTaskPage` is called with varying
|
||||
`taskPageData` (e.g. `{ taskSource: 'github' }` vs `'linear'` from
|
||||
`SidebarNav.tsx:88,102`); all collapse to a single `'tasks'` entry and
|
||||
dedupe against the current entry, so toggling presets produces no extra
|
||||
history entries. This is consistent with the out-of-scope "no per-entry
|
||||
snapshotting" decision.
|
||||
|
||||
3. **Dispatch in goBack/goForward** → based on entry kind:
|
||||
- worktreeId → existing `activator(worktreeId)` path. When current entry
|
||||
is `'tasks'`, no extra view handling is needed:
|
||||
`activateAndRevealWorktree` at `worktree-activation.ts:82-84` already
|
||||
switches `activeView` back to `'terminal'`.
|
||||
- `'tasks'` → `setActiveView('tasks')` (not `openTaskPage` — avoids
|
||||
mutating `previousViewBeforeTasks` and the SWR prefetch).
|
||||
|
||||
4. **Fix Esc-close history desync.** `closeTaskPage` (`ui.ts:210`) currently
|
||||
sets `activeView` to `previousViewBeforeTasks` without touching the
|
||||
history index, so after `A → Tasks → Esc` the index still points at the
|
||||
`'tasks'` entry and Back becomes a visual no-op (activator re-activates A)
|
||||
while Forward re-opens Tasks. Fix: in `closeTaskPage`, if the current
|
||||
history entry is `'tasks'`, move the index to the previous live entry
|
||||
(same scan as `findPrevLiveWorktreeHistoryIndex`). Sub-case: if there is
|
||||
no previous live entry (e.g. user's first action was open Tasks, then
|
||||
Esc — history `['tasks']` at index 0), leave the index unchanged at 0.
|
||||
Accept the minor cost (Back becomes a visual no-op until a real visit
|
||||
records a new entry) rather than setting to -1, which would lose the
|
||||
only forward target. Guard with `isNavigatingHistory` is unnecessary
|
||||
here — `closeTaskPage` is never invoked from the history path.
|
||||
|
||||
5. **Unhide UI on Tasks** (allowlist, not denylist — `activeView` union is
|
||||
`'terminal' | 'settings' | 'tasks'`, but an allowlist won't silently
|
||||
include future views):
|
||||
- Replace the `activeView !== 'terminal'` early-return at `App.tsx:616`
|
||||
with `activeView !== 'terminal' && activeView !== 'tasks'`. Update the
|
||||
adjacent comment (`App.tsx:613-615`) — it currently claims the
|
||||
shortcut is a no-op outside terminal because the buttons are hidden;
|
||||
replace with: back/forward traverse worktree + Tasks visits, so the
|
||||
shortcut is active whenever the button cluster is (terminal or
|
||||
Tasks); still suppressed elsewhere (Settings).
|
||||
- Change the titlebar cluster guard at `App.tsx:900` to
|
||||
`activeView === 'terminal' || activeView === 'tasks'`. Update the
|
||||
adjacent comment (`App.tsx:896-899`) — drop the "terminal view only"
|
||||
framing; explain the cluster is shown wherever the history shortcut
|
||||
is live, and hidden in Settings to keep that view modal-ish.
|
||||
|
||||
6. **Tests**: extend `worktree-nav-history.test.ts` for mixed entries:
|
||||
- `A → Tasks → B`, back lands on Tasks, back again on A.
|
||||
- Tasks dedupe against current entry.
|
||||
- Dead worktree between Tasks entries is skipped.
|
||||
- `A → Tasks → closeTaskPage()`: index moves back to A; subsequent Back
|
||||
is a no-op, Forward re-opens Tasks.
|
||||
- `Tasks (only entry) → closeTaskPage()`: index stays at 0, Back is a
|
||||
visual no-op.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Settings in history.** Stays modal-ish; closes via
|
||||
`previousViewBeforeSettings`.
|
||||
- **Per-entry `taskPageData` snapshotting.** Back-to-Tasks opens Tasks with
|
||||
default filters/source. Revisit if users complain.
|
||||
- **Session persistence of history.** Keep in-memory only (it already is).
|
||||
|
||||
## Edge cases handled
|
||||
|
||||
- Worktree deletion mid-session → skipped by existing live-check; Tasks
|
||||
entries always live.
|
||||
- Dedupe semantics → same adjacency rule works for `'tasks'`.
|
||||
- Activator failure on worktree → unchanged (`result !== false` gate). Tasks
|
||||
dispatch can't fail.
|
||||
- Editable-target guard at `App.tsx:600` still fires first → typing in the
|
||||
Tasks search box + `Cmd+Alt+←` remains a no-op.
|
||||
- `MAX_HISTORY = 50` → Tasks entries consume slots; worst-case effective
|
||||
worktree depth halves to ~25. Acceptable.
|
||||
|
||||
## Known residual quirks (accepted)
|
||||
|
||||
1. **Prefetch loss on back-to-Tasks.** Routing through `setActiveView('tasks')`
|
||||
skips the SWR prefetch at `ui.ts:201-208`; back-to-Tasks is ~300–800ms
|
||||
slower than a fresh open via the sidebar. Not a regression vs today
|
||||
(back-to-Tasks isn't possible at all today).
|
||||
|
||||
2. **Titlebar layout shift.** Revealing the button cluster on Tasks changes
|
||||
the titlebar — needs a visual check that nothing Tasks-specific collides.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `src/renderer/src/store/slices/worktree-nav-history.ts` — entry type,
|
||||
`recordViewVisit`, dispatch in `goBack/goForwardWorktree`.
|
||||
- `src/renderer/src/store/slices/worktree-nav-history.test.ts` — new cases.
|
||||
- `src/renderer/src/store/slices/ui.ts` — `openTaskPage` records via
|
||||
`recordViewVisit`; `closeTaskPage` rewinds history index when the current
|
||||
entry is `'tasks'`.
|
||||
- `src/renderer/src/App.tsx` — widen the two `activeView === 'terminal'`
|
||||
guards to include `'tasks'`, and update the adjacent "why" comments at
|
||||
`App.tsx:613-615` and `App.tsx:896-899` to reflect the new invariant.
|
||||
|
||||
Estimated diff: ~30 lines of slice logic, 1 call site in `ui.ts`, 2 guards
|
||||
in `App.tsx`, plus tests.
|
||||
@@ -1,480 +0,0 @@
|
||||
# Design Document: Scoped Cmd+J Jump Palette for Worktrees and Browser Tabs
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-15
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Extend Orca's existing `Cmd+J` / `Ctrl+Shift+J` worktree palette into a single app-wide jump surface with explicit scopes. The first release keeps the palette centered on two jobs:
|
||||
|
||||
- jump to a worktree
|
||||
- jump to an already-open browser page across any worktree
|
||||
|
||||
The palette opens into a lightweight scope switcher with three modes:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Users can press `Tab` / `Shift+Tab` to cycle scopes without leaving the keyboard. The default contract on open remains `Worktrees`, not `All`, so existing users still land in the familiar worktree-first flow before opting into broader search. Search results stay intentionally narrow: browser-only discovery is supported because the user explicitly needs to find already-open pages across worktrees, but the palette does not become a generic "everything" bucket in v1.
|
||||
|
||||
## 1.1 Phase 0.5 Direction Lock
|
||||
|
||||
Phase 0.5 locked three product decisions for v1:
|
||||
|
||||
- `Cmd+J` still opens into `Worktrees` by default.
|
||||
- Browser discovery indexes live open `BrowserPage`s, not just browser workspace containers.
|
||||
- Browser ordering uses a simple context-first heuristic instead of true last-focused recency state.
|
||||
|
||||
Why these decisions were chosen:
|
||||
|
||||
- `Cmd+J` already has a strong worktree-switching contract in Orca. Making `All` the default would silently turn a familiar command into a mixed-ranking surface and force existing users to re-learn the first screen they see.
|
||||
- The remembered object in the browser case is the page itself. Searching only browser workspace shells would miss the page-level titles and URLs users actually recall when they say "I know I already have this open somewhere."
|
||||
- True global browser recency would require new focus-tracking state whose only initial consumer is palette ranking. A simple heuristic is easier to ship, easier to explain, and good enough for the first version of this discovery workflow.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Orca already supports multiple concurrent worktrees and persistent in-app browser tabs. Users can jump between worktrees with the existing `Cmd+J` palette, but they cannot quickly answer a more specific question:
|
||||
|
||||
> "I know I already have this page open somewhere. Which worktree is it in, and how do I get back to it?"
|
||||
|
||||
The current worktree palette is container-first. That works when the user remembers the worktree identity, but it breaks down when the remembered thing is a page title, host, or URL path.
|
||||
|
||||
This is a discovery problem, not a recency problem. A cycle UI is poor at it because:
|
||||
|
||||
- the user often does not know which worktree owns the target tab
|
||||
- multiple browser tabs can share similar titles
|
||||
- cycling scales badly once many worktrees have open tabs
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Preserve `Cmd+J` / `Ctrl+Shift+J` as Orca's single global jump entry point.
|
||||
- Let users search open browser pages across all worktrees.
|
||||
- Keep worktree search fast and familiar for existing users.
|
||||
- Make scope switching explicit and keyboard-first.
|
||||
- Avoid overcommitting Orca to a generic "search all open items" model before the product is ready to support terminals, editors, and commands consistently.
|
||||
- Preserve the existing expectation that `Cmd+J` opens as a worktree-first jump flow.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Searching terminal scrollback or editor contents.
|
||||
- Adding a persistent sidebar or browser-tab manager panel.
|
||||
- Replacing local `Ctrl+Tab` behavior or introducing a new cycle UI.
|
||||
- Expanding `Cmd+J` into files, terminals, editor tabs, or commands in this change.
|
||||
|
||||
## 5. UX
|
||||
|
||||
### 5.1 Entry Point
|
||||
|
||||
Keep the existing shortcut:
|
||||
|
||||
- macOS: `Cmd+J`
|
||||
- Windows/Linux: `Ctrl+Shift+J`
|
||||
|
||||
This shortcut already means "global jump" inside Orca and is already forwarded correctly even when an embedded browser guest owns focus. Reusing it preserves muscle memory and avoids proliferating navigation surfaces.
|
||||
|
||||
### 5.2 Scope Model
|
||||
|
||||
The palette header contains three explicit scope chips:
|
||||
|
||||
- `All`
|
||||
- `Worktrees`
|
||||
- `Browser Tabs`
|
||||
|
||||
Keyboard behavior:
|
||||
|
||||
- `Tab`: next scope
|
||||
- `Shift+Tab`: previous scope
|
||||
- `Up` / `Down`: move selection within results
|
||||
- `Enter`: activate selected result
|
||||
- `Esc`: close palette
|
||||
|
||||
Default scope on open: `Worktrees`.
|
||||
|
||||
Why this shape:
|
||||
|
||||
- one entry point is easier to remember than separate dialogs
|
||||
- explicit scopes prevent a mixed list from becoming noisy
|
||||
- `Tab` matches the mental model of "move across modes" without conflicting with list navigation
|
||||
- opening in `Worktrees` preserves today's default behavior and makes `All` an explicit expansion, not a silent contract change
|
||||
|
||||
### 5.3 Scope Semantics
|
||||
|
||||
#### `All`
|
||||
|
||||
Merged result list of:
|
||||
|
||||
- open browser pages across all worktrees
|
||||
- worktrees across all repos
|
||||
|
||||
Ranking rules:
|
||||
|
||||
- strong browser title matches rank above weak worktree metadata matches
|
||||
- host/url matches get boosted when the query resembles a domain, URL, or path fragment
|
||||
- exact worktree name matches still beat weak browser matches
|
||||
- current worktree/current browser page receive a small context boost when otherwise tied
|
||||
- browser results use the same heuristic ordering as `Browser Tabs`; v1 does not add hidden last-focused browser recency state just for this merged scope
|
||||
|
||||
`All` is meant to feel smart, not exhaustive.
|
||||
|
||||
#### `Worktrees`
|
||||
|
||||
Equivalent to today's worktree palette behavior:
|
||||
|
||||
- same global search semantics for worktree metadata
|
||||
- same recent-first ordering for the default empty-query state
|
||||
- same selection and activation behavior
|
||||
|
||||
#### `Browser Tabs`
|
||||
|
||||
Shows only open browser pages across all worktrees. The user-facing scope label stays `Browser Tabs`, but each row maps to a live `BrowserPage`, not a browser workspace shell.
|
||||
|
||||
Empty query ordering:
|
||||
|
||||
1. current browser page, if any
|
||||
2. other open browser pages in the current worktree
|
||||
3. browser pages in other worktrees, grouped by the existing worktree ordering and then sorted by title, falling back to URL
|
||||
|
||||
This mode is the direct answer to "just show me all open browsers."
|
||||
|
||||
Why this ordering:
|
||||
|
||||
- it pulls the user's current context to the top without inventing new global recency state
|
||||
- it stays stable enough that users can learn where results tend to land
|
||||
- it keeps the implementation honest about what Orca already knows today versus what would require new focus-history plumbing
|
||||
|
||||
### 5.4 Result Rows
|
||||
|
||||
#### Browser tab row
|
||||
|
||||
Primary text:
|
||||
|
||||
- current page title, falling back to formatted URL when the title is blank or useless
|
||||
|
||||
Secondary text:
|
||||
|
||||
- host + trimmed path
|
||||
|
||||
Context chips on the right:
|
||||
|
||||
- repo name
|
||||
- worktree display name
|
||||
|
||||
Optional badges:
|
||||
|
||||
- `Current Tab`
|
||||
- `Current Worktree`
|
||||
|
||||
Why this is required:
|
||||
|
||||
- browser tab titles are often duplicated (`localhost`, `Settings`, `Dashboard`)
|
||||
- users need immediate disambiguation without opening the result
|
||||
- worktree context is the whole point of the feature
|
||||
- each row represents the actual page the user remembers, while worktree and repo chips explain where that page lives
|
||||
|
||||
#### Worktree row
|
||||
|
||||
Keep the existing row structure:
|
||||
|
||||
- worktree display name
|
||||
- branch
|
||||
- optional supporting text for comment / PR / issue
|
||||
- repo badge
|
||||
|
||||
This avoids making existing users relearn the palette.
|
||||
|
||||
### 5.5 Empty States
|
||||
|
||||
`All`
|
||||
|
||||
- If no worktrees and no browser tabs exist: `No worktrees or open browser tabs`
|
||||
- If query yields no results: `No matches in worktrees or browser tabs`
|
||||
|
||||
`Worktrees`
|
||||
|
||||
- Preserve existing copy
|
||||
|
||||
`Browser Tabs`
|
||||
|
||||
- No open browser tabs: `No open browser tabs`
|
||||
- No query matches: `No browser tabs match your search`
|
||||
|
||||
### 5.6 Activation Behavior
|
||||
|
||||
Selecting a worktree result:
|
||||
|
||||
- preserve current `activateAndRevealWorktree(worktreeId)` behavior
|
||||
|
||||
Selecting a browser result:
|
||||
|
||||
1. activate and reveal the owning worktree
|
||||
2. focus the target browser workspace tab
|
||||
3. select the target `BrowserPage` inside that workspace
|
||||
4. set `activeTabType` to `browser`
|
||||
5. close the palette
|
||||
6. restore focus into the browser surface, not the terminal/editor fallback
|
||||
|
||||
Why this ordering matters:
|
||||
|
||||
- browser pages are subordinate to worktree activation in Orca's model
|
||||
- worktree-first activation restores the right workspace state and sidebar visibility
|
||||
- selecting a browser result should feel like "take me there directly," not "switch worktree and make me pick again"
|
||||
|
||||
## 6. Data Model and Search Inputs
|
||||
|
||||
### 6.1 Worktree Results
|
||||
|
||||
Use the existing search surface:
|
||||
|
||||
- `displayName`
|
||||
- branch
|
||||
- repo name
|
||||
- comment
|
||||
- linked PR number/title
|
||||
- linked issue number/title
|
||||
|
||||
### 6.2 Browser Tab Results
|
||||
|
||||
Search only currently open browser pages, not history and not just browser workspace shells.
|
||||
|
||||
For each browser result, index:
|
||||
|
||||
- page title
|
||||
- page URL
|
||||
- formatted host/path
|
||||
- owning browser workspace label, if available
|
||||
- owning worktree display name
|
||||
- owning repo display name
|
||||
|
||||
Each open `BrowserPage` contributes its own result row. Browser workspaces still matter for ownership and activation, but they are context, not the searchable unit.
|
||||
|
||||
This is intentionally limited to live open pages because Orca still is not an app-wide browsing history system. The goal is to help users recover something they already have open, not to introduce a second browsing history feature through the palette.
|
||||
|
||||
### 6.3 Why Not Terminal Tabs Yet
|
||||
|
||||
Terminal tabs are deliberately out of scope for text-first search in this change.
|
||||
|
||||
Reasons:
|
||||
|
||||
- terminal tab titles are less stable and less descriptive than browser titles
|
||||
- the meaningful part of a terminal session often lives in scrollback, not tab metadata
|
||||
- adding terminal tabs only because browser tabs are added would create a low-signal mixed palette
|
||||
|
||||
This design keeps the palette honest: it supports browser-page search because that metadata makes the target genuinely searchable. If Orca later wants an "all open items" palette, that should be a deliberate follow-up with result quality standards for each item type.
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
### 7.1 Existing Pieces Reused
|
||||
|
||||
- `WorktreeJumpPalette.tsx` remains the base surface and interaction shell.
|
||||
- Existing worktree search logic remains intact for the `Worktrees` scope.
|
||||
- Browser search input comes from the live open `BrowserPage`s already held by renderer browser state.
|
||||
- Browser activation continues to use the existing browser-workspace activation pathway after worktree activation, then selects the matching page inside that workspace.
|
||||
- Main-process shortcut forwarding remains unchanged.
|
||||
|
||||
### 7.2 New Search Model
|
||||
|
||||
Add a palette view-model layer that produces typed results:
|
||||
|
||||
```ts
|
||||
type JumpPaletteScope = 'all' | 'worktrees' | 'browser-tabs'
|
||||
|
||||
type JumpPaletteResult =
|
||||
| { type: 'worktree'; worktreeId: string; score: number; ... }
|
||||
| {
|
||||
type: 'browser-page'
|
||||
worktreeId: string
|
||||
browserTabId: string
|
||||
browserPageId: string
|
||||
score: number
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The existing worktree search helper remains responsible for worktree scoring. A new browser-page search helper handles browser result scoring and formatting. The palette shell merges and sorts results only in `All`.
|
||||
|
||||
Why split the search helpers:
|
||||
|
||||
- worktree matching logic is already non-trivial and should not be regressed
|
||||
- browser result ranking has different signals than worktree ranking
|
||||
- typed results keep selection and rendering explicit instead of relying on ad hoc ID prefixes
|
||||
- page-level browser hits need both workspace ownership and page identity for activation
|
||||
|
||||
### 7.3 Focus and Close Semantics
|
||||
|
||||
The existing palette already manages focus restoration carefully. That logic should be extended, not replaced.
|
||||
|
||||
New rule:
|
||||
|
||||
- if the selected result is a browser page, the post-close focus path targets the active browser surface
|
||||
- otherwise preserve today's terminal/editor focus restoration behavior
|
||||
|
||||
### 7.4 System Context
|
||||
|
||||
```text
|
||||
+------------------+ +-----------------------+
|
||||
| Main Process | | Renderer Store |
|
||||
| shortcut forward | -----> | activeModal |
|
||||
| Cmd+J / Ctrl+... | | worktreesByRepo |
|
||||
+------------------+ | browser state |
|
||||
| activeWorktreeId |
|
||||
+-----------+-----------+
|
||||
|
|
||||
v
|
||||
+------------------------+
|
||||
| Cmd+J Jump Palette |
|
||||
| scopes + search + list |
|
||||
+-----+-------------+----+
|
||||
| |
|
||||
worktree hit | | browser-page hit
|
||||
v v
|
||||
+------------------+ +---------------------+
|
||||
| activate/reveal | | activate/reveal |
|
||||
| target worktree | | target worktree |
|
||||
+------------------+ +----------+----------+
|
||||
|
|
||||
v
|
||||
+----------------------+
|
||||
| activate browser |
|
||||
| workspace + page |
|
||||
| focus browser pane |
|
||||
+----------------------+
|
||||
```
|
||||
|
||||
### 7.5 Data Flows
|
||||
|
||||
#### Happy path: browser-page search and jump
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens in Worktrees -> user switches to Browser Tabs ->
|
||||
query matches browser page ->
|
||||
user presses Enter -> activateAndRevealWorktree(worktreeId) ->
|
||||
activate target browser workspace and page -> palette closes -> browser surface focused
|
||||
```
|
||||
|
||||
#### Nil path: user opens palette with no browser pages
|
||||
|
||||
```text
|
||||
Cmd+J -> palette opens -> Browser Tabs scope selected ->
|
||||
search model sees zero browser pages -> empty state shown -> no side effects
|
||||
```
|
||||
|
||||
#### Empty path: query yields no browser or worktree matches
|
||||
|
||||
```text
|
||||
query typed -> search returns [] -> scope-specific empty state rendered ->
|
||||
selection cleared or pinned to no result -> Enter does nothing
|
||||
```
|
||||
|
||||
#### Upstream error path: selected browser page disappears before activation
|
||||
|
||||
```text
|
||||
user selects browser result -> store lookup fails because page/worktree closed ->
|
||||
show toast error -> keep palette open if possible, otherwise close safely without switching
|
||||
```
|
||||
|
||||
## 8. Alternatives Considered
|
||||
|
||||
### 8.1 Dedicated browser-tab-only dialog
|
||||
|
||||
Pros:
|
||||
|
||||
- clearer mental model for the browser-specific job
|
||||
- no mixed-result ranking complexity
|
||||
|
||||
Cons:
|
||||
|
||||
- adds another shortcut and another navigation surface
|
||||
- weakens `Cmd+J` as the single place to jump around Orca
|
||||
|
||||
Decision: rejected for now. The scoped palette gives the same utility with less surface area.
|
||||
|
||||
### 8.2 Browser tabs only inside `Cmd+J`, no scopes
|
||||
|
||||
Pros:
|
||||
|
||||
- least new UI chrome
|
||||
|
||||
Cons:
|
||||
|
||||
- mixed results become harder to reason about
|
||||
- users cannot quickly answer "just show me browser tabs"
|
||||
|
||||
Decision: rejected. Explicit scopes are worth the small extra header chrome.
|
||||
|
||||
### 8.3 Expand immediately to terminals, files, and commands
|
||||
|
||||
Pros:
|
||||
|
||||
- one "go to anything" story
|
||||
|
||||
Cons:
|
||||
|
||||
- scope explosion
|
||||
- result quality is uneven across item types
|
||||
- much higher design and implementation complexity
|
||||
|
||||
Decision: rejected for v1. Start with the two jobs the user clearly asked for.
|
||||
|
||||
### 8.4 Make `All` the default scope
|
||||
|
||||
Pros:
|
||||
|
||||
- makes browser discovery visible immediately
|
||||
- creates a more obviously "global" first impression
|
||||
|
||||
Cons:
|
||||
|
||||
- breaks the current worktree-first contract of `Cmd+J`
|
||||
- makes the first screen depend on mixed ranking logic instead of today's predictable worktree list
|
||||
|
||||
Decision: rejected for v1. `All` remains available, but opening in `Worktrees` preserves muscle memory and keeps the expansion explicit.
|
||||
|
||||
### 8.5 Search browser workspaces instead of live pages
|
||||
|
||||
Pros:
|
||||
|
||||
- simpler indexing model
|
||||
- reuses the existing browser workspace abstraction directly
|
||||
|
||||
Cons:
|
||||
|
||||
- misses the page titles and URLs users actually remember
|
||||
- treats the container as the search target even when the user wants a specific page inside it
|
||||
|
||||
Decision: rejected. The palette should index the page the user is trying to recover, then use workspace and worktree context to explain where it lives.
|
||||
|
||||
### 8.6 Add true browser recency tracking for v1 ranking
|
||||
|
||||
Pros:
|
||||
|
||||
- could produce sharper empty-query ordering over time
|
||||
|
||||
Cons:
|
||||
|
||||
- requires new state and focus bookkeeping for a thin initial payoff
|
||||
- introduces ranking behavior that is harder to explain and debug
|
||||
|
||||
Decision: rejected for v1. Start with a deterministic context-first heuristic and revisit true recency only if usage shows the heuristic is insufficient.
|
||||
|
||||
## 9. Rollout
|
||||
|
||||
### Phase 1
|
||||
|
||||
- Add scoped header to the existing `Cmd+J` palette
|
||||
- Keep `Worktrees` as the default scope on open
|
||||
- Preserve worktree-only behavior under the `Worktrees` scope
|
||||
- Add browser-page search and activation
|
||||
- Search live open `BrowserPage`s rather than browser workspace shells
|
||||
- Add `All` merged ranking
|
||||
|
||||
### Phase 2 (optional follow-up)
|
||||
|
||||
- Evaluate whether users need additional scopes such as editor tabs or commands
|
||||
- Only add a new scope if it has a clear, high-signal search model
|
||||
|
||||
## 10. Open Questions
|
||||
|
||||
- Whether a later iteration should remember a last-used non-default scope without changing the default-open `Worktrees` contract
|
||||
- Whether browser-tab results should expose close actions from the palette in a later pass
|
||||
- Whether `All` should group results visually by type or keep one flat ranked list
|
||||
@@ -1,451 +0,0 @@
|
||||
# Codex Account Switching Design
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-17
|
||||
|
||||
## Summary
|
||||
|
||||
Orca's current Codex account switcher swaps the entire `CODEX_HOME` for each managed account. That isolates authentication, but it also unintentionally forks config, permissions, history, sessions, memories, skills, and other local Codex state. The result is that account switching feels like switching between separate Codex installs instead of switching which account powers the same Codex environment.
|
||||
|
||||
The recommended design is:
|
||||
|
||||
- Keep `~/.codex` as the shared runtime `CODEX_HOME` for all Codex user state.
|
||||
- Store only `auth.json` per managed account in Orca-owned storage.
|
||||
- Introduce a dedicated main-process runtime-home owner that materializes the selected account's `auth.json` into the shared runtime home before any Codex launch, login, or rate-limit fetch.
|
||||
- Restart live Codex panes after switch; newly launched panes use the new account while preserving the shared Codex state.
|
||||
|
||||
This matches the intended product behavior: account switching is for authentication and usage limits, not for creating separate Codex worlds. It also matches how manual Codex account switching already behaves outside Orca: logging out and back in mutates the same `~/.codex` state the user sees in terminal Codex.
|
||||
|
||||
## Motivation
|
||||
|
||||
The current managed-account design causes user-visible problems:
|
||||
|
||||
- `config.toml` diverges per account, so permissions and sandbox defaults reset unexpectedly.
|
||||
- `history.jsonl` and `sessions/` are scoped per managed home, so chat history appears to disappear after account switches.
|
||||
- `memories`, `skills`, `rules`, and likely sqlite-backed local state drift per account.
|
||||
- Live sessions require restart because the active terminal process keeps using the old `CODEX_HOME`.
|
||||
|
||||
We already patched the first symptom by syncing `config.toml` into managed homes. That is a tactical fix, not the right long-term model. The full-home-per-account design still leaves history and session continuity split across accounts.
|
||||
|
||||
The deeper issue is ownership. Orca currently has no single component that owns Codex runtime state preparation. `CodexAccountService`, `pty.ts`, rate-limit fetchers, and usage scanning each participate in path or environment decisions. The long-term fix must therefore be a runtime-home ownership refactor, not just a storage-layout tweak.
|
||||
|
||||
## Current State
|
||||
|
||||
### Orca behavior today
|
||||
|
||||
- Managed accounts are created under `app.getPath('userData')/codex-accounts/<id>/home`.
|
||||
- Orca selects an account by updating `settings.activeCodexManagedAccountId`.
|
||||
- New Codex PTYs inherit the selected managed home's path as `CODEX_HOME`.
|
||||
- Codex rate-limit fetches also use that selected managed home.
|
||||
|
||||
Relevant code:
|
||||
|
||||
- [src/main/codex-accounts/service.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-accounts/service.ts)
|
||||
- [src/main/ipc/pty.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/ipc/pty.ts)
|
||||
- [src/main/codex-usage/scanner.ts](/Users/jinwoohong/orca/workspaces/orca/codex-fix-2/src/main/codex-usage/scanner.ts)
|
||||
|
||||
### Codex state observed on disk
|
||||
|
||||
On a typical install, `CODEX_HOME` contains at least:
|
||||
|
||||
- `auth.json`
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- `memories/`
|
||||
- `skills/`
|
||||
- `rules/`
|
||||
- `shell_snapshots/`
|
||||
- `models_cache.json`
|
||||
- `logs_2.sqlite`
|
||||
- `state_5.sqlite`
|
||||
- `installation_id`
|
||||
- `version.json`
|
||||
|
||||
Because Orca currently swaps the whole home, all of that becomes account-scoped.
|
||||
|
||||
## Goals
|
||||
|
||||
- Make Codex account switching feel like swapping credentials, not swapping environments.
|
||||
- Preserve one continuous Codex history and session store across accounts.
|
||||
- Keep permissions, sandbox defaults, MCP config, memories, and other user state stable across account changes.
|
||||
- Preserve Orca's existing account-switch UX: one selected active account at a time, with restart prompts for live Codex panes.
|
||||
- Keep the solution cross-platform across macOS, Linux, and Windows.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Supporting simultaneous live Codex sessions under different accounts in the same Orca instance.
|
||||
- Changing Codex upstream behavior or requiring first-class multi-account support from Codex.
|
||||
- Building a replication system that continuously merges multiple independent `CODEX_HOME` trees.
|
||||
|
||||
## Constraints
|
||||
|
||||
### Codex does not currently expose a separate auth path
|
||||
|
||||
From local CLI inspection, Codex supports:
|
||||
|
||||
- `CODEX_HOME`
|
||||
- config overrides via `-c key=value`
|
||||
|
||||
It does not currently expose a first-class "shared config/home plus separate auth profile" interface. There is an upstream feature request for auth profiles, which suggests Orca cannot rely on such a feature today.
|
||||
|
||||
### Orca is cross-platform
|
||||
|
||||
The design must work on macOS, Linux, and Windows. That rules out relying on symlink-heavy designs as the primary solution:
|
||||
|
||||
- Windows symlink/junction behavior is more fragile.
|
||||
- Atomic copy + rename is simpler and more portable.
|
||||
- Node path utilities should be used everywhere.
|
||||
|
||||
### Orca's current mental model is single selected account
|
||||
|
||||
The current switcher already assumes one active account at a time and uses restart prompts for live Codex panes. The recommended design leans into that model rather than trying to support concurrent mixed-account sessions.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option 1: Keep full per-account homes and sync everything
|
||||
|
||||
Each managed account keeps its own full `CODEX_HOME`, and Orca syncs:
|
||||
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- sqlite state
|
||||
- memories, skills, rules
|
||||
|
||||
**Pros**
|
||||
|
||||
- Minimal conceptual change from the current design.
|
||||
- Per-account auth isolation stays simple.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Orca becomes a replication system for Codex state.
|
||||
- `history.jsonl` is mergeable, but `sessions/` and sqlite-backed files are much harder to reconcile safely.
|
||||
- Concurrent activity can easily cause stale-copy or overwrite bugs.
|
||||
- More code, more edge cases, weaker guarantees.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Not recommended. This is the highest-complexity path for the weakest product result.
|
||||
|
||||
### Option 2: Shared runtime home, per-account `auth.json`
|
||||
|
||||
Keep a single shared runtime `CODEX_HOME` and store one `auth.json` per managed account. On switch, Orca copies the selected account's `auth.json` into the shared runtime home before launching or restarting Codex sessions.
|
||||
|
||||
**Pros**
|
||||
|
||||
- Cleanest mapping to the product intent.
|
||||
- `config.toml`, history, sessions, memories, skills, rules, and local state are naturally shared.
|
||||
- No state replication logic.
|
||||
- Cross-platform implementation is straightforward with normal file copy and rename.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Does not support simultaneous different-account live sessions in the same runtime home.
|
||||
- Existing live sessions still likely need restart because Codex may read auth only at startup.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Recommended.
|
||||
|
||||
### Option 3: Wait for upstream Codex auth profiles
|
||||
|
||||
If Codex eventually supports a true auth-profile model, Orca could delegate account isolation to Codex itself.
|
||||
|
||||
**Pros**
|
||||
|
||||
- Best long-term upstream integration.
|
||||
- Less Orca-specific state management.
|
||||
|
||||
**Cons**
|
||||
|
||||
- Not available today.
|
||||
- Does not solve Orca's user-facing problems now.
|
||||
|
||||
**Verdict**
|
||||
|
||||
Good future migration target, not a current solution.
|
||||
|
||||
## Recommended Design
|
||||
|
||||
### High-level model
|
||||
|
||||
Introduce two separate concepts:
|
||||
|
||||
1. **Shared runtime home**
|
||||
The single `CODEX_HOME` used for all Codex launches inside Orca. For this design, the canonical shared runtime home is `~/.codex`.
|
||||
|
||||
2. **Per-account auth store**
|
||||
Orca-managed storage that keeps one `auth.json` per account.
|
||||
|
||||
3. **Codex runtime-home owner**
|
||||
A dedicated main-process component that prepares active Codex runtime state before any Codex subprocess, rate-limit fetch, or login flow touches it.
|
||||
|
||||
At runtime:
|
||||
|
||||
- Orca picks the selected managed account.
|
||||
- The runtime-home owner copies that account's `auth.json` into `~/.codex/auth.json`.
|
||||
- All Codex entry points consume the runtime-home owner's resolved home path instead of reasoning about Codex paths independently.
|
||||
- Orca launches Codex with `CODEX_HOME` pointing to `~/.codex`.
|
||||
|
||||
### Shared vs per-account state
|
||||
|
||||
**Shared runtime home**
|
||||
|
||||
- `config.toml`
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- `memories/`
|
||||
- `skills/`
|
||||
- `rules/`
|
||||
- `shell_snapshots/`
|
||||
- `models_cache.json`
|
||||
- `logs_2.sqlite`
|
||||
- `state_5.sqlite`
|
||||
- `installation_id`
|
||||
- `version.json`
|
||||
- transient caches and temp dirs, unless we later discover they must be treated specially
|
||||
|
||||
**Per-account storage**
|
||||
|
||||
- `auth.json`
|
||||
- Orca account metadata already stored in Orca settings
|
||||
|
||||
### Why this is the right split
|
||||
|
||||
`auth.json` is the only file we explicitly know needs to vary by account. The rest of the files represent user environment, session continuity, and local Codex behavior. If those are split by account, the switcher does not feel seamless.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### Storage layout
|
||||
|
||||
Recommended paths:
|
||||
|
||||
- Shared runtime home:
|
||||
- `~/.codex`
|
||||
- Per-account auth store:
|
||||
- `app.getPath('userData')/codex-accounts/<id>/home/auth.json`
|
||||
|
||||
The important part is the separation of concerns, not the exact path choice.
|
||||
|
||||
### Ownership and API
|
||||
|
||||
The design should introduce a dedicated main-process owner for runtime-home preparation. A representative API shape:
|
||||
|
||||
```ts
|
||||
type PreparedCodexRuntime = {
|
||||
homePath: string
|
||||
activeAccountId: string | null
|
||||
}
|
||||
|
||||
interface CodexRuntimeHomeService {
|
||||
prepareForAccountSwitch(accountId: string | null): PreparedCodexRuntime
|
||||
prepareForCodexLaunch(): PreparedCodexRuntime
|
||||
prepareForRateLimitFetch(): PreparedCodexRuntime
|
||||
prepareForLogin(accountId: string): { loginHomePath: string }
|
||||
}
|
||||
```
|
||||
|
||||
Why: today path/runtime ownership is fragmented across `CodexAccountService`, PTY spawn env injection, rate-limit fetches, and usage scanning. A single owner prevents those code paths from drifting again.
|
||||
|
||||
### Serialization contract
|
||||
|
||||
Because `~/.codex/auth.json` is shared mutable state, the runtime-home owner must be the only component allowed to mutate active Codex auth. It must serialize these operations behind one coordination primitive:
|
||||
|
||||
- `prepareForAccountSwitch`
|
||||
- `prepareForCodexLaunch`
|
||||
- `prepareForRateLimitFetch`
|
||||
- `prepareForLogin`
|
||||
- any future reauth or logout helpers
|
||||
|
||||
Required contract:
|
||||
|
||||
- account switch auth materialization is exclusive
|
||||
- launch and rate-limit preparation must either observe the auth state from before the switch or the fully committed auth state from after the switch
|
||||
- they must never observe an in-progress partial write
|
||||
- login preparation must not mutate the active runtime auth in place
|
||||
|
||||
Why: without this contract, PTY launch, quota fetch, and auth swap can still race and intermittently bind work to the wrong account.
|
||||
|
||||
### Account switch flow
|
||||
|
||||
1. User selects a managed account.
|
||||
2. Orca validates that account's stored `auth.json`.
|
||||
3. The runtime-home owner writes the selected `auth.json` into `~/.codex/auth.json`.
|
||||
4. Orca refreshes Codex rate-limit state using the same prepared runtime home.
|
||||
5. Orca prompts restart for live Codex panes, marks them stale until restarted, and blocks further Codex execution from those panes.
|
||||
6. New or restarted Codex panes launch with:
|
||||
- `CODEX_HOME=~/.codex`
|
||||
- shared config/history/session state
|
||||
- selected account auth
|
||||
|
||||
Interaction states that must be explicit in product/UI copy:
|
||||
|
||||
- **Switch in progress**: selection disabled while auth materialization and rate-limit refresh run.
|
||||
- **Switch complete, restart required**: existing live Codex panes are stale, must show a restart affordance, and must not be allowed to submit further Codex work until restarted.
|
||||
- **Switch failed**: active account remains unchanged and stale restart notices are not applied.
|
||||
- **Switch to system default**: Orca clears managed-account selection and restores the system-default auth snapshot into `~/.codex`.
|
||||
|
||||
### System default source of truth
|
||||
|
||||
This design treats “System default” as a first-class auth source, not as “whatever happens to be left in `~/.codex/auth.json`.”
|
||||
|
||||
Rules:
|
||||
|
||||
- On first startup of the new architecture, before any managed-account switch mutates `~/.codex/auth.json`, Orca captures a `system-default` auth snapshot from the current `~/.codex/auth.json` when present.
|
||||
- That snapshot is stored in Orca-owned storage separately from managed account auth blobs.
|
||||
- Switching to “System default” restores `~/.codex/auth.json` from that stored snapshot.
|
||||
- If the user changes external Codex auth outside Orca and wants Orca’s “System default” target to follow it, Orca should expose an explicit refresh/import action or perform refresh only at startup before any managed account takes ownership in the current app session.
|
||||
|
||||
Why: without a defined snapshot-and-restore model, switching back to “System default” is nondeterministic and can leave the last managed account active or overwrite the user’s expected external Codex auth.
|
||||
|
||||
### New account add flow
|
||||
|
||||
1. Orca prepares a temporary login home that inherits the current shared config baseline but does not dirty the active runtime state on failure.
|
||||
2. Orca runs `codex login` against that temporary login home.
|
||||
3. Orca captures the resulting `auth.json`.
|
||||
4. Orca stores only that `auth.json` under the managed account's storage.
|
||||
5. Orca does not change the active runtime home until the user selects that account or explicitly makes it active on completion.
|
||||
|
||||
Why: a failed or aborted login must not poison the currently active `~/.codex` runtime state.
|
||||
|
||||
### Legacy managed-home migration
|
||||
|
||||
Migration of existing managed-home history and sessions is required, not optional.
|
||||
|
||||
Rules:
|
||||
|
||||
- On first startup after the new architecture lands, Orca scans legacy managed homes for:
|
||||
- `history.jsonl`
|
||||
- `sessions/`
|
||||
- Orca imports legacy history into `~/.codex/history.jsonl` using append/merge semantics that avoid dropping existing shared-home history.
|
||||
- Orca imports legacy sessions into `~/.codex/sessions/` with an explicit collision policy:
|
||||
- import non-conflicting legacy session files directly
|
||||
- for conflicting session files, merge turns when Orca can prove the files represent the same logical session with append-only divergence
|
||||
- if Orca cannot safely merge a conflicting session file, preserve both copies under deterministic names and emit a diagnostic record rather than silently dropping either side
|
||||
- Orca records migration completion so the import does not repeat on every startup.
|
||||
|
||||
Why: shared history/session continuity is a core goal of the design. Leaving legacy managed-home data unresolved would make the upgrade look like history loss for users who previously used managed accounts.
|
||||
|
||||
### Live session behavior
|
||||
|
||||
The safe assumption is:
|
||||
|
||||
- switching accounts affects new Codex launches
|
||||
- existing live Codex sessions should still restart
|
||||
- stale live Codex panes are blocked from further execution until restart completes
|
||||
|
||||
If future validation shows Codex hot-reloads `auth.json`, Orca can relax this. The architecture should not rely on hot-reload behavior today.
|
||||
|
||||
### Startup and recovery behavior
|
||||
|
||||
The design must explicitly cover startup and error handling:
|
||||
|
||||
- If `~/.codex` exists but has no `history.jsonl` or `sessions/`, Orca should launch cleanly and let Codex create them lazily.
|
||||
- If the selected managed account's stored `auth.json` is missing or corrupt, Orca should:
|
||||
- log a recoverable warning,
|
||||
- fall back to system-default semantics,
|
||||
- clear or mark invalid the selected managed account,
|
||||
- avoid leaving rate-limit UI bound to the wrong identity.
|
||||
- If a rate-limit refresh fails after account switch, Orca should keep the account switch result but show quota fetch failure separately rather than rolling back auth materialization implicitly.
|
||||
|
||||
## Why `~/.codex` Is The Shared Home
|
||||
|
||||
This document explicitly chooses `~/.codex` as the canonical shared runtime home.
|
||||
|
||||
Reasons:
|
||||
|
||||
- It matches the user's existing Codex mental model inside and outside Orca.
|
||||
- It avoids split-brain between Orca Codex usage and terminal Codex usage.
|
||||
- It matches what manual account switching already does today: logout/login mutates the same shared Codex state.
|
||||
|
||||
Tradeoff:
|
||||
|
||||
- Orca account switching will mutate the same Codex state used outside Orca.
|
||||
|
||||
That is acceptable for this product direction. Orca is acting as an automated frontend for the user's existing Codex environment, not a separate Codex silo.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Preserve the config sync patch
|
||||
|
||||
Keep the existing `config.toml` sync patch as a tactical fix while the broader migration is in progress.
|
||||
|
||||
### Phase 2: Introduce runtime-home owner
|
||||
|
||||
- Add a dedicated main-process runtime-home owner/service.
|
||||
- Route PTY spawning, rate-limit fetches, and login preparation through it.
|
||||
- Make `~/.codex` the explicit resolved runtime home for those flows.
|
||||
|
||||
### Phase 3: Move managed accounts to auth-only semantics
|
||||
|
||||
- Update account add/reauth logic to persist only the account's `auth.json`.
|
||||
- Stop treating per-account homes as full runtime environments.
|
||||
|
||||
### Phase 4: Account switch writes auth into shared runtime home
|
||||
|
||||
- On select, materialize the chosen account's `auth.json` into `~/.codex`.
|
||||
- Keep the existing restart notice flow for live Codex panes.
|
||||
|
||||
### Phase 5: Cleanup / compatibility
|
||||
|
||||
- Run the one-time legacy managed-home migration into `~/.codex`.
|
||||
- Mark old full-home-per-account storage as legacy.
|
||||
- Remove code paths that assume managed homes are full `CODEX_HOME`s.
|
||||
|
||||
## Risks
|
||||
|
||||
### Concurrent mixed-account sessions
|
||||
|
||||
This design assumes one selected active account at a time. If Orca ever needs simultaneous live sessions under different accounts, a single shared runtime home with one `auth.json` will not be sufficient.
|
||||
|
||||
### Unknown Codex coupling
|
||||
|
||||
We know `auth.json` is account-specific. We infer that most other files are environment/user-state and should be shared. If Codex later proves that some sqlite or cache files are also account-coupled, Orca may need to carve out a small additional per-account subset. That is still far simpler than syncing whole homes.
|
||||
|
||||
### Mutating the shared runtime home
|
||||
|
||||
This design intentionally updates `~/.codex/auth.json`. That means Orca and terminal Codex outside Orca share one Codex world. This is a deliberate product choice, not an accidental side effect.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit tests
|
||||
|
||||
- account selection writes the selected `auth.json` into `~/.codex`
|
||||
- PTY spawn uses `~/.codex` instead of the managed account home
|
||||
- config/history/session paths resolve from `~/.codex`
|
||||
- invalid or unreadable account auth does not corrupt the shared runtime home
|
||||
- startup with missing shared runtime files is repaired gracefully
|
||||
- rate-limit fetches and PTY launches consume the same runtime-home owner output
|
||||
|
||||
### Integration tests
|
||||
|
||||
- add account A, launch Codex, verify `~/.codex` gets history
|
||||
- switch to account B, restart pane, verify history remains available
|
||||
- verify `config.toml` and permissions do not change across account switches
|
||||
- verify only `auth.json` differs across account switches
|
||||
|
||||
### Manual verification
|
||||
|
||||
1. Start a Codex session under account A and create visible history.
|
||||
2. Switch to account B.
|
||||
3. Restart the Codex pane.
|
||||
4. Verify the session uses account B for auth/rate limits.
|
||||
5. Verify history, sessions, config, memories, and skills remain available.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Do `logs_2.sqlite` and `state_5.sqlite` behave correctly when fully shared across account switches? This is the expected design, but should be validated during rollout.
|
||||
- How should Orca expose refresh of the stored `system-default` auth snapshot when the user logs into Codex outside Orca?
|
||||
|
||||
## Recommendation
|
||||
|
||||
Adopt **`~/.codex` as the shared runtime `CODEX_HOME`, plus per-account `auth.json` only**, and implement it through a dedicated runtime-home owner in the main process.
|
||||
|
||||
This is the simplest design that:
|
||||
|
||||
- matches the intended account-switching UX,
|
||||
- avoids fragile replication logic,
|
||||
- works cross-platform,
|
||||
- and leaves room to adopt upstream Codex auth-profile support later if it becomes available.
|
||||
@@ -1,355 +0,0 @@
|
||||
# Engineering Spec: "Start from" field in Create Workspace
|
||||
|
||||
## Summary
|
||||
|
||||
Add a **"Start from"** field to the Create Workspace composer that lets the user pick a **branch or PR** as the basis for the new workspace. The picker is scoped to the selected repo; changing repo resets the field. This replaces no existing behavior: "Start from" defaults to the repo's **effective base ref** (`repo.worktreeBaseRef` when configured, otherwise the repo's detected default branch), so the current quick-create flow is unchanged.
|
||||
|
||||
This spec targets the shared composer flow, not just the modal wrapper. The quick-create modal and the full-page composer both consume `NewWorkspaceComposerCard` + `useComposerState`, so the new field/state should live there unless a piece is truly modal-only.
|
||||
|
||||
**Issues are out of scope for this picker.** Picking an issue does not change the base ref — it only sets `linkedIssue`, which the existing Link-work-item UI (`useComposerState.ts:256–260`) already handles. Putting Issues in a picker called "Start from" creates a second entry point into the same state and misleads the user. Users who want to link an issue use the existing Link UI.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
| Selection | Branch created from | Workspace metadata |
|
||||
|---|---|---|
|
||||
| Branch (default or other) | selected branch/ref | — |
|
||||
| PR | PR's same-repo head branch/ref | `linkedPR = #N` |
|
||||
|
||||
Important: Orca's create flow always creates a new worktree branch derived from the workspace name. "Start from PR" therefore means **branch from the PR head**, not "check out the PR branch directly".
|
||||
|
||||
**Scope for v1:** local repos support both picker tabs. Remote SSH repos support **Branches** only in this spec; PR start points stay disabled there until GitHub lookup can run without assuming a local `cwd`.
|
||||
|
||||
**PR scope for v1:** only PRs whose head branch lives in the selected repo are selectable. Fork PRs render disabled with copy like *"Fork PRs aren't supported yet in Start from"* because the current create flow cannot safely resolve a fork head from `headRefName` alone.
|
||||
|
||||
**Repo ↔ Start from contradiction:** the picker is repo-scoped. Changing `repoId` clears the prior selection and resets the field to the new repo's effective base ref. The field renders the reset inline (e.g. trigger reads *"Default branch — was PR #8778"*) instead of a fading toast, so the state is recoverable visually and not missed by users focused on another field.
|
||||
|
||||
**Naming:** when the picker selects a PR and the Name field is still auto-managed (matches `lastAutoNameRef.current`, including empty), apply the existing auto-name behavior from `getLinkedWorkItemSuggestedName()` to the actual `name` state. Once the user edits the name, subsequent PR selections leave `name` alone. This matches the existing Link-UI rule exactly — one code path, not two.
|
||||
|
||||
**Linked-work-item interaction:** the picker writes into the *same* `linkedWorkItem`/`linkedPR` state that the Link UI owns. A PR selection is a `linkedWorkItem` assignment. Switching back to a branch leaves `linkedWorkItem` alone: the user changed the *start ref*, not the *link*. If the user wants to remove the link, they use the Link UI. No source-tagging, no parallel state, no "whose selection was it" bookkeeping.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
**No new shared type.** The "Start from" picker is a UI affordance that writes into fields the system already has:
|
||||
|
||||
- `CreateWorktreeArgs.baseBranch` (existing, `src/shared/types.ts:461`) — carries the resolved git ref the new worktree branches from.
|
||||
- `WorktreeMeta.linkedPR` (existing, `src/shared/types.ts:56`) — set on PR selection. `useComposerState` already owns `linkedWorkItem` + `linkedPR` state and already writes it via `applyWorktreeMeta` post-create (`useComposerState.ts:880`).
|
||||
|
||||
Every picker selection reduces to one of:
|
||||
|
||||
| Picker selection | `baseBranch` passed to create | Linked metadata |
|
||||
|---|---|---|
|
||||
| Branch, local row | short branch name (e.g. `main`) | — |
|
||||
| Branch, remote-tracking row | remote-qualified form from the row's full refname (e.g. `origin/main`, `upstream/feat-x`) | — |
|
||||
| PR #N (same-repo head) | `<remote>/<headRefName>` after main-process `git fetch` of that ref (see PR head resolution) | `linkedPR = N` |
|
||||
|
||||
Why full-ref precision without a new type: local and remote-tracking refs are not interchangeable. The picker emits the right short form to `baseBranch` — remote-tracking rows pass the `<remote>/<name>` form so the new branch tracks the remote ref instead of creating a detached HEAD. Classification at the picker must use the row's underlying full refname (`refs/heads/…` vs `refs/remotes/<remote>/…`), never prefix-matching a short name (a local branch literally named `origin/foo` is legal). The remote name comes from the row's full refname, not a hardcoded `origin` — repos can have `upstream` or other remotes.
|
||||
|
||||
**Scope note on main-side remote derivation.** `createRemoteWorktree` (`worktree-remote.ts:96`) and `createLocalWorktree` (`worktree-remote.ts:243`) currently derive the remote as `baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'`. For picker selections this is fine — remote-tracking rows and PR refs always contain a slash, so the derived remote is correct. Branch-picker rows without a slash are local heads, and the `'origin'` fallback is unused. Non-picker code paths that pass slashless remote refs still hit the `origin` hardcode; fixing that is *out of scope* for this spec. The `resolvePrBase` resolver (below) must derive the push remote explicitly, since the PR head may live on `upstream` or another remote configured at the repo level.
|
||||
|
||||
**PR head resolution lives in main, not the renderer.** `gh pr list` returns `headRefName` as a short branch name that typically does not exist locally. Passing the bare `headRefName` as `baseBranch` will fail `git worktree add` in the common case. Git-ref resolution (`git fetch`, `git rev-parse`) belongs in main; the renderer does not shell out.
|
||||
|
||||
New IPC: `worktrees:resolvePrBase`
|
||||
```ts
|
||||
window.api.worktrees.resolvePrBase({
|
||||
repoId,
|
||||
prNumber,
|
||||
// Optional cache hints from the renderer's existing PR cache.
|
||||
// When both are present, main skips the `gh pr view` lookup.
|
||||
headRefName?: string,
|
||||
isCrossRepository?: boolean,
|
||||
}) => Promise<{ baseBranch: string } | { error: string }>
|
||||
```
|
||||
On PR selection, the picker calls this resolver. Main:
|
||||
1. If both hints are present, skip GitHub lookup. Otherwise resolve the PR via the existing GitHub client to obtain `headRefName` + `isCrossRepository`.
|
||||
2. Reject fork PRs (`isCrossRepository === true`) with `"Fork PRs aren't supported yet"`.
|
||||
3. Runs `git fetch <remote> <headRefName>` against the repo's default remote (see Default remote selection below).
|
||||
4. Verifies the fetched ref with `git rev-parse --verify <remote>/<headRefName>`.
|
||||
5. Returns `{ baseBranch: "<remote>/<headRefName>" }` or a user-readable error.
|
||||
|
||||
Pre-submit resolution surfaces "branch deleted on remote" in the picker, not at create time. The resolved string is the one passed to `CreateWorktreeArgs.baseBranch` — no special `kind: 'pr'` at the IPC boundary.
|
||||
|
||||
**Concurrency / stale resolves.** A PR selection commits to `baseBranch` only after `resolvePrBase` succeeds. If the user selects a second PR before the first resolves, the first resolve's result is discarded (last-click-wins). Track this in the picker with a per-click token or `AbortController`; do not simply `await` sequentially, or late resolves will clobber newer selections.
|
||||
|
||||
**Submit while resolve pending.** If the user triggers create while a `resolvePrBase` is still in flight, the submit path waits for the pending resolve (or fails fast with a visible "Resolving PR head…" state). It must not submit with an unresolved `baseBranch` and it must not submit with the *previous* selection's `baseBranch`.
|
||||
|
||||
**Default remote selection.** "Default remote" is not "the one named `origin`." Resolve inside `resolvePrBase` in this order: (1) the remote configured on the repo's default branch (`git config branch.<default>.remote`); (2) `origin` if present; (3) the single remote if the repo has exactly one; (4) error otherwise (ask the user to configure). Centralize this in a helper in `src/main/git/repo.ts` rather than re-deriving per call site.
|
||||
|
||||
**WSL repos.** The existing main-process helpers route through `isWslPath` / `parseWslPath` (`src/main/ipc/worktree-remote.ts:19`). `worktrees:resolvePrBase` must use the same routing for its `git fetch` / `rev-parse` calls, not bare `gitExecFileAsync` on a raw path, or WSL repos will fail to resolve.
|
||||
|
||||
**Draft restore.** `newWorkspaceDraft.baseBranch` is persisted. On composer mount a restored `baseBranch` may reference a ref that no longer exists (PR closed, branch deleted since yesterday). Current main-process behavior will silently fall back to `worktreeBaseRef` / default branch rather than erroring — this is the same v1 hole tracked in Follow-up chores. Optional v1 nicety: run a cheap `rev-parse --verify` on mount (local repos only); if the ref is gone, clear `baseBranch` in the draft and show a one-line hint in the field ("Previous start ref no longer exists"). Don't block the user.
|
||||
|
||||
**Accessibility.** The popover must support keyboard-only operation: arrow keys within a tab, Tab / Shift+Tab between tabs, Enter to commit, Esc to close without committing. Reuse the existing Link-UI popover's keyboard hook rather than re-implementing.
|
||||
|
||||
### `GitHubWorkItem` additions (`src/shared/types.ts:390`)
|
||||
|
||||
One new field is needed. `GitHubWorkItem` already carries `branchName?: string` (`src/shared/types.ts:400`), which `src/main/github/client.ts:636` populates from `headRefName` for PR rows. **Reuse `branchName` — do not introduce a second field meaning "PR head branch".** Throughout this spec, reads of "the PR's head branch" on `GitHubWorkItem` refer to `branchName`.
|
||||
|
||||
```ts
|
||||
isCrossRepository?: boolean // true = fork PR; disabled in picker
|
||||
```
|
||||
|
||||
**Verification:** `gh pr list` in `src/main/github/client.ts:280,401,610` currently requests `number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName`. Add `headRepositoryOwner` to the field list at all three sites; compute `isCrossRepository` in the mapper as `item.headRepositoryOwner?.login !== <selected repo owner>`. Mapper change lands at `src/main/github/client.ts:636`, where `branchName` is already set — this is the one place fork detection is computed.
|
||||
|
||||
### Draft persistence (`src/renderer/src/store/slices/ui.ts:64`)
|
||||
|
||||
Extend `newWorkspaceDraft` with `baseBranch?: string`. Absence means "use the repo's effective base ref" — no `null`-plus-conversion step, shape matches `CreateWorktreeArgs.baseBranch`. `linkedPR` / `linkedWorkItem` are already persisted, so PR selections round-trip without further schema changes.
|
||||
|
||||
No new `worktrees:create` wire change. The existing contract already carries `baseBranch`.
|
||||
|
||||
---
|
||||
|
||||
## Main-process changes
|
||||
|
||||
### IPC handlers
|
||||
|
||||
- `src/main/ipc/worktrees.ts` — wire the new `worktrees:resolvePrBase` handler (signature + algorithm defined in §Data model). Handler module may live adjacent if it grows.
|
||||
- `src/main/ipc/worktree-remote.ts` — **no changes**. The existing `||` fallback chain is preserved as-is. The picker emits validated refs (branch rows from `searchBaseRefs` exist; PR refs are `git fetch`ed + `rev-parse`d inside `resolvePrBase` before the picker commits them to `baseBranch`), so in practice the picker never hands an unresolvable ref to create.
|
||||
|
||||
### Base-ref resolution
|
||||
|
||||
No main-process branching on "kind". The renderer collapses every picker selection into a `baseBranch` string (and, for PR selections, `linkedPR` metadata). The existing `args.baseBranch || repo.worktreeBaseRef || <detected default>` chain in `createLocalWorktree` / `createRemoteWorktree` is unchanged.
|
||||
|
||||
Known limitation (acceptable for v1): if a picker-selected ref is deleted between selection and submit, create silently falls back instead of erroring. In practice this requires the remote branch to disappear in the seconds between picker commit and Create click — vanishingly rare. Tightening this into strict-when-explicit behavior is tracked as a follow-up chore (see `Follow-up chores`).
|
||||
|
||||
### Metadata persistence
|
||||
|
||||
PR selections set `linkedPR` in the existing composer state. The existing post-create `applyWorktreeMeta` call (`useComposerState.ts:880`) already writes it — no new write path.
|
||||
|
||||
If metadata persistence fails after the git worktree already exists, log and continue. The worktree is still valid even if the link badge is missing.
|
||||
|
||||
### GitHub data scope
|
||||
|
||||
- local repos, PRs: existing `gh:listWorkItems` (cached — see caching rules below)
|
||||
- local repos, direct number lookup: existing `gh:workItem` (cached)
|
||||
- branches: existing `repos:searchBaseRefs`
|
||||
|
||||
No new GitHub IPC is required for local repos in v1.
|
||||
|
||||
### Create-time validation
|
||||
|
||||
Unchanged. The picker pre-validates refs (branches via `searchBaseRefs` results, PR heads via `resolvePrBase`'s fetch + `rev-parse`), so most bad paths are caught before submit. The one remaining hole — ref deleted between commit and create — falls through to today's silent fallback; see `Follow-up chores`.
|
||||
|
||||
---
|
||||
|
||||
## Caching rules (must read)
|
||||
|
||||
PR searches and number lookups hit the user's `gh` CLI quota. The picker **must** ride on the existing SWR caches in `src/renderer/src/store/slices/github.ts`; it must not introduce a parallel fetch path.
|
||||
|
||||
**Required behavior:**
|
||||
|
||||
- **Always call `fetchWorkItems(repoPath, limit, query, options?)`** — never `window.api.gh.listWorkItems(...)` directly. The store already deduplicates in-flight requests (`inflightWorkItemsRequests`) and applies `WORK_ITEMS_CACHE_TTL`. Direct calls bypass both.
|
||||
- **Use the prefetch path to warm shared keys.** The cache key is `(repoPath, limit, query)`. The picker's query **must** match the prefetch query exactly or cache hits won't share. Use:
|
||||
- Prefetch on composer mount (local repo): `prefetchWorkItems(repoPath, 36, 'is:pr is:open')`.
|
||||
- Picker PR tab default list: `fetchWorkItems(repoPath, 36, 'is:pr is:open')`.
|
||||
- Picker PR tab user query: `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` — queries debounced ~150ms (matches existing Link-UI debounce) so rapid typing collapses to one fetch.
|
||||
- **Render cached results synchronously while revalidating.** Use `getCachedWorkItems(...)` for the first paint so opening the popover is instant and costs zero API calls when the cache is fresh.
|
||||
- **Direct-number lookup (`#123`, full URL) uses `gh:workItem` via its cache.** Same SWR contract; the picker reads `prCache`/`issueCache` synchronously first.
|
||||
- **Do not prefetch on every keystroke.** Only prefetch (a) on composer mount and (b) on popover open. Search queries go through the debounced `fetchWorkItems`, which dedupes against the cache anyway.
|
||||
- **PR resolver (`worktrees:resolvePrBase`) must reuse the renderer-side PR cache when available.** The renderer passes the already-known `headRefName` and `isCrossRepository` to the resolver (as an optional hint); main skips the `gh pr view` call when the hint is present. Only the `git fetch` + `git rev-parse` steps always run, since remote refs can change.
|
||||
- **Branch search (`repos:searchBaseRefs`) is git-local and cheap**; it does not count against GitHub quota, so fetch-on-demand when the Branches tab becomes active is acceptable. Debounce ~150ms to avoid redundant `git for-each-ref` invocations on large repos.
|
||||
|
||||
**Do not** call `prefetchWorkItems(repoPath, 'is:open')` — the second argument is `limit`, not `query`, and this would silently prefetch a different cache key than the picker reads.
|
||||
|
||||
**Audit existing prefetch callers.** `ui.ts:195` already calls `prefetchWorkItems(repo.path, 36, presetToQuery(preset))`. Confirm during implementation that at least one active task preset produces `'is:pr is:open'` (the exact string the picker queries) so the sidebar prefetch and the picker fetch share a cache key. If no preset matches exactly, add a dedicated mount-time prefetch in the composer and do not rely on the sidebar's opportunistic warming.
|
||||
|
||||
**Cache invalidation.** The existing SWR caches are keyed by `(repoPath, limit, query)` and expire via `WORK_ITEMS_CACHE_TTL`. The picker does **not** call `force: true` on every open — that defeats the cache. It only forces a refresh on explicit user action (e.g. a "Refresh" control in the popover, if added later). Stale-within-TTL is acceptable for picker use.
|
||||
|
||||
---
|
||||
|
||||
## Renderer changes
|
||||
|
||||
### 1. New components
|
||||
|
||||
- `src/renderer/src/components/new-workspace/StartFromField.tsx`
|
||||
- popover trigger (pill + title + chevron)
|
||||
- `src/renderer/src/components/new-workspace/StartFromPicker.tsx`
|
||||
- tabs: **Branches · Pull requests**
|
||||
- search input debounced ~150ms
|
||||
- PR tab calls `worktrees:resolvePrBase` on selection; shows an inline error on fetch/resolve failure *before* the user submits
|
||||
- on selection, calls back into `useComposerState` with `{ baseBranch, linkedWorkItem? }` — no new shared type
|
||||
|
||||
### 2. Popover state coverage
|
||||
|
||||
Each tab must render these states explicitly:
|
||||
|
||||
| Flow | Loading | Empty | Error | Success |
|
||||
|---|---|---|---|---|
|
||||
| Branches | skeleton rows | "No branches match" | inline error | list |
|
||||
| Pull requests | skeleton rows (only if no cached data) | "No open PRs" | "gh not available — Branches tab still works" | list (cached first, revalidated in background) |
|
||||
|
||||
Cached results must paint immediately; the loading state appears only when nothing is cached. This makes the common case a zero-API-cost open.
|
||||
|
||||
### 3. Integrate into shared composer state
|
||||
|
||||
Add `baseBranch?: string` state in `src/renderer/src/hooks/useComposerState.ts` (reusing the existing `linkedWorkItem` / `linkedPR` state for PR selections — don't introduce parallel `startFrom` state). The hook already owns:
|
||||
|
||||
- repo selection
|
||||
- `linkedWorkItem` + `linkedPR` (see `useComposerState.ts:209,223`)
|
||||
- auto-name behavior via `lastAutoNameRef` (`useComposerState.ts:262`)
|
||||
- full-page draft persistence
|
||||
- submit / submitQuick, with post-create `applyWorktreeMeta` already writing linked metadata (`useComposerState.ts:880`)
|
||||
|
||||
The modal wrapper should stay thin. `NewWorkspaceComposerModal.tsx` continues to pass through `cardProps` to `NewWorkspaceComposerCard`, while the card gets new props for rendering the field.
|
||||
|
||||
### 4. Picker data sources
|
||||
|
||||
- Branches tab:
|
||||
- `window.api.repos.searchBaseRefs({ repoId, query })`
|
||||
- PRs tab:
|
||||
- `fetchWorkItems(repoPath, 36, 'is:pr is:open')` for the default list
|
||||
- `fetchWorkItems(repoPath, 36, \`is:pr is:open \${userQuery}\`)` for typed queries
|
||||
- `getCachedWorkItems(...)` for first paint
|
||||
|
||||
Use the selected repo object already derived in `useComposerState`; do not introduce a separate `reposById` dependency unless the store actually gains one.
|
||||
|
||||
Filter PR results to same-repo heads only (`!isCrossRepository`). Fork PRs render disabled with explanatory copy, not silently filtered, so the user understands why their PR isn't selectable.
|
||||
|
||||
Normalize PR queries before dispatching GitHub lookups. Route by shape:
|
||||
|
||||
- bare number (`123`), `#123`, or a full GitHub PR URL for the selected repo → strip to the number and dispatch `gh:workItem` (reads `prCache` first per §Caching rules). `getWorkItem` returns `type: 'pr' | 'issue'`; when `type !== 'pr'`, treat as no-match in the PR tab (number collides with an issue).
|
||||
- full GitHub PR URL for a *different* repo → silently fall back to free-text search. Do not hard-block; users paste URLs because they want the content.
|
||||
- anything else → pass through as a free-text query to `fetchWorkItems(..., \`is:pr is:open \${query}\`)`.
|
||||
|
||||
### 5. Repo-change reset
|
||||
|
||||
On repo change:
|
||||
|
||||
- reset `baseBranch` to `undefined` (so the field shows the new repo's effective base ref as placeholder)
|
||||
- clear any transient picker state tied to the previous repo
|
||||
- the field's trigger copy shows the reset inline (e.g. *"Default branch — was PR #N"*) when a selection was cleared
|
||||
|
||||
The existing `handleRepoChange` callback (`useComposerState.ts:819`) already clears `linkedIssue` / `linkedPR` / `linkedWorkItem` inline; extend it to also clear `baseBranch`. One callback, not a new effect.
|
||||
|
||||
### 6. Naming behavior
|
||||
|
||||
When the picker selects a PR, it sets the existing `linkedWorkItem` state. The composer's existing auto-name path (which reacts to `linkedWorkItem` via `getLinkedWorkItemSuggestedName` and `lastAutoNameRef`) will update `name` iff `name === '' || name === lastAutoNameRef.current` — i.e. the name is still auto-managed. Once the user edits the name, subsequent selections leave it alone. This is the existing Link-UI rule; no new naming code.
|
||||
|
||||
### 7. Submission
|
||||
|
||||
Thread `baseBranch` through:
|
||||
|
||||
- `useComposerState` submit paths (already constructs `CreateWorktreeArgs` — just add the field)
|
||||
- persisted `newWorkspaceDraft`
|
||||
- store `createWorktree(...)` → preload `window.api.worktrees.create(...)` → main `CreateWorktreeArgs` (field already exists)
|
||||
|
||||
`linkedPR` needs no new wiring — the post-create `applyWorktreeMeta` call already writes it.
|
||||
|
||||
### 8. Prefetch
|
||||
|
||||
On composer mount (local repo only), warm the PR cache:
|
||||
|
||||
```ts
|
||||
prefetchWorkItems(repoPath, 36, 'is:pr is:open')
|
||||
```
|
||||
|
||||
Do **not** call `prefetchWorkItems(repoPath, 'is:open')`; the second argument is `limit`, not `query`. Do **not** use a query string that differs from what the picker will fetch — mismatched keys produce a double fetch.
|
||||
|
||||
Branch results stay fetch-on-demand when the Branches tab becomes active.
|
||||
|
||||
---
|
||||
|
||||
## Shortcut discoverability
|
||||
|
||||
Out of scope for this spec. A follow-up can add a split "+" button with `CmdOrCtrl+Shift+N` for a more explicit "Create from…" entry point.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- **Issues tab.** Issues do not change the start ref; use the existing Link UI to link an issue.
|
||||
- **Checking out an existing branch without `-b`.** Orca's create flow always derives a new branch from the workspace name; "Start from PR" means branch from the PR head, not open the PR branch directly.
|
||||
- **Fork PR start points.** Disabled in v1.
|
||||
- **SSH PR start points.** Disabled in v1.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
|---|---|
|
||||
| User picks PR, then renames Name manually | Manual name wins (existing `lastAutoNameRef` rule) |
|
||||
| User picks PR, then picks a different PR without editing name | Name updates to the new PR's suggestion |
|
||||
| User picks PR from a fork | picker disables it in v1; no create attempt |
|
||||
| PR head branch has since been deleted at picker open | picker surfaces resolve error before submit; create never attempted |
|
||||
| PR head fetch fails (network/auth) | picker surfaces the fetch error; selection does not commit |
|
||||
| User picks branch, switches repo, switches back | no cross-repo picker state is preserved |
|
||||
| Offline / `gh` CLI missing | PRs tab shows error state; Branches tab still works |
|
||||
| Remote repo over SSH | only Branches tab is enabled in v1; PRs tab disabled with explanatory copy |
|
||||
| Repo has `worktreeBaseRef` set to non-default branch | reset behavior uses that configured base ref |
|
||||
| User pastes `#123` or a full GitHub PR URL | picker normalizes to the work item number and uses `gh:workItem` cache |
|
||||
| Pasted number resolves to an issue, not a PR, in the PR tab | treated as no-match; user sees empty-state copy |
|
||||
| User pastes a PR URL for a different repo | picker silently falls back to free-text search |
|
||||
| Selected ref (branch or PR head) disappears between picker commit and create | falls through to today's `worktreeBaseRef` / default-branch fallback (acceptable v1 hole; tracked in Follow-up chores) |
|
||||
| Restored draft references a ref that no longer exists | same silent fallback at create; optional v1 nicety clears the draft field with an inline hint on mount |
|
||||
| Popover opened with fresh cache | renders instantly from `getCachedWorkItems`; zero API calls |
|
||||
| User selects PR, then quickly selects a different PR before first resolve returns | last-click wins; stale resolve is discarded (AbortController / token) |
|
||||
| User hits Create while `resolvePrBase` is still pending | submit waits for the in-flight resolve; never submits with a stale `baseBranch` |
|
||||
| User rapidly toggles between Branches and PRs tabs mid-fetch | in-flight search requests for the prior tab are aborted; stale rows never render |
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
- **Unit / renderer**
|
||||
- `StartFromField` renders the correct pill for each selection kind
|
||||
- repo change resets `baseBranch` and the field shows the reset inline
|
||||
- full-page draft persistence round-trips `baseBranch` (and existing linked-work-item fields)
|
||||
- PR selection updates the actual `name` state only while it remains auto-managed (`name === '' || name === lastAutoNameRef.current`)
|
||||
- PR selection mirrors into linked-work-item state; switching back to branch does **not** clear the link
|
||||
- SSH repos disable PR tab
|
||||
- cross-repo PRs are disabled with explanatory copy
|
||||
- `#123` and full GitHub PR URLs normalize to number search
|
||||
- pasted cross-repo URLs fall back to free-text search (no hard error)
|
||||
- picker writes short name for local branch rows, `<remote>/<name>` for remote-tracking rows
|
||||
- PR selection calls `worktrees:resolvePrBase` and threads the resolved ref into `baseBranch`
|
||||
- **Caching**
|
||||
- opening the PR tab with a fresh cache triggers **zero** `window.api.gh.listWorkItems` calls (assert via spy)
|
||||
- prefetch on composer mount and picker default fetch share the same cache key (`(repoPath, 36, 'is:pr is:open')`)
|
||||
- rapid typing in the PR search debounces to a single fetch
|
||||
- direct-number lookup (`#123`) reads `prCache`/`issueCache` synchronously before hitting `gh:workItem`
|
||||
- `worktrees:resolvePrBase` skips `gh pr view` when the renderer passes a cached `headRefName` hint
|
||||
- rapid re-selection aborts prior `resolvePrBase`; only the latest selection's result commits
|
||||
- submit while `resolvePrBase` is pending waits for it; never submits a stale `baseBranch`
|
||||
- **Main-process**
|
||||
- `worktrees:resolvePrBase` fetches via the repo's default remote (not hardcoded `origin`), returns resolved ref on success
|
||||
- `worktrees:resolvePrBase` returns a user-readable error when the remote branch is missing or fetch fails
|
||||
- `worktrees:resolvePrBase` rejects fork PRs
|
||||
- `isCrossRepository` is populated on `GitHubWorkItem` PR rows from `headRepositoryOwner`
|
||||
- **Manual**
|
||||
- quick create without touching the field behaves exactly as today
|
||||
- create from same-repo PR creates a new branch from the PR head and shows the PR badge
|
||||
- SSH repo shows branch start points only
|
||||
- changing repo mid-flow resets the field and shows the inline reset copy
|
||||
- opening the picker a second time within the cache TTL makes zero network calls
|
||||
|
||||
---
|
||||
|
||||
## Rollout
|
||||
|
||||
Single PR. No feature flag. The change is additive and backward-compatible — `baseBranch` is already optional on `CreateWorktreeArgs`, and `linkedPR` metadata is already written by existing code.
|
||||
|
||||
## Files touched
|
||||
|
||||
```text
|
||||
src/shared/types.ts (add isCrossRepository to GitHubWorkItem; branchName already carries PR head)
|
||||
src/main/github/client.ts (add headRepositoryOwner to gh pr list field set; populate isCrossRepository)
|
||||
src/main/ipc/worktrees.ts (new worktrees:resolvePrBase handler)
|
||||
src/main/git/repo.ts (default-remote helper used by resolvePrBase)
|
||||
src/preload/index.ts (invoke worktrees:resolvePrBase)
|
||||
src/preload/api-types.d.ts (type worktrees.resolvePrBase)
|
||||
src/renderer/src/hooks/useComposerState.ts (baseBranch state + repo-change reset)
|
||||
src/renderer/src/components/NewWorkspaceComposerCard.tsx (render the field)
|
||||
src/renderer/src/components/new-workspace/StartFromField.tsx (new: pill trigger)
|
||||
src/renderer/src/components/new-workspace/StartFromPicker.tsx (new: tabs + picker; SWR via fetchWorkItems/getCachedWorkItems)
|
||||
src/renderer/src/store/slices/ui.ts (baseBranch in newWorkspaceDraft)
|
||||
src/renderer/src/lib/new-workspace.ts (if a small display helper is needed)
|
||||
```
|
||||
|
||||
Estimated effort: ~1.5 engineering days — new `worktrees:resolvePrBase` IPC, `isCrossRepository` plumbing through the `gh` client and its mappers, picker + cancellation, URL normalization, prefetch key alignment, and renderer + main tests. Fully additive; no behavior change for existing flows.
|
||||
|
||||
## Follow-up chores
|
||||
|
||||
File these as separate issues at ship time, not in this PR:
|
||||
|
||||
- **Strict-when-explicit base-ref validation.** Today `createLocalWorktree` / `createRemoteWorktree` silently fall back when an explicit `args.baseBranch` is unresolvable. The Start-from picker avoids this in practice by pre-validating, but a ref deleted between selection and submit still falls through. Replace the `||` chain with strict-when-explicit / fallback-when-implicit; add `SshGitProvider.verifyRef` for the remote path. Standalone refactor, own test coverage, affects all callers of `CreateWorktreeArgs.baseBranch`.
|
||||
- **Main-side default-remote derivation.** `createLocalWorktree` / `createRemoteWorktree` derive remote as `origin` when `baseBranch` lacks a slash. Centralize default-remote resolution (push remote of default branch → `origin` → single remote) and share with `resolvePrBase`.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Design: Searchable Repository Selection in New Worktree Dialog
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
GitHub issue [#379](https://github.com/stablyai/orca/issues/379): when creating a new worktree, the user must manually scroll a plain `Select` dropdown to pick the target repository. This does not scale once Orca manages many repositories.
|
||||
|
||||
The current Radix `Select` in `AddWorktreeDialog` has two product problems:
|
||||
|
||||
- It forces serial scanning instead of direct search.
|
||||
- It makes the most important first step in the worktree-creation flow slower than every subsequent step.
|
||||
|
||||
The issue has no explicit acceptance criteria, so implementation requirements are inferred from the existing UX:
|
||||
|
||||
- Users must be able to search for a repository by name while creating a worktree.
|
||||
- Existing auto-preselection behavior (preselectedRepoId, activeWorktreeRepoId, activeRepoId fallback chain) must keep working.
|
||||
- Keyboard and mouse selection must both work.
|
||||
- The change must not break the rest of the create-worktree flow: setup lookup, issue-command lookup, validation, and Enter-to-create behavior.
|
||||
|
||||
## 2. Approach
|
||||
|
||||
Replace the repository `Select` with a searchable combobox built from Orca's existing `Command` primitive (cmdk-based) and a new shared `Popover` primitive (Radix-based).
|
||||
|
||||
Rationale:
|
||||
|
||||
- A combobox is the standard pattern for "pick one item from a potentially long list, with search."
|
||||
- Orca already ships `cmdk`-based command surfaces and Radix primitives, so this fits the existing stack without introducing new dependencies.
|
||||
- Keeping the search scoped to the repo field preserves the current worktree-creation layout instead of turning the entire dialog into a command palette.
|
||||
- Search logic lives in a pure helper (`searchRepos`) so matching rules are explicit and regression-testable outside React.
|
||||
|
||||
### Matching and ranking
|
||||
|
||||
- Search is case-insensitive substring matching.
|
||||
- Primary target is `repo.displayName` (what issue #379 is specifically about). Secondary target is `repo.path` for disambiguating repos with similar display names.
|
||||
- Ranking uses position-based scoring: matches earlier in the string rank higher. Display-name matches always outrank path-only matches (path scores are offset by 1000). Ties preserve original list order.
|
||||
- Empty query returns the full eligible repo list in its original order.
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### `src/renderer/src/components/ui/popover.tsx`
|
||||
|
||||
New shared Popover wrapper around Radix `Popover`. Follows the same styling conventions as existing `dialog.tsx`, `select.tsx`, and `dropdown-menu.tsx`. Generic and reusable — not inlined inside the worktree dialog.
|
||||
|
||||
### `src/renderer/src/lib/repo-search.ts`
|
||||
|
||||
New pure helper (`searchRepos`) that filters and ranks eligible repositories by a query string. Normalizes trimming and lowercasing in one place. Scoring logic:
|
||||
|
||||
- Display-name hit: score = index of substring match within `displayName`.
|
||||
- Path-only hit: score = 1000 + index of substring match within `path`.
|
||||
- No hit: excluded from results.
|
||||
- Stable sort by `(score, originalIndex)` so equivalent matches preserve list order.
|
||||
|
||||
### `src/renderer/src/lib/repo-search.test.ts`
|
||||
|
||||
Unit tests covering:
|
||||
|
||||
- Empty query returns all repos in original order.
|
||||
- Display-name matching is case-insensitive.
|
||||
- Path fallback works when the display name does not match.
|
||||
- Display-name matches rank ahead of path-only matches.
|
||||
|
||||
### `src/renderer/src/components/repo/RepoCombobox.tsx`
|
||||
|
||||
New reusable combobox component. Props: `repos`, `value`, `onValueChange`, optional `placeholder`.
|
||||
|
||||
Key design decisions:
|
||||
|
||||
- **External filtering**: `Command` is rendered with `shouldFilter={false}` because filtering is handled by `searchRepos`, not by cmdk's built-in filter. This keeps ranking rules in the pure helper where they are testable.
|
||||
- **Trigger and content markup**: Both the trigger `Button` and `PopoverContent` carry `data-repo-combobox-root="true"` so the parent dialog's keydown handler can detect events originating inside the combobox surface (see Section 5).
|
||||
- **Selected repo display**: The trigger renders the selected repo using the existing `RepoDotLabel` component, consistent with how repos appear elsewhere in Orca.
|
||||
- **Result items**: Each item shows `RepoDotLabel` for the display name plus the full `repo.path` as secondary text, so repos with similar names are visually distinguishable.
|
||||
- **Popover width**: `PopoverContent` uses `w-[var(--radix-popover-trigger-width)]` to match the trigger width for visual alignment.
|
||||
- **Focus management**: `CommandInput` has `autoFocus` so the search input receives focus immediately when the popover opens.
|
||||
- **Query reset on close**: `handleOpenChange` clears the query when the popover closes so stale filter text from a previous interaction does not hide repos on the next open.
|
||||
- **Empty state**: `CommandEmpty` shows an explicit message when no repo matches.
|
||||
|
||||
### `src/renderer/src/components/sidebar/AddWorktreeDialog.tsx`
|
||||
|
||||
Changes to the existing dialog:
|
||||
|
||||
- Replace the `Select` repository control with `RepoCombobox`.
|
||||
- All existing repo-selection state and side effects are preserved unchanged: `repoId`, `handleRepoChange`, auto-preselection on open, hook lookup, and issue-command lookup.
|
||||
- Update the dialog-level `handleKeyDown` to suppress Enter when the event target lives inside a `[data-repo-combobox-root="true"]` element (see Section 5).
|
||||
|
||||
## 4. Edge Cases
|
||||
|
||||
| Scenario | Handling |
|
||||
|---|---|
|
||||
| No eligible git repos | Existing guard closes the dialog; the combobox is never rendered in a broken state. |
|
||||
| Only one eligible repo | The combobox still renders normally. Search is trivial, but the UI stays consistent. |
|
||||
| Multiple repos share a similar display name | Search also matches `repo.path`, and each item shows the full path as secondary text for disambiguation. |
|
||||
| User opens combobox, types a query, closes, reopens | Query resets on close so the next open starts from the full list. |
|
||||
| Enter pressed while focus is inside the repo search input | The dialog-level create shortcut is suppressed for events originating inside the combobox (see Section 5). |
|
||||
| Preselected repo is not the first repo in the list | Auto-preselection logic is unchanged; the selected value is still controlled by `repoId` state. |
|
||||
| Repo list changes while the dialog is open | The combobox renders from the live `eligibleRepos` array and updates reactively. If the selected repo disappears, existing dialog validation prevents create without a valid `selectedRepo`. |
|
||||
|
||||
## 5. Regressions and Mitigations
|
||||
|
||||
### Enter inside repo search creates a worktree
|
||||
|
||||
This is the most important correctness constraint. Without mitigation, typing a search query and pressing Enter to select a repo would bubble up to the dialog's keydown handler and trigger worktree creation.
|
||||
|
||||
Mitigation: `data-repo-combobox-root="true"` is placed on both the combobox trigger button and the popover content. The dialog's `handleKeyDown` checks `e.target.closest('[data-repo-combobox-root="true"]')` and returns early when it finds a match. This covers Enter events from:
|
||||
|
||||
- The `CommandInput` search field (inside the popover content).
|
||||
- The trigger button itself when the popover is closed (Enter should open the popover, not submit the form).
|
||||
|
||||
An inline comment in `handleKeyDown` documents why this guard exists.
|
||||
|
||||
### Stale search query hides repos on reopen
|
||||
|
||||
Mitigation: `RepoCombobox.handleOpenChange` resets the query to `''` whenever the popover closes. An inline comment documents the timing interaction with the dialog's own delayed field reset.
|
||||
|
||||
### Repo preselection breaks
|
||||
|
||||
Mitigation: `repoId` remains the single source of truth for the selected repository. Only the presentation control was swapped; the state management and auto-selection logic in `AddWorktreeDialog` are untouched.
|
||||
|
||||
### Users cannot distinguish similarly named repos
|
||||
|
||||
Mitigation: Each combobox item shows both the `RepoDotLabel` display name and the full `repo.path` as secondary text.
|
||||
|
||||
### Matching behavior drifts over time
|
||||
|
||||
Mitigation: Search rules live in the pure `searchRepos` helper with dedicated unit tests, not inlined in JSX.
|
||||
|
||||
## 6. Test Plan
|
||||
|
||||
### Unit tests (`repo-search.test.ts`)
|
||||
|
||||
- Empty query returns all repos in original order.
|
||||
- Display-name matching is case-insensitive.
|
||||
- Path fallback works when the display name does not match.
|
||||
- Display-name matches rank ahead of path-only matches.
|
||||
|
||||
### Manual verification
|
||||
|
||||
- Open the New Worktree dialog with multiple repos.
|
||||
- Confirm the repo field opens a searchable combobox instead of a plain dropdown.
|
||||
- Type part of a repo name and verify the list filters immediately.
|
||||
- Press Enter to select a filtered repo and verify the dialog does not submit.
|
||||
- Create a worktree after selecting a repo through search and verify the existing create flow still works end-to-end (setup, issue command, metadata).
|
||||
- Reopen the dialog and verify the previous search text is cleared.
|
||||
- Verify the preselected repo still appears when opening from flows that pass `preselectedRepoId`.
|
||||
- Verify keyboard navigation (arrow keys, Enter to select, Escape to close popover) works within the combobox.
|
||||
@@ -1,213 +0,0 @@
|
||||
# Design: Preserve Scroll Position Per File in Editor
|
||||
|
||||
## Context
|
||||
|
||||
When navigating between file tabs, the scroll position resets to the top because each editor component unmounts and remounts. The cursor line is already tracked per file via `editorCursorLine` in the store, but scroll position is not. This causes a poor UX when users switch between files frequently.
|
||||
|
||||
## Approach
|
||||
|
||||
Use a module-scoped `Map<string, number>` with LRU eviction, following the proven pattern from `CombinedDiffViewer` (lines 39–58). The scroll cache is extracted into a shared utility at `src/renderer/src/lib/scroll-cache.ts` so all viewer components share one bounded Map.
|
||||
|
||||
**Why not Zustand?** Scroll position updates at high frequency during user interaction. Putting it in Zustand means every write spreads a new object (`{ ...s.editorScrollTop, [fileId]: scrollTop }`), which (a) causes React re-render notifications even though no component subscribes to scroll position for rendering, and (b) generates object allocation churn on every scroll event. A module-scoped Map avoids both problems: zero re-renders, zero GC pressure, O(1) reads and writes. CombinedDiffViewer already validates this approach in production.
|
||||
|
||||
**Why LRU at 20 entries?** Without a cap, the Map grows unboundedly as unique file IDs accumulate across a session. 20 entries covers typical tab working sets with headroom. This matches `CombinedDiffViewer`'s existing cap and means no explicit cleanup is needed when files are closed — eviction is automatic.
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### 1. Shared Utility: `src/renderer/src/lib/scroll-cache.ts` (new file)
|
||||
|
||||
Extract `setWithLRU` from `CombinedDiffViewer` into a shared module, and expose a single scroll cache Map:
|
||||
|
||||
```ts
|
||||
const CACHE_MAX_ENTRIES = 20
|
||||
|
||||
// Why: Module-scoped Maps grow unboundedly as unique file keys accumulate.
|
||||
// Cap them with a simple LRU eviction: after each set, if the map exceeds
|
||||
// this limit, delete the oldest entry (Maps iterate in insertion order).
|
||||
export function setWithLRU<K, V>(map: Map<K, V>, key: K, value: V): void {
|
||||
// Re-insert to refresh insertion order (move to end).
|
||||
map.delete(key)
|
||||
map.set(key, value)
|
||||
if (map.size > CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = map.keys().next().value
|
||||
if (oldestKey !== undefined) {
|
||||
map.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: A single shared Map for scroll positions across all editor components.
|
||||
// Module-scoped so it survives component unmount/remount without triggering
|
||||
// React re-renders (unlike Zustand, which would broadcast state changes on
|
||||
// every scroll event even though no component renders from scroll position).
|
||||
export const scrollTopCache = new Map<string, number>()
|
||||
```
|
||||
|
||||
After extracting, update `CombinedDiffViewer` to import `setWithLRU` from this module instead of defining it inline. `CombinedDiffViewer`'s own `combinedDiffViewStateCache` and `combinedDiffScrollTopCache` stay local since their value types are component-specific.
|
||||
|
||||
---
|
||||
|
||||
### 2. MonacoEditor: `src/renderer/src/components/editor/MonacoEditor.tsx`
|
||||
|
||||
**Save scroll position:**
|
||||
|
||||
In `handleMount` (line ~62), add a throttled scroll listener:
|
||||
|
||||
```ts
|
||||
import { scrollTopCache, setWithLRU } from '@renderer/lib/scroll-cache'
|
||||
|
||||
// Why: Writing to the Map at 60fps (every scroll frame) is unnecessary since
|
||||
// we only need the final position when the user stops scrolling or switches
|
||||
// tabs. A trailing throttle of ~150ms captures the resting position while
|
||||
// avoiding excessive writes.
|
||||
let scrollThrottleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
editorInstance.onDidScrollChange((e) => {
|
||||
if (scrollThrottleTimer !== null) clearTimeout(scrollThrottleTimer)
|
||||
scrollThrottleTimer = setTimeout(() => {
|
||||
setWithLRU(scrollTopCache, filePath, e.scrollTop)
|
||||
scrollThrottleTimer = null
|
||||
}, 150)
|
||||
})
|
||||
```
|
||||
|
||||
Also snapshot the current position synchronously in the cleanup/unmount path (before the timeout fires) so tab switches always capture the latest value — same pattern as `CombinedDiffViewer`'s `updateCachedScrollPosition()` call in its cleanup return.
|
||||
|
||||
**Restore scroll position:**
|
||||
|
||||
In the `else` branch (line ~118) where there is NO pending reveal:
|
||||
|
||||
```ts
|
||||
const savedScrollTop = scrollTopCache.get(filePath)
|
||||
if (savedScrollTop !== undefined) {
|
||||
// Why: Monaco renders synchronously, so a single RAF is sufficient to
|
||||
// wait for the layout pass. Unlike react-markdown or Tiptap, there is
|
||||
// no async content loading that would require a retry loop.
|
||||
requestAnimationFrame(() => editorInstance.setScrollTop(savedScrollTop))
|
||||
}
|
||||
```
|
||||
|
||||
**Key edge case:** When `pendingEditorReveal` exists (search-result navigation), skip scroll restoration — `performReveal` handles its own scroll.
|
||||
|
||||
---
|
||||
|
||||
### 3. MarkdownPreview: `src/renderer/src/components/editor/MarkdownPreview.tsx`
|
||||
|
||||
**Mode-scoped cache key:**
|
||||
|
||||
```ts
|
||||
// Why: Each markdown viewing mode (source/rich/preview) produces different
|
||||
// DOM structures and content heights. A scroll position saved in source mode
|
||||
// (a code block at line 500) has no meaningful correspondence in preview mode
|
||||
// (rendered HTML at a completely different height). Using mode-scoped keys
|
||||
// means each mode remembers its own position independently.
|
||||
const scrollCacheKey = `${filePath}:preview`
|
||||
```
|
||||
|
||||
**Save scroll position:**
|
||||
|
||||
Add a throttled scroll listener on `rootRef.current` (the scrollable div, line 192) via `useLayoutEffect`:
|
||||
|
||||
```ts
|
||||
useLayoutEffect(() => {
|
||||
const container = rootRef.current
|
||||
if (!container) return
|
||||
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (throttleTimer !== null) clearTimeout(throttleTimer)
|
||||
throttleTimer = setTimeout(() => {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
throttleTimer = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
// Snapshot final position synchronously before detach.
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
if (throttleTimer !== null) clearTimeout(throttleTimer)
|
||||
container.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [scrollCacheKey])
|
||||
```
|
||||
|
||||
**Restore scroll position (RAF retry loop):**
|
||||
|
||||
react-markdown renders asynchronously — content may not be in the DOM when the layout effect first runs, so `scrollHeight` is still small and `scrollTop` gets clamped to 0. Use `CombinedDiffViewer`'s RAF retry pattern (lines 353–388) to keep attempting until content has loaded:
|
||||
|
||||
```ts
|
||||
useLayoutEffect(() => {
|
||||
const container = rootRef.current
|
||||
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
|
||||
if (!container || targetScrollTop === undefined) return
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
|
||||
// Why: react-markdown renders asynchronously, so scrollHeight may still be
|
||||
// too small on the first frame. Retry up to 30 frames (~500ms at 60fps) to
|
||||
// accommodate content loading. This matches CombinedDiffViewer's proven
|
||||
// pattern for dynamic-height content restoration.
|
||||
const tryRestore = (): void => {
|
||||
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
|
||||
container.scrollTop = nextScrollTop
|
||||
|
||||
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(tryRestore)
|
||||
}
|
||||
}
|
||||
|
||||
tryRestore()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [scrollCacheKey])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. RichMarkdownEditor: `src/renderer/src/components/editor/RichMarkdownEditor.tsx`
|
||||
|
||||
Same approach as MarkdownPreview with two differences:
|
||||
|
||||
1. **Cache key:** `${filePath}:rich`
|
||||
2. **Scroll container:** The scrollable container is `<EditorContent>` with `overflow-auto` (line 334). Wrap in a div with a ref to get the scroll container, move `overflow-auto` to wrapper.
|
||||
|
||||
The RAF retry loop is needed here too — Tiptap renders asynchronously as it hydrates its ProseMirror document, so `scrollHeight` may be undersized on the initial frame.
|
||||
|
||||
Save/restore logic is identical to MarkdownPreview, substituting the container ref and cache key.
|
||||
|
||||
---
|
||||
|
||||
### 5. DiffViewer — Deferred
|
||||
|
||||
DiffViewer doesn't currently receive `filePath` and diff views are typically opened for brief review. Deferring to avoid scope creep. `CombinedDiffViewer` already has its own scroll cache; if DiffViewer needs one later it can import from `scroll-cache.ts`.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Case | Behavior |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pending reveal (search nav) | Scroll restoration skipped; `performReveal` takes priority |
|
||||
| First open (no saved position) | `undefined` in cache → no restoration, starts at top |
|
||||
| Markdown mode switch (source → preview) | Mode-scoped keys (`path:source`, `path:preview`, `path:rich`) mean each mode preserves its own position independently. No cross-mode confusion. |
|
||||
| Scroll at 0 | Always restore (including 0) to guard against Monaco auto-scroll behavior |
|
||||
| External file content changes | **Known trade-off:** When external changes add/remove content above the viewport, the saved scroll position points to different content. Monaco clamps `scrollTop` if it exceeds the new document length, but does not adjust for insertions above the viewport. HTML containers (`MarkdownPreview`, `RichMarkdownEditor`) behave the same way — the browser clamps `scrollTop` to `scrollHeight - clientHeight`. This is acceptable: scroll position is a best-effort hint, not a semantic anchor. Fixing this would require mapping scroll offsets to content anchors (like line numbers), which is out of scope. |
|
||||
| LRU eviction (>20 files) | Oldest scroll entry is evicted. User sees top-of-file on return to a very old tab — same as a fresh open. No data corruption or memory leak. |
|
||||
| Close file, reopen | LRU may or may not still have the entry. If present, position restores. If evicted, starts at top. No explicit cleanup needed. |
|
||||
| Async content (react-markdown, Tiptap) | RAF retry loop (up to 30 attempts) handles content that renders after the initial layout pass. Falls back to best-effort clamped position if content never reaches the target height. |
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open a code file, scroll down ~50%, switch to another tab, switch back → verify position preserved
|
||||
2. Open a markdown file in preview mode, scroll down, switch tabs, return → verify position preserved
|
||||
3. Open a markdown file in rich mode, scroll down, switch tabs, return → verify position preserved
|
||||
4. Switch a markdown file from source to preview mode → verify each mode has independent scroll position (scroll in source, switch to preview, preview starts at its own saved position or top)
|
||||
5. Use Cmd+Shift+F to search, click a result → verify it scrolls to the match (NOT saved position)
|
||||
6. Open 25+ files to trigger LRU eviction, return to the earliest file → verify it starts at top gracefully
|
||||
7. `pnpm run typecheck` passes
|
||||
@@ -1,654 +0,0 @@
|
||||
# Design: Persist and Auto-Restore SSH Sessions Across App Restarts
|
||||
|
||||
**Issue:** [#985](https://github.com/stablyai/orca/issues/985)
|
||||
**Branch:** `Jinwoo-H/persist-ssh-985`
|
||||
**Author:** Jinwoo Hong
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
When Orca quits (intentionally, via update, or crash), all SSH connections and remote terminal sessions are lost. On restart, users must manually re-open each remote worktree, wait for SSH + relay redeploy, and re-start every remote process — even though the relay daemon on the remote host stays alive during its 5-minute grace window.
|
||||
|
||||
Three specific gaps:
|
||||
|
||||
1. **SSH connections are not persisted.** `buildWorkspaceSessionPayload()` in `src/renderer/src/lib/workspace-session.ts` captures terminal tabs, layouts, editor files, and browser tabs but has zero SSH state. `WorkspaceSessionState` has no fields for active connection IDs.
|
||||
|
||||
2. **Remote PTY session IDs are not persisted.** `reconnectPersistedTerminals()` in `src/renderer/src/store/slices/terminals.ts:1205` explicitly skips SSH-backed repos (`const supportsDeferredReattach = !repo?.connectionId`). Even if the relay keeps PTYs alive, Orca doesn't know their session IDs after restart.
|
||||
|
||||
3. **Shutdown doesn't serialize SSH state.** Neither `beforeunload` in `App.tsx` nor `will-quit` in `src/main/index.ts` records which SSH targets were connected or what remote PTY session IDs existed.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope (Layer 1 — app-bound lifetime):**
|
||||
- Persist active SSH connection targets and remote PTY session IDs at shutdown
|
||||
- Auto-reconnect SSH targets on startup
|
||||
- Reattach remote PTYs via the relay's existing `pty.attach` RPC
|
||||
- Make the relay grace window configurable via settings
|
||||
|
||||
**Out of scope (Layer 2 — future):**
|
||||
- Background daemon owning SSH connections independent of app lifecycle
|
||||
- Credential persistence (passphrases/passwords remain session-only)
|
||||
|
||||
## Existing Architecture
|
||||
|
||||
### What already works
|
||||
|
||||
| Component | File | Behavior |
|
||||
|-----------|------|----------|
|
||||
| Relay grace timer | `src/relay/relay.ts:15,70-76` | Keeps PTYs alive 5 min after client disconnect |
|
||||
| Relay `pty.attach` | `src/relay/pty-handler.ts` | Replays buffered output on reattach |
|
||||
| In-session reconnect | `src/main/ipc/ssh-relay-helpers.ts` | `reestablishRelayStack()` re-deploys relay, reattaches PTYs for transient drops |
|
||||
| SSH target persistence | `src/main/persistence.ts:365-398` | Target configs (host, user, identity) survive restarts |
|
||||
| Local PTY reattach | `src/renderer/src/store/slices/terminals.ts:1167-1266` | Daemon-mode local PTYs reattach via `pendingReconnectPtyIdByTabId` |
|
||||
| PTY ownership tracking | `src/main/ipc/pty.ts` | `getPtyIdsForConnection()` maps PTY IDs to connection targets (in-memory) |
|
||||
| SSH connection retry | `src/main/ssh/ssh-connection.ts` | Exponential backoff reconnection for transient errors |
|
||||
|
||||
### Connection lifecycle (current)
|
||||
|
||||
```
|
||||
App Start → hydrate repos/worktrees → reconnectPersistedTerminals()
|
||||
↓
|
||||
skips SSH repos (connectionId truthy)
|
||||
↓
|
||||
SSH tabs show as disconnected
|
||||
```
|
||||
|
||||
```
|
||||
App Shutdown → beforeunload → buildWorkspaceSessionPayload()
|
||||
↓
|
||||
captures local PTY IDs, tabs, layouts
|
||||
does NOT capture SSH connection state
|
||||
does NOT capture remote PTY session IDs
|
||||
```
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Extend WorkspaceSessionState
|
||||
|
||||
**File:** `src/shared/types.ts`
|
||||
|
||||
Add two new optional fields:
|
||||
|
||||
```typescript
|
||||
export type WorkspaceSessionState = {
|
||||
// ... existing fields ...
|
||||
|
||||
/** SSH target IDs that were connected at shutdown. Used on startup to
|
||||
* auto-reconnect before attempting remote PTY reattach. */
|
||||
activeConnectionIdsAtShutdown?: string[]
|
||||
|
||||
/** Maps tab IDs to their remote relay PTY session IDs. Mirrors
|
||||
* pendingReconnectPtyIdByTabId but for SSH-backed terminals. Populated
|
||||
* at shutdown from the main-process PTY ownership registry. */
|
||||
remoteSessionIdsByTabId?: Record<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/shared/workspace-session-schema.ts`
|
||||
|
||||
Add corresponding Zod schemas:
|
||||
|
||||
```typescript
|
||||
activeConnectionIdsAtShutdown: z.array(z.string()).optional(),
|
||||
remoteSessionIdsByTabId: z.record(z.string(), z.string()).optional(),
|
||||
```
|
||||
|
||||
### 2. Capture SSH state at shutdown
|
||||
|
||||
**File:** `src/renderer/src/lib/workspace-session.ts`
|
||||
|
||||
The `WorkspaceSessionSnapshot` type gains two new fields from AppState, and `buildWorkspaceSessionPayload()` populates the new session fields.
|
||||
|
||||
The renderer already has the data it needs — no IPC required. `tabsByWorktree` contains each tab's `ptyId`, and the renderer knows which worktrees are SSH-backed via `repo.connectionId`. Building the map renderer-side avoids a synchronous IPC call during `beforeunload` (which is fragile: sync IPC blocks the main process and can be dropped under time pressure on quit).
|
||||
|
||||
**File:** `src/renderer/src/lib/workspace-session.ts`
|
||||
|
||||
Add `sshConnectionStates` and `repos` / `worktreesByRepo` to the `WorkspaceSessionSnapshot` Pick type, then build both new fields in `buildWorkspaceSessionPayload()`:
|
||||
|
||||
```typescript
|
||||
// Capture active SSH connections.
|
||||
// Why: sshConnectionStates is a Map<string, SshConnectionState>, not a plain
|
||||
// object. Object.entries() on a Map returns [] — must use Array.from().
|
||||
const connectedTargetIds = Array.from(snapshot.sshConnectionStates.entries())
|
||||
.filter(([, state]) => state.status === 'connected')
|
||||
.map(([targetId]) => targetId)
|
||||
|
||||
// Build remote PTY session IDs from renderer state.
|
||||
// Why: the renderer already has tab.ptyId for every terminal tab and knows
|
||||
// which worktrees are SSH-backed via repo.connectionId. Deriving the map
|
||||
// here avoids a sync IPC round-trip during beforeunload, which is fragile
|
||||
// (can be dropped by Chromium under shutdown time pressure).
|
||||
const remoteSessionIdsByTabId: Record<string, string> = {}
|
||||
for (const [worktreeId, tabs] of Object.entries(snapshot.tabsByWorktree)) {
|
||||
const worktree = Object.values(snapshot.worktreesByRepo)
|
||||
.flat()
|
||||
.find((w) => w.id === worktreeId)
|
||||
const repo = worktree
|
||||
? snapshot.repos.find((r) => r.id === worktree.repoId)
|
||||
: null
|
||||
if (!repo?.connectionId) continue // not SSH-backed
|
||||
for (const tab of tabs) {
|
||||
if (tab.ptyId) {
|
||||
remoteSessionIdsByTabId[tab.id] = tab.ptyId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// ... existing fields ...
|
||||
activeConnectionIdsAtShutdown: connectedTargetIds.length > 0
|
||||
? connectedTargetIds : undefined,
|
||||
remoteSessionIdsByTabId: Object.keys(remoteSessionIdsByTabId).length > 0
|
||||
? remoteSessionIdsByTabId : undefined,
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Auto-reconnect SSH on startup
|
||||
|
||||
**File:** `src/renderer/src/App.tsx`
|
||||
|
||||
Insert an SSH reconnect pass between workspace hydration and `reconnectPersistedTerminals()`. Targets are split into two categories:
|
||||
|
||||
1. **Non-passphrase targets** — reconnected eagerly at startup in parallel.
|
||||
2. **Passphrase-protected targets** — deferred until the user focuses an SSH-backed terminal tab.
|
||||
|
||||
**Why defer passphrase targets:** Eagerly reconnecting passphrase-protected keys would pop credential dialogs before the user has context about which terminal is reconnecting. Stacking multiple passphrase prompts at startup is disorienting and error-prone (wrong passphrase entered for the wrong key). Deferring to tab focus lets the user see the terminal they're about to reconnect and provide the credential with full context.
|
||||
|
||||
```typescript
|
||||
// After repos/worktrees hydrated, before terminal reconnect
|
||||
const connectionIds = session.activeConnectionIdsAtShutdown ?? []
|
||||
const SSH_RECONNECT_TIMEOUT_MS = 15_000
|
||||
|
||||
if (connectionIds.length > 0) {
|
||||
// Partition targets: eagerly reconnect non-passphrase targets,
|
||||
// defer passphrase-protected targets to tab focus.
|
||||
// Why: use listTargets (which already has an IPC handler) and index
|
||||
// by ID, rather than adding a per-target IPC call. Fetching all
|
||||
// targets in one call is simpler and avoids adding a new ssh:getTarget
|
||||
// handler that would only be used here.
|
||||
const allTargets = await window.api.ssh.listTargets()
|
||||
const targetMap = new Map(allTargets.map((t) => [t.id, t]))
|
||||
const targets = connectionIds.map((targetId) => {
|
||||
const target = targetMap.get(targetId)
|
||||
return { targetId, needsPassphrase: target?.lastRequiredPassphrase ?? false }
|
||||
})
|
||||
|
||||
const eagerTargets = targets.filter((t) => !t.needsPassphrase)
|
||||
const deferredTargets = targets.filter((t) => t.needsPassphrase)
|
||||
|
||||
// Store deferred targets so on-demand reconnect can pick them up
|
||||
if (deferredTargets.length > 0) {
|
||||
actions.setDeferredSshReconnectTargets(
|
||||
deferredTargets.map((t) => t.targetId)
|
||||
)
|
||||
}
|
||||
|
||||
// Reconnect eager targets in parallel with per-target timeout.
|
||||
// Why: a per-target timeout prevents a single unreachable host from
|
||||
// blocking the entire startup sequence. 15 seconds is long enough for
|
||||
// a typical SSH handshake + relay redeploy over broadband, short enough
|
||||
// to avoid the user staring at a frozen app.
|
||||
await Promise.allSettled(
|
||||
eagerTargets.map(({ targetId }) =>
|
||||
Promise.race([
|
||||
window.api.ssh.connect({ targetId }),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('SSH reconnect timeout')),
|
||||
SSH_RECONNECT_TIMEOUT_MS)
|
||||
)
|
||||
]).catch((err) => {
|
||||
console.warn(`SSH auto-reconnect failed for ${targetId}:`, err)
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Now proceed with terminal reconnect (which can now include SSH tabs)
|
||||
await actions.reconnectPersistedTerminals(abortController.signal)
|
||||
```
|
||||
|
||||
**On-demand reconnect for deferred (passphrase-protected) targets:**
|
||||
|
||||
When the user focuses a terminal tab backed by a deferred SSH target, the tab's mount logic checks `deferredSshReconnectTargets`. If the tab's `connectionId` is in that set, it triggers `ssh:connect` for that target, shows the passphrase dialog naturally, and on success proceeds with `pty.attach` for that tab's persisted session ID. The target is removed from the deferred set once connected (or after the user cancels).
|
||||
|
||||
```typescript
|
||||
// In terminal pane mount / tab focus handler
|
||||
const deferredTargets = get().deferredSshReconnectTargets ?? []
|
||||
if (repo?.connectionId && deferredTargets.includes(repo.connectionId)) {
|
||||
// Show reconnecting overlay on this tab (see Section 6)
|
||||
try {
|
||||
await window.api.ssh.connect({ targetId: repo.connectionId })
|
||||
actions.removeDeferredSshReconnectTarget(repo.connectionId)
|
||||
// Proceed to pty.attach with the persisted sessionId
|
||||
} catch (err) {
|
||||
// Leave tab in disconnected state with retry affordance
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `ssh:connect` IPC handler already exists and handles the full flow: SSH connect → relay deploy → provider registration. No new main-process code needed for the connect path.
|
||||
|
||||
**Error handling:** Each connection attempt is independent. Failed connections show the existing `SshDisconnectedDialog` with manual retry. Successful connections proceed to PTY reattach.
|
||||
|
||||
**Credential prompts:** For eager (non-passphrase) targets, the reconnect completes without user interaction. For deferred targets, the existing `SshPassphraseDialog` flow fires at tab focus — `ssh:credential-request` IPC is already wired.
|
||||
|
||||
### 4. Enable remote PTY reattach
|
||||
|
||||
**File:** `src/renderer/src/store/slices/terminals.ts`
|
||||
|
||||
The current gate at line 1205:
|
||||
|
||||
```typescript
|
||||
const supportsDeferredReattach = !repo?.connectionId
|
||||
```
|
||||
|
||||
Changes to:
|
||||
|
||||
```typescript
|
||||
const supportsDeferredReattach = !repo?.connectionId || hasActiveConnection(repo.connectionId)
|
||||
```
|
||||
|
||||
Where `hasActiveConnection` checks the renderer's `sshConnectionStates` store for `status === 'connected'`.
|
||||
|
||||
For SSH-backed tabs, the reattach data comes from the new `remoteSessionIdsByTabId` session field instead of `pendingReconnectPtyIdByTabId` (which is populated from the local daemon). The hydration step in `hydrateWorkspaceSession` needs to merge remote session IDs into `pendingReconnectPtyIdByTabId`.
|
||||
|
||||
**Critical placement constraint:** The existing `pendingReconnectPtyIdByTabId` population loop (lines 1090-1108) is guarded by `if (daemonEnabled)` and explicitly skips SSH-backed repos (`if (repo?.connectionId) { continue }`). The remote session ID merge must run **outside and after** that guard block — SSH PTY reattach does not depend on the local terminal daemon; it uses the relay's `pty.attach` RPC. If the merge is placed inside the `daemonEnabled` block, users without the experimental daemon flag will never have remote session IDs populated, silently breaking the entire reattach feature.
|
||||
|
||||
**File:** `src/renderer/src/store/slices/terminals.ts` — In `hydrateWorkspaceSession`, after the existing `if (daemonEnabled) { ... }` block (line 1108):
|
||||
|
||||
```typescript
|
||||
// Existing code (unchanged):
|
||||
const daemonEnabled = s.settings?.experimentalTerminalDaemon === true
|
||||
const pendingReconnectPtyIdByTabId: Record<string, string> = {}
|
||||
if (daemonEnabled) {
|
||||
// ... existing loop for local daemon session IDs (skips SSH repos) ...
|
||||
}
|
||||
|
||||
// NEW: merge remote PTY session IDs from SSH persistence.
|
||||
// Why: this runs outside the daemonEnabled guard because remote PTY reattach
|
||||
// uses the relay's pty.attach RPC, not the local terminal daemon. SSH-backed
|
||||
// tabs need their session IDs regardless of the experimentalTerminalDaemon
|
||||
// setting. The existing loop above correctly skips SSH repos (connectionId
|
||||
// check), so there is no overlap — local daemon IDs and remote session IDs
|
||||
// are mutually exclusive per-tab.
|
||||
const remoteSessionIds = session.remoteSessionIdsByTabId ?? {}
|
||||
for (const [tabId, sessionId] of Object.entries(remoteSessionIds)) {
|
||||
if (validTabIds.has(tabId)) {
|
||||
pendingReconnectPtyIdByTabId[tabId] = sessionId
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The inner guard in `reconnectPersistedTerminals` (`if (supportsDeferredReattach && tabLevelPtyId)`) works correctly for SSH tabs because `tabLevelPtyId` is now populated by this merge, and `supportsDeferredReattach` is `true` for connected SSH targets.
|
||||
|
||||
**File:** `src/main/providers/ssh-pty-provider.ts` — Modify `spawn()` to detect sessionId:
|
||||
|
||||
The current `SshPtyProvider.spawn()` unconditionally calls `pty.spawn` and ignores `opts.sessionId`. This is the critical missing link — without this change, passing a sessionId through `connectPanePty` has no effect on the remote relay.
|
||||
|
||||
```typescript
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
// Why: when sessionId is present, the caller is requesting reattach to an
|
||||
// existing relay PTY (persisted across app restart). Calling pty.spawn would
|
||||
// create a new shell and discard the buffered output the relay kept alive
|
||||
// during the grace window. pty.attach replays that buffer instead.
|
||||
if (opts.sessionId) {
|
||||
try {
|
||||
await this.mux.request('pty.attach', {
|
||||
id: opts.sessionId,
|
||||
cols: opts.cols,
|
||||
rows: opts.rows
|
||||
})
|
||||
return { id: opts.sessionId }
|
||||
} catch (err) {
|
||||
// Why: pty.attach fails when the relay grace window has elapsed and the
|
||||
// PTY no longer exists (relay returns "not found"). Without this catch,
|
||||
// the error propagates as an unhandled rejection and the terminal tab
|
||||
// stays stuck. Falling through to pty.spawn gives the user a fresh
|
||||
// shell, and sessionExpired lets the renderer show a brief toast
|
||||
// ("Session expired — new shell started") so the user understands why
|
||||
// their scrollback is gone.
|
||||
console.warn(`[ssh-pty] pty.attach failed for ${opts.sessionId}, falling back to fresh spawn:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.mux.request('pty.spawn', {
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cwd: opts.cwd,
|
||||
env: opts.env
|
||||
})
|
||||
// Why: sessionExpired is only set when we attempted reattach and it failed.
|
||||
// The renderer checks this flag to show the "Session expired" toast. When
|
||||
// sessionId was never provided (fresh terminal), this field is omitted.
|
||||
return {
|
||||
...(result as PtySpawnResult),
|
||||
...(opts.sessionId ? { sessionExpired: true } : {})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `PtySpawnResult` type (in `src/main/providers/types.ts`) needs a new optional field:
|
||||
|
||||
```typescript
|
||||
export type PtySpawnResult = {
|
||||
id: string
|
||||
/** True when the caller requested reattach (sessionId was provided) but the
|
||||
* relay PTY was gone (grace window elapsed). The renderer uses this to show
|
||||
* a brief "Session expired — new shell started" toast. */
|
||||
sessionExpired?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
This routes through to the relay's existing `pty.attach` RPC, which replays buffered output via the `pty.replay` notification.
|
||||
|
||||
**File:** `src/main/ipc/ssh-relay-helpers.ts` — Wire `onReplay` in `wireUpSshPtyEvents()`:
|
||||
|
||||
The current `wireUpSshPtyEvents()` only wires `onData` and `onExit`. It does **not** wire `onReplay`. After `pty.attach`, the relay sends a `pty.replay` notification with buffered output, but without this wiring the replay data is silently dropped and the terminal renders blank after reattach.
|
||||
|
||||
Why use a dedicated `pty:replay` IPC channel (not `pty:data`): the renderer's `pty-transport.ts` has a `replayingBufferedData` flag that suppresses xterm auto-replies to embedded terminal query sequences (e.g., `\x1b[6n` for cursor position reports) during replay. If relay replay data arrives via the regular `pty:data` channel, this flag is **not set** — xterm processes the data as normal output and auto-replies leak into the remote shell as stray input, potentially executing unintended commands. A dedicated `pty:replay` channel lets the renderer route replay through the guarded `onReplayData` callback, which suppresses auto-replies.
|
||||
|
||||
```typescript
|
||||
export function wireUpSshPtyEvents(
|
||||
ptyProvider: SshPtyProvider,
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
ptyProvider.onData((payload) => {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:data', payload)
|
||||
}
|
||||
})
|
||||
// Why: after pty.attach, the relay sends pty.replay with buffered output
|
||||
// from the grace window. Without this, reattached terminals render blank.
|
||||
// Uses a dedicated pty:replay channel (not pty:data) so the renderer can
|
||||
// route it through the replay-guarded onReplayData callback, which
|
||||
// suppresses xterm auto-replies to embedded query sequences.
|
||||
ptyProvider.onReplay((payload) => {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:replay', payload)
|
||||
}
|
||||
})
|
||||
ptyProvider.onExit((payload) => {
|
||||
clearProviderPtyState(payload.id)
|
||||
deletePtyOwnership(payload.id)
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('pty:exit', payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
The renderer needs a corresponding `pty:replay` handler in `pty-dispatcher.ts` that routes through the existing `onReplayData` callback (same path used by the local daemon's eager buffer replay). This ensures `replayingBufferedData` is set and xterm auto-replies are suppressed during relay replay.
|
||||
|
||||
The preload bridge (`src/preload/index.ts`) needs a new `onReplay` binding alongside the existing `onData`:
|
||||
|
||||
```typescript
|
||||
pty: {
|
||||
// ... existing bindings ...
|
||||
onReplay: (callback: (payload: PtyDataPayload) => void) =>
|
||||
ipcRenderer.on('pty:replay', (_event, payload) => callback(payload)),
|
||||
}
|
||||
```
|
||||
|
||||
Then in `reconnectPersistedTerminals`, when the SSH connection is confirmed active, the existing `pty.spawn` call in `connectPanePty` receives the `sessionId` which routes through `SshPtyProvider.spawn()` → relay `pty.attach()`, replaying buffered output.
|
||||
|
||||
**Wait for SSH before reattach:** The key ordering constraint is that `reconnectPersistedTerminals` must run *after* SSH connections are established. The sequential await in App.tsx (step 3) guarantees this.
|
||||
|
||||
### 5. Configurable relay grace window
|
||||
|
||||
**File:** `src/shared/ssh-types.ts` — Extend `SshTarget`:
|
||||
|
||||
```typescript
|
||||
export type SshTarget = {
|
||||
// ... existing fields ...
|
||||
|
||||
/** Grace period in seconds before relay shuts down after disconnect.
|
||||
* Default: 300 (5 minutes). */
|
||||
relayGracePeriodSeconds?: number
|
||||
|
||||
/** Set to true after a successful connection that triggered a credential
|
||||
* prompt (passphrase or password), false after one that didn't. Persisted
|
||||
* with the target config so the startup reconnect logic can partition
|
||||
* targets into eager (no passphrase) vs deferred (passphrase) without
|
||||
* attempting a connection first.
|
||||
*
|
||||
* Why a persisted flag instead of inspecting the key at startup: detecting
|
||||
* whether a key is encrypted requires attempting to load it, which is
|
||||
* expensive and may itself trigger an OS keychain prompt. Recording the
|
||||
* outcome of the last successful connection is both cheaper and more
|
||||
* accurate (it reflects the user's actual auth experience). */
|
||||
lastRequiredPassphrase?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Population:** The `lastRequiredPassphrase` flag is set during `ssh:connect` success handling in the main process. When `SshConnection.connect()` succeeds, the handler checks whether a credential prompt was issued during the attempt (the `onCredentialRequest` callback was invoked). If yes, the flag is set to `true` on the target and persisted; if no, it is set to `false`. This ensures the flag stays up to date as users add/remove passphrases from their keys.
|
||||
|
||||
**File:** `src/main/ssh/ssh-relay-deploy.ts` — Pass grace time to relay launch:
|
||||
|
||||
```typescript
|
||||
const graceSeconds = target.relayGracePeriodSeconds ?? 300
|
||||
const command = `${relayPath} --grace-time ${graceSeconds}`
|
||||
```
|
||||
|
||||
**File:** `src/renderer/src/components/settings/SshTargetForm.tsx` — Add UI field with reasonable bounds (60s–3600s).
|
||||
|
||||
The relay already accepts `--grace-time` CLI arg (`src/relay/relay.ts:17-29`).
|
||||
|
||||
## Sequence Diagram
|
||||
|
||||
### Shutdown
|
||||
|
||||
```
|
||||
Renderer Main Process
|
||||
│ │
|
||||
├─ beforeunload fires │
|
||||
├─ capture terminal buffers │
|
||||
├─ build session payload │
|
||||
│ (derives remoteSessionIdsByTabId from
|
||||
│ Zustand state: tabsByWorktree + repo.connectionId;
|
||||
│ derives activeConnectionIdsAtShutdown from
|
||||
│ sshConnectionStates — no IPC needed)
|
||||
├─ session:set-sync ────────────►├─ write to orca-data.json
|
||||
│ ├─ flush()
|
||||
└─ window closes └─ SSH connections drop
|
||||
relay grace timer starts
|
||||
```
|
||||
|
||||
### Startup (eager targets — no passphrase)
|
||||
|
||||
```
|
||||
Renderer Main Process Remote Relay
|
||||
│ │ │
|
||||
├─ session:get ─────────────────►├─ read orca-data.json │
|
||||
│◄─ session ────────────────────┤ │
|
||||
├─ hydrate repos, tabs, layouts │ │
|
||||
├─ partition targets: │ │
|
||||
│ eager vs deferred │ │
|
||||
│ │ │
|
||||
├─ ssh:connect(eager1) ─────────►├─ SSH handshake ────────►│
|
||||
│ (15s timeout per target) ├─ deploy relay ─────────►├─ READY
|
||||
│ ├─ register roots │
|
||||
│◄─ connected ──────────────────┤ │
|
||||
│ │ │
|
||||
├─ reconnectPersistedTerminals() │ │
|
||||
│ (SSH tabs no longer skipped │ │
|
||||
│ for connected targets) │ │
|
||||
│ │ │
|
||||
├─ connectPanePty(sessionId) ───►├─ SshPtyProvider.spawn() │
|
||||
│ │ detects sessionId │
|
||||
│ ├─ pty.attach(id) ──────►├─ replay buffer
|
||||
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
|
||||
│ terminal shows restored │ │
|
||||
│ scrollback + live shell │ │
|
||||
```
|
||||
|
||||
### Startup (deferred targets — passphrase-protected)
|
||||
|
||||
```
|
||||
Renderer Main Process Remote Relay
|
||||
│ │ │
|
||||
│ (passphrase targets stored │ │
|
||||
│ in deferredSshReconnectTargets) │
|
||||
│ │ │
|
||||
├─ user focuses SSH tab ─────────│ │
|
||||
│ tab shows "Reconnecting..." │ │
|
||||
│ overlay │ │
|
||||
├─ ssh:connect(deferred1) ──────►├─ SSH handshake │
|
||||
│ ├─ ssh:credential-request │
|
||||
│◄─ passphrase dialog ──────────┤ │
|
||||
├─ user enters passphrase ──────►├─ auth continues ───────►│
|
||||
│ ├─ deploy relay ─────────►├─ READY
|
||||
│◄─ connected ──────────────────┤ │
|
||||
│ │ │
|
||||
├─ connectPanePty(sessionId) ───►├─ pty.attach(id) ──────►├─ replay buffer
|
||||
│◄─ pty.replay ─────────────────┤◄─ pty.replay ──────────┤
|
||||
│ overlay removed, terminal │ │
|
||||
│ shows restored scrollback │ │
|
||||
```
|
||||
|
||||
## Per-Tab Reconnect Status
|
||||
|
||||
During SSH reconnection and PTY reattach, individual terminal tabs need visual feedback so the user knows what's happening and which tabs are still in progress.
|
||||
|
||||
**Reconnecting state:** While an SSH target is being reconnected (either eagerly at startup or on-demand at tab focus), each affected terminal tab shows a semi-transparent overlay with a spinner and the text "Reconnecting to [host]...". The terminal content beneath is dimmed but visible — the user can see their previous scrollback through the overlay.
|
||||
|
||||
**Reattaching state:** After SSH connects but before `pty.attach` completes and replays the buffer, the overlay updates to "Reattaching session...". This is typically brief (<1 second) but visible on high-latency connections.
|
||||
|
||||
**Success:** The overlay is removed and the terminal receives the replayed buffer. No toast or notification — the terminal being live is sufficient signal.
|
||||
|
||||
**Failure — target failed:** If the SSH connection fails (timeout, network error, cancelled passphrase), the overlay is replaced with a persistent banner: "SSH connection failed — [reason]" with a "Retry" button. The terminal content remains visible but dimmed and non-interactive.
|
||||
|
||||
**Failure — session expired:** If SSH connects but `pty.attach` returns `not-found` (grace window elapsed), the tab shows a brief inline message "Session expired — new shell started" and spawns a fresh PTY. The message auto-dismisses after 5 seconds.
|
||||
|
||||
## Partial Failure Behavior
|
||||
|
||||
When multiple SSH targets are reconnected at startup, some may succeed and others may fail. The user needs clear per-tab attribution of which connections failed:
|
||||
|
||||
- **Tabs backed by a successful target:** Proceed to PTY reattach normally. No special indicator — they work as expected.
|
||||
- **Tabs backed by a failed target:** Show the persistent failure banner described above. The banner includes the target hostname so the user can identify which remote host is unreachable.
|
||||
- **Tabs backed by a deferred (passphrase) target:** Show a static overlay: "Waiting for connection — focus this tab to reconnect". This distinguishes intentionally-deferred tabs from failed tabs.
|
||||
|
||||
This ensures the user can scan their tab bar and immediately tell which terminals are live, which need attention, and which are waiting for credentials.
|
||||
|
||||
## Data Flow Diagrams
|
||||
|
||||
### Path 1: Happy path (restart within grace window)
|
||||
|
||||
```
|
||||
SHUTDOWN:
|
||||
Zustand state ──► buildWorkspaceSessionPayload() ──► orca-data.json
|
||||
(tabsByWorktree, (derives remoteSessionIdsByTabId, (persisted)
|
||||
sshConnectionStates, activeConnectionIdsAtShutdown)
|
||||
repos, worktreesByRepo)
|
||||
|
||||
STARTUP:
|
||||
orca-data.json ──► hydrateWorkspaceSession() ──► ssh:connect(eager) ──► reconnectPersistedTerminals()
|
||||
(read) (merges remoteSessionIds (SSH + relay up) (pty.attach via SshPtyProvider)
|
||||
into pendingReconnectPtyIdByTabId) ──► pty.replay ──► terminal live
|
||||
```
|
||||
|
||||
### Path 2: Nil path (no SSH state persisted)
|
||||
|
||||
```
|
||||
SHUTDOWN:
|
||||
No SSH connections active ──► buildWorkspaceSessionPayload() ──► orca-data.json
|
||||
(activeConnectionIdsAtShutdown (no SSH fields)
|
||||
omitted, remoteSessionIdsByTabId
|
||||
omitted)
|
||||
|
||||
STARTUP:
|
||||
orca-data.json ──► hydrateWorkspaceSession() ──► reconnectPersistedTerminals()
|
||||
(no SSH fields) (no remote IDs to merge) (SSH tabs still skipped — connectionId
|
||||
but no active connection)
|
||||
```
|
||||
|
||||
### Path 3: Grace expired (restart after relay grace window)
|
||||
|
||||
```
|
||||
SHUTDOWN:
|
||||
(same as happy path — SSH state persisted to orca-data.json)
|
||||
|
||||
STARTUP:
|
||||
orca-data.json ──► ssh:connect() ──► relay redeploy ──► pty.attach(sessionId)
|
||||
(SSH succeeds) (new relay instance; ──► NOT FOUND
|
||||
old PTYs gone) ──► spawn fresh PTY
|
||||
──► "Session expired" message
|
||||
```
|
||||
|
||||
### Path 4: Error path (SSH connect fails)
|
||||
|
||||
```
|
||||
SHUTDOWN:
|
||||
(same as happy path — SSH state persisted to orca-data.json)
|
||||
|
||||
STARTUP:
|
||||
orca-data.json ──► ssh:connect() ──► TIMEOUT/ERROR (15s)
|
||||
──► tab shows failure banner
|
||||
──► reconnectPersistedTerminals()
|
||||
skips tabs for failed targets
|
||||
(no active connection)
|
||||
──► user retries manually via banner
|
||||
```
|
||||
|
||||
## Migration & Backwards Compatibility
|
||||
|
||||
- New session fields are optional (`z.optional()`), so older sessions parse without error
|
||||
- Older Orca versions ignore unknown fields, so a downgrade doesn't break
|
||||
- If `activeConnectionIdsAtShutdown` references a deleted SSH target, the connect call fails gracefully and the tab shows as disconnected
|
||||
- If `remoteSessionIdsByTabId` references a PTY that expired (grace window elapsed), `pty.attach` returns `not-found` and the tab spawns a fresh shell
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| Relay grace expired before restart | `pty.attach` fails → tab spawns fresh PTY, shows "Session expired — new shell started" |
|
||||
| SSH target deleted between shutdown and startup | `ssh:connect` fails → tabs for that target remain disconnected, user sees reconnect dialog |
|
||||
| Network unreachable on startup | `ssh:connect` times out → existing retry/backoff kicks in, shows reconnecting status |
|
||||
| Passphrase-protected key | Target deferred to on-demand reconnect at tab focus; passphrase dialog fires in context of the specific terminal |
|
||||
| Multiple SSH targets | Non-passphrase targets reconnect eagerly in parallel via `Promise.allSettled`; passphrase targets deferred; partial success is fine |
|
||||
| Per-target timeout exceeded | Target treated as failed after 15s; tabs show disconnected state with manual retry affordance |
|
||||
| Crash (no beforeunload) | No session captured → same as today (no regression). Periodic save (future improvement) could mitigate |
|
||||
| App update with relay version bump | Relay redeploy triggers naturally; old PTYs lost (acceptable — version change implies incompatibility) |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `workspace-session.test.ts`: Verify `buildWorkspaceSessionPayload` includes SSH fields when connections are active and omits them when none are
|
||||
- `workspace-session-schema.test.ts`: Verify schema parses sessions with/without new optional fields
|
||||
- `terminals.test.ts`: Verify `reconnectPersistedTerminals` processes SSH-backed tabs when connection is active and skips when not
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- `store-session-cascades.test.ts`: Extend existing local daemon reattach test suite (lines 660-960) to cover SSH-backed tabs
|
||||
- `terminal-restart-persistence.spec.ts`: Add E2E scenario for SSH terminal restore
|
||||
|
||||
### Manual Test Plan
|
||||
|
||||
1. Connect to SSH target with 2+ terminal tabs running long processes
|
||||
2. Quit Orca
|
||||
3. Relaunch within grace window → verify SSH reconnects, terminals show scrollback, processes still running
|
||||
4. Quit Orca, wait past grace window, relaunch → verify SSH reconnects, terminals show "session expired" and spawn fresh shells
|
||||
5. Quit Orca with passphrase-protected key → verify tabs show "Waiting for connection" overlay, focus a tab → verify passphrase dialog, enter passphrase → verify terminal restores
|
||||
6. Kill Orca (force quit) → verify no crash on next launch (no session saved, clean fallback)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/shared/types.ts` | Add `activeConnectionIdsAtShutdown`, `remoteSessionIdsByTabId` to `WorkspaceSessionState` |
|
||||
| `src/shared/ssh-types.ts` | Add `relayGracePeriodSeconds` and `lastRequiredPassphrase` to `SshTarget` |
|
||||
| `src/shared/workspace-session-schema.ts` | Add Zod schemas for new fields |
|
||||
| `src/main/providers/ssh-pty-provider.ts` | Modify `spawn()` to detect `sessionId`, call `pty.attach` with try/catch fallback to `pty.spawn` |
|
||||
| `src/main/providers/types.ts` | Add `sessionExpired?: boolean` to `PtySpawnResult` |
|
||||
| `src/main/ipc/ssh-relay-helpers.ts` | Wire `onReplay` in `wireUpSshPtyEvents()` to forward replay data via dedicated `pty:replay` IPC channel |
|
||||
| `src/preload/index.ts` | Add `pty.onReplay` binding for the new `pty:replay` IPC channel |
|
||||
| `src/renderer/src/lib/pty-dispatcher.ts` | Add `pty:replay` handler that routes through replay-guarded `onReplayData` callback |
|
||||
| `src/main/ipc/ssh.ts` | Set `lastRequiredPassphrase` on target after successful `ssh:connect` based on whether credential prompt was triggered |
|
||||
| `src/renderer/src/lib/workspace-session.ts` | Add `sshConnectionStates`, `repos`, `worktreesByRepo` to snapshot; build `remoteSessionIdsByTabId` from renderer state |
|
||||
| `src/renderer/src/App.tsx` | Add SSH reconnect pass (eager + deferred) before `reconnectPersistedTerminals()` |
|
||||
| `src/renderer/src/store/slices/terminals.ts` | Remove SSH skip gate; merge remote session IDs into pending reconnect map; add `deferredSshReconnectTargets` state + actions |
|
||||
| `src/main/ssh/ssh-relay-deploy.ts` | Pass configurable grace time to relay |
|
||||
| `src/renderer/src/components/settings/SshTargetForm.tsx` | Add grace period setting UI |
|
||||
@@ -1,110 +0,0 @@
|
||||
# Design: Persist Task Source Preference (GitHub / Linear)
|
||||
|
||||
## Problem
|
||||
|
||||
The Tasks button in the sidebar always defaults to GitHub when clicked without an explicit source. If a user switches to Linear inside the Task page, that choice is lost when they navigate away and return. Users who primarily use Linear must switch every time.
|
||||
|
||||
## Goal
|
||||
|
||||
Remember which task source (GitHub or Linear) the user last selected and default to it the next time they open the Tasks page without an explicit source override.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
1. **SidebarNav.tsx** — The main Tasks button calls `openTaskPage()` with no arguments. The small GitHub/Linear icons call `openTaskPage({ taskSource: 'github' })` or `openTaskPage({ taskSource: 'linear' })` respectively.
|
||||
|
||||
2. **ui.ts (store)** — `openTaskPage(data = {})` stores `data` into `taskPageData`. No `taskSource` field is persisted.
|
||||
|
||||
3. **TaskPage.tsx (line 597)** — Initializes source with `useState<TaskSource>(pageData.taskSource ?? 'github')`. The hardcoded `'github'` fallback is the root cause.
|
||||
|
||||
4. **TaskPage.tsx (line 1259)** — In-page source toggle calls `setTaskSource(source.id)` — local state only, never persisted.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Piggyback on the existing `GlobalSettings` persistence pattern already used by `defaultTaskViewPreset` and `defaultRepoSelection`.
|
||||
|
||||
### Changes
|
||||
|
||||
#### 1. `src/shared/types.ts` — Add setting field
|
||||
|
||||
Add `defaultTaskSource: 'github' | 'linear'` to the `GlobalSettings` type, alongside the existing `defaultTaskViewPreset` field (~line 818). Use the inline union (matching the existing `taskPageData` pattern in `ui.ts`) rather than importing the file-local `TaskSource` alias from TaskPage.
|
||||
|
||||
#### 1b. `src/shared/constants.ts` — Add default value
|
||||
|
||||
Add `defaultTaskSource: 'github'` to the `GlobalSettings` defaults object (adjacent to `defaultTaskViewPreset: 'all'` ~line 158).
|
||||
|
||||
#### 2. `src/renderer/src/components/TaskPage.tsx` — Read and write the preference
|
||||
|
||||
**Read (line 597):** Change the fallback from the hardcoded `'github'` to the persisted setting:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? 'github')
|
||||
|
||||
// After
|
||||
const defaultTaskSource = settings?.defaultTaskSource ?? 'github'
|
||||
const [taskSource, setTaskSource] = useState<TaskSource>(pageData.taskSource ?? defaultTaskSource)
|
||||
```
|
||||
|
||||
**Sync effect (after line 606):** `settings` may be `null` on first render (async hydration). Add a sync effect so the persisted preference applies once settings arrive, mirroring the existing `pageData.taskSource` sync pattern on lines 602-606:
|
||||
|
||||
```ts
|
||||
// Why: settings load asynchronously — the useState initializer may
|
||||
// capture null settings on fast navigation. Sync once settings arrive,
|
||||
// but only when no explicit source was passed via sidebar icon click.
|
||||
useEffect(() => {
|
||||
if (!pageData.taskSource && settings?.defaultTaskSource) {
|
||||
setTaskSource(settings.defaultTaskSource)
|
||||
}
|
||||
}, [settings?.defaultTaskSource, pageData.taskSource])
|
||||
```
|
||||
|
||||
**Write (line 1259):** When the user toggles the source inside the page, persist the choice:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
onClick={() => setTaskSource(source.id)}
|
||||
|
||||
// After
|
||||
onClick={() => {
|
||||
setTaskSource(source.id)
|
||||
void updateSettings({ defaultTaskSource: source.id }).catch(() => {
|
||||
toast.error('Failed to save default task source.')
|
||||
})
|
||||
}}
|
||||
```
|
||||
|
||||
This mirrors the exact pattern used by `handleSetDefaultTaskPreset` (line 859-868).
|
||||
|
||||
#### 3. Settings initialization / migration
|
||||
|
||||
Wherever `GlobalSettings` defaults are constructed (the main process settings loader), add `defaultTaskSource: 'github'` so existing users get the current behavior with no migration needed. The field is optional in the type — missing means `'github'`.
|
||||
|
||||
### No changes needed
|
||||
|
||||
- **SidebarNav.tsx** — No changes. The main Tasks button continues to call `openTaskPage()` with no `taskSource`. The explicit GitHub/Linear icons continue to pass their source. TaskPage handles the default resolution.
|
||||
- **ui.ts store** — No changes. `taskPageData` remains ephemeral. The preference is a setting, not UI state.
|
||||
- **Prefetch logic** — The prefetch in `openTaskPage` and `SidebarNav` only warms GitHub work items. This is fine — Linear fetches are fast and don't benefit from the same prefetch pattern. Optionally, we could skip the GitHub prefetch when the saved source is Linear, but that's a separate optimization.
|
||||
|
||||
## Behavior Matrix
|
||||
|
||||
| Action | Result |
|
||||
|---|---|
|
||||
| Click main Tasks button (no saved pref) | Opens GitHub (backward-compatible default) |
|
||||
| Click main Tasks button (saved pref = linear) | Opens Linear |
|
||||
| Click GitHub icon in sidebar | Opens GitHub (does not change saved preference) |
|
||||
| Click Linear icon in sidebar | Opens Linear (does not change saved preference) |
|
||||
| Toggle source inside TaskPage | Switches view, saves new preference |
|
||||
| Fresh install / no setting | Defaults to `'github'` |
|
||||
|
||||
## Scope
|
||||
|
||||
- ~20 lines of code across 3 files (`types.ts`, `constants.ts`, `TaskPage.tsx`)
|
||||
- No new IPC channels
|
||||
- No migrations
|
||||
- No UI changes — only behavior change is remembering the last choice
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **Should clicking the sidebar GitHub/Linear icons also persist the preference?** No — sidebar icons are pure navigation ("go to GitHub tasks" / "go to Linear tasks"). Persisting from a one-off exploratory click would surprise users. Only the in-page toggle persists, keeping the mental model clean: sidebar icons = navigate, in-page toggle = set preference.
|
||||
|
||||
2. **Should we skip GitHub prefetch when saved source is Linear?** Deferred to a follow-up. The prefetch is cheap and doesn't hurt.
|
||||
@@ -1,192 +0,0 @@
|
||||
# Design: Share Text Search Logic Between Local Main and SSH Relay
|
||||
|
||||
**Branch:** `fix-ssh-keywords-search`
|
||||
**Status:** Draft
|
||||
|
||||
## Problem
|
||||
|
||||
The right-sidebar keywords search (`fs:search`) and the Cmd+P quick-open file search historically shared no code between the local main process and the SSH relay. Each side reinvented: rg argument construction, rg `--json` stdout parsing, the git-grep fallback, the submatch regex, the `SearchFileResult` accumulator, and the "kill previous search on new query" logic.
|
||||
|
||||
This drift already caused one user-visible bug: the relay's `searchWithRg` in `src/relay/fs-handler-utils.ts:139` uses `execFile('rg', ..., { maxBuffer: 50 * 1024 * 1024 })`. `execFile` buffers stdout internally and kills the child when `maxBuffer` is exceeded, even when `data` listeners are attached. Under rg's `--json` output (one verbose JSON object per match), 50MB fills well before the match cap in large folders. The `child.once('error', () => resolveOnce())` then silently resolves with whatever was accumulated — users see "some files can't be found" with no error.
|
||||
|
||||
The local handler at `src/main/ipc/filesystem.ts:402` uses `wslAwareSpawn` (plain `spawn`) and has never had this bug. The two paths must not be allowed to drift again.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Extract rg + git-grep search logic into `src/shared/text-search.ts`, matching the pattern already established by `src/shared/quick-open-filter.ts` for listFiles.
|
||||
- Remove the `execFile`/`maxBuffer` footgun from the relay path.
|
||||
- Unify the accumulator, truncation semantics, and submatch regex construction.
|
||||
|
||||
**Out of scope:**
|
||||
- Changing the `fs.search` request shape or existing `SearchResult`/`SearchOptions` fields.
|
||||
- Adding new search features (multiline, semantic, etc).
|
||||
- Changing how quick-open lists files (already shared via `quick-open-filter.ts`).
|
||||
- Re-homing WSL path translation — that stays in the local main process; the relay never sees WSL paths.
|
||||
|
||||
## Existing Code Map
|
||||
|
||||
| Concern | Local (main) | Remote (relay) |
|
||||
|---|---|---|
|
||||
| rg `--json` run + parse | `src/main/ipc/filesystem.ts:286-441` (inline in IPC handler) | `src/relay/fs-handler-utils.ts:78-231` (`searchWithRg`) |
|
||||
| git-grep fallback | `src/main/ipc/filesystem-search-git.ts:43-220` (`searchWithGitGrep`) | `src/relay/fs-handler-git-fallback.ts:140-297` (`searchWithGitGrep`) |
|
||||
| rg availability check | `src/main/ipc/rg-availability.ts` (`checkRgAvailable`) | `src/relay/fs-handler-utils.ts:241-260` (`checkRgAvailable`) |
|
||||
| rg arg construction | inline in filesystem.ts | inline in fs-handler-utils.ts |
|
||||
| git-grep arg construction | inline in filesystem-search-git.ts | inline in fs-handler-git-fallback.ts |
|
||||
| Submatch regex | `filesystem-search-git.ts:115-119` | `fs-handler-git-fallback.ts:200-204` |
|
||||
| Accumulator (fileMap, totalMatches, truncated) | duplicated in all four files | duplicated in all four files |
|
||||
| Relative-path normalization | `normalizeRelativePath` (collapses `\\`/`/`, strips leading slashes) | plain `.replace(/\\/g, '/')` |
|
||||
| git-grep signature | `searchWithGitGrep(rootPath, args, maxResults)` (maxResults positional) | `searchWithGitGrep(rootPath, query, opts)` (maxResults inside opts) |
|
||||
| Process spawn | `wslAwareSpawn` (local) / `gitSpawn` (local) | `execFile` (rg, buggy) / `spawn` (git) |
|
||||
|
||||
Net duplicate: ~400 lines across four files that do nearly the same thing.
|
||||
|
||||
## Design
|
||||
|
||||
### New module: `src/shared/text-search.ts`
|
||||
|
||||
Pure, IO-agnostic helpers. No Electron, no child_process, no fs. Mirrors `quick-open-filter.ts` — the caller owns process execution and transport-specific path quirks.
|
||||
|
||||
```ts
|
||||
// Types (re-exported from shared/types or defined here)
|
||||
export type SearchAccumulator = {
|
||||
fileMap: Map<string, SearchFileResult>
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export function createAccumulator(): SearchAccumulator
|
||||
|
||||
// ── rg ─────────────────────────────────────────────────────────────
|
||||
// Returns the full argv including '--', query, and target. Both callers
|
||||
// pass `rootPath` unchanged as the target — the local side does NOT
|
||||
// translate the target to a WSL-native path. WSL only affects the
|
||||
// invocation (via `wslAwareSpawn`) and the *output* paths rg emits,
|
||||
// which the caller translates back via `transformAbsPath` below.
|
||||
export function buildRgArgs(
|
||||
query: string,
|
||||
target: string,
|
||||
opts: SearchOptions
|
||||
): string[]
|
||||
|
||||
// Ingest one rg --json stdout line. Mutates `acc`. Returns 'continue'
|
||||
// or 'stop' (stop = totalMatches hit maxResults). Takes an optional
|
||||
// path transform so the local caller can apply WSL translation.
|
||||
export function ingestRgJsonLine(
|
||||
line: string,
|
||||
rootPath: string,
|
||||
acc: SearchAccumulator,
|
||||
maxResults: number,
|
||||
transformAbsPath?: (p: string) => string
|
||||
): 'continue' | 'stop'
|
||||
|
||||
// ── git grep ───────────────────────────────────────────────────────
|
||||
// Also owns include/exclude glob → git pathspec translation
|
||||
// (`toGitGlobPathspec`), which today is duplicated inline in both
|
||||
// `filesystem-search-git.ts` and `fs-handler-git-fallback.ts`.
|
||||
export function buildGitGrepArgs(
|
||||
query: string,
|
||||
opts: SearchOptions
|
||||
): string[]
|
||||
|
||||
// Build the submatch regex used to locate column positions within a
|
||||
// matched line (git grep only reports the first hit per line).
|
||||
export function buildSubmatchRegex(
|
||||
query: string,
|
||||
opts: { useRegex?: boolean; wholeWord?: boolean; caseSensitive?: boolean }
|
||||
): RegExp
|
||||
|
||||
export function ingestGitGrepLine(
|
||||
line: string,
|
||||
rootPath: string,
|
||||
submatchRegex: RegExp,
|
||||
acc: SearchAccumulator,
|
||||
maxResults: number
|
||||
): 'continue' | 'stop'
|
||||
|
||||
// ── finalize ───────────────────────────────────────────────────────
|
||||
export function finalize(acc: SearchAccumulator): SearchResult
|
||||
```
|
||||
|
||||
### What stays environment-specific
|
||||
|
||||
| Stays local | Stays in relay |
|
||||
|---|---|
|
||||
| `wslAwareSpawn`, `gitSpawn` | plain `spawn` |
|
||||
| `parseWslPath` / `toWindowsWslPath` transform passed to `ingestRgJsonLine` | no-op transform |
|
||||
| `activeTextSearches` kill-on-new-query map keyed by `sender.id` | single-search-at-a-time per client (already one channel) |
|
||||
| `resolveAuthorizedPath` | `context.validatePathResolved` |
|
||||
| `checkRgAvailable` wrapping `wslAwareSpawn` (accepts a `searchPath` for WSL resolution) | `checkRgAvailable` wrapping plain `execFile` (no WSL) |
|
||||
|
||||
### Call sites after refactor
|
||||
|
||||
**`src/main/ipc/filesystem.ts`** shrinks from ~180 lines of search logic to ~40:
|
||||
```
|
||||
const rgAvailable = await checkRgAvailable(rootPath)
|
||||
if (!rgAvailable) return searchWithGitGrep(rootPath, args, maxResults)
|
||||
|
||||
const acc = createAccumulator()
|
||||
const rgArgs = buildRgArgs(args.query, rootPath, args)
|
||||
const child = wslAwareSpawn('rg', rgArgs, { cwd: rootPath, stdio: ... })
|
||||
activeTextSearches.get(searchKey)?.kill()
|
||||
activeTextSearches.set(searchKey, child)
|
||||
// stream stdout → ingestRgJsonLine(line, rootPath, acc, maxResults, wslTransform)
|
||||
// on 'stop' → child.kill()
|
||||
// on close/error → resolve(finalize(acc))
|
||||
// timeout → set acc.truncated = true, child.kill()
|
||||
```
|
||||
|
||||
**`src/main/ipc/filesystem-search-git.ts`** becomes a thin wrapper around `buildGitGrepArgs` + `ingestGitGrepLine`. File drops from 220 → ~80 lines.
|
||||
|
||||
**`src/relay/fs-handler.ts::search`** and the relay's rg/git-grep helpers: identical shape to the local caller, minus the WSL transform and `activeTextSearches` tracking. `searchWithRg` in `fs-handler-utils.ts` is deleted; its callers inline the spawn loop or we keep a thin relay-side wrapper (`src/relay/fs-handler-search.ts`).
|
||||
|
||||
**Critical:** the relay's rg caller uses `spawn`, not `execFile`. This alone fixes the reported bug.
|
||||
|
||||
### Signature + path normalization (unified)
|
||||
|
||||
The shared helpers settle two small existing asymmetries:
|
||||
|
||||
- git-grep callers in main and relay take `maxResults` differently today (third positional arg vs. folded into opts). The shared `buildGitGrepArgs` / `ingestGitGrepLine` take `maxResults` on the accumulator-ingest side only, so callers no longer invent their own shape.
|
||||
- Relative-path normalization is unified on `normalizeRelativePath` (collapse mixed separators, strip leading slashes). The relay's plain `replace(/\\/g, '/')` is replaced, removing a drift seam that would surface the first time someone passed a path with a leading slash through the relay.
|
||||
|
||||
### Truncation semantics (unified)
|
||||
|
||||
One rule: `acc.truncated = true` if and only if rg/git-grep would have emitted more matches after we stopped consuming. Specifically:
|
||||
|
||||
- `maxResults` reached while processing submatches for a match record → truncated.
|
||||
- Kill-timeout fires → truncated.
|
||||
- rg/git-grep exits with non-zero status → *not* truncated; this is a clean "no results or early termination" path. (Matches current local behavior.)
|
||||
|
||||
This removes the existing inconsistency where the relay's `execFile` maxBuffer overflow silently returned `truncated: false` despite dropping matches.
|
||||
|
||||
**Ordering invariant (do not break during migration).** Today the caller flips `truncated = true` synchronously in the same tick it calls `child.kill()`, before the `close` handler resolves the promise. The shared module must preserve that ordering: `ingestRgJsonLine` / `ingestGitGrepLine` mutate `acc.truncated` synchronously when they return `'stop'`, and the caller must kill the child *after* that mutation. If a naive refactor moves the kill inside the helper but leaves `truncated` setting in the caller — or vice versa — a `close` event can resolve the promise with `truncated: false` even though matches were dropped. This is the exact silent-truncation footgun the refactor is meant to kill; regressing it reintroduces the original bug in a harder-to-spot form.
|
||||
|
||||
### Regex parity
|
||||
|
||||
`buildSubmatchRegex` centralizes the "escape literal query, wrap in `\b` for whole-word, add `gi` flags" logic currently in two files. Includes the zero-length-match guard (`matchRegex.lastIndex++` when `m[0].length === 0`) that the relay version also has but that would regress if one side is touched without the other.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **Land the shared module with tests.** Unit tests live at `src/shared/text-search.test.ts`, modeled on `src/shared/quick-open-filter.test.ts`. Cover: arg construction (every flag combination), rg JSON line ingestion (match/non-match/malformed/multi-submatch/maxResults boundary), git-grep line parsing (null-byte delimiter, colons in filenames, unicode, zero-length regex), and finalize shape.
|
||||
|
||||
2. **Migrate the local path.** Replace the inline rg loop in `filesystem.ts` and rewrite `filesystem-search-git.ts` to use the shared helpers. Existing `filesystem-list-files.test.ts` + `filesystem.test.ts` catch regressions; add a test specifically for the `execFile` → `spawn` equivalence (large result set that would have overflowed 50MB under `execFile`).
|
||||
|
||||
3. **Migrate the relay path.** Replace `searchWithRg` (`execFile` → `spawn`) and consolidate `fs-handler-git-fallback.ts`'s search half. Delete `searchWithRg` and the relay's git-grep duplicate once the last caller is gone.
|
||||
|
||||
4. **Drift guard.** Add a short comment at the top of `shared/text-search.ts` pointing to this design doc and naming both call sites. The existing comment at the top of `shared/quick-open-filter.ts` is the template.
|
||||
|
||||
## Non-goals / Explicit Non-changes
|
||||
|
||||
- **Not merging `checkRgAvailable`.** Both versions already agreed (no caching — see the "Why no cache" comments in `src/main/ipc/rg-availability.ts` and `src/relay/fs-handler-utils.ts`), but they wrap different spawn primitives: the local side runs through `wslAwareSpawn` and accepts a `searchPath` so WSL distro resolution works, while the relay uses plain `execFile`. Sharing would force one side to import the other's spawn wrapper. Two thin files, one contract.
|
||||
- **Not unifying spawn.** `wslAwareSpawn` and `gitSpawn` carry WSL and git-auth concerns the relay has no business with.
|
||||
- **Not changing the request shape or existing result fields.** `SearchOptions` stays as-is, and
|
||||
`SearchResult` keeps the same required fields. Long-line clamping may add optional display-only
|
||||
coordinates to a match so the sidebar can highlight bounded snippets without corrupting the
|
||||
source `column`/`matchLength` used for editor reveal.
|
||||
- **Not touching the renderer.** `right-sidebar/Search.tsx` and `QuickOpen.tsx` are unchanged.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Test coverage for relay search is thin.** Current tests exercise the local path. Plan: port the new shared-module tests plus add a relay-specific integration check (`fs-handler` test that streams a large mock rg stdout through the `spawn`-based loop to confirm no drop).
|
||||
- **Behavioral parity is not 1:1 today.** The local path has WSL translation; the relay does not. Parity we're preserving is output *shape*, not output *paths*. Tests must not assume absolute-path equality across the two callers.
|
||||
- **Kill-previous-search is only local.** The relay can process multiple concurrent `fs.search` requests over the mux. If that becomes a problem, add relay-side cancellation later — it is not part of this refactor.
|
||||
@@ -1,317 +0,0 @@
|
||||
# Design Document: Drag-and-Drop File Import over SSH
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Orca's file explorer supports dragging external files from the OS into the explorer when working with a local worktree (see `docs/file-explorer-external-drop.md`). However, this feature does not work when connected to an SSH remote. The `fs:importExternalPaths` IPC handler uses Node's local filesystem APIs (`copyFile`, `lstat`, `readdir`, `mkdir`) and has no `connectionId` parameter — the renderer never passes one, and the main process has no code path to route import operations through the SSH filesystem provider.
|
||||
|
||||
This document proposes extending the import flow to support SSH connections, enabling users to drop local files onto the explorer and have them uploaded to the remote server.
|
||||
|
||||
**Origin:** User feedback — [Slack thread](https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009), [GitHub #200](https://github.com/stablyai/orca-internal/issues/200).
|
||||
|
||||
## 2. Current Architecture
|
||||
|
||||
### 2.1 Local Import Path
|
||||
|
||||
The existing local import flow:
|
||||
|
||||
1. **Preload** intercepts native OS `drop` events, extracts `FileList` paths, resolves the destination directory from `data-native-file-drop-dir` DOM markers, and emits one IPC event: `{ target: 'file-explorer', paths, destinationDir }`.
|
||||
2. **Renderer** (`useFileExplorerImport.ts`) receives the event and calls `window.api.fs.importExternalPaths({ sourcePaths, destDir })`.
|
||||
3. **Main** (`filesystem-mutations.ts`) runs the import: authorize paths → `lstat` validation → symlink pre-scan → deconflict names → `copyFile`/`recursiveCopyDir`.
|
||||
|
||||
All of this is local filesystem only. No `connectionId` is threaded anywhere.
|
||||
|
||||
### 2.2 SSH Filesystem Provider
|
||||
|
||||
The `SshFilesystemProvider` communicates with a relay binary on the remote host via a JSON-RPC multiplexer (`SshChannelMultiplexer`). It supports:
|
||||
|
||||
- `readDir`, `readFile`, `writeFile`, `stat`, `deletePath`, `createFile`, `createDir`, `rename`, `copy`, `realpath`, `search`, `listFiles`, `watch`
|
||||
|
||||
`writeFile` accepts a `string` content parameter — it is text-only and unsuitable for binary files (images, compiled assets, etc.).
|
||||
|
||||
`copy` is remote-to-remote — it tells the relay to copy a file on the remote side.
|
||||
|
||||
### 2.3 Direct SFTP
|
||||
|
||||
The codebase already uses direct SFTP for relay deployment (`ssh-relay-deploy-helpers.ts`):
|
||||
|
||||
- `uploadFile(sftp, localPath, remotePath)` — streams a local file to the remote via `createReadStream` → `sftp.createWriteStream`.
|
||||
- `uploadDirectory(sftp, localDir, remoteDir)` — recursively creates directories and uploads files.
|
||||
- `mkdirSftp(sftp, remotePath)` — creates remote directories.
|
||||
|
||||
These helpers use `ssh2`'s `SFTPWrapper` obtained from `SshConnection.sftp()`.
|
||||
|
||||
### 2.4 Other SSH-Aware Mutations
|
||||
|
||||
Other filesystem mutations (`createFile`, `createDir`, `rename`) already accept `connectionId` and route through `getSshFilesystemProvider()`. The import handler is the exception.
|
||||
|
||||
### 2.5 System Context
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ Renderer (file-explorer) │
|
||||
│ useFileExplorerImport ──► IPC: fs:importExternalPaths │
|
||||
│ { sourcePaths, destDir, connectionId? } │
|
||||
└──────────────────────┬───────────────────────────────────┘
|
||||
│
|
||||
┌────────────▼────────────┐
|
||||
│ Main Process │
|
||||
│ filesystem-mutations │
|
||||
│ │
|
||||
│ connectionId present? │
|
||||
│ ├─ NO → local fs │
|
||||
│ │ copyFile / mkdir │
|
||||
│ └─ YES → SFTP │
|
||||
│ SshConnection │
|
||||
│ .sftp() │
|
||||
└──────┬──────────┬───────┘
|
||||
│ │
|
||||
┌────────▼──┐ ┌───▼────────────┐
|
||||
│ Local FS │ │ Remote Host │
|
||||
│ (source │ │ (destination │
|
||||
│ always │ │ via SFTP) │
|
||||
│ local) │ │ │
|
||||
└───────────┘ └────────────────┘
|
||||
```
|
||||
|
||||
> **Architecture note:** The import handler bypasses `SshFilesystemProvider` and uses `SshConnection.sftp()` directly. This is intentional — the relay's JSON-RPC `fs.writeFile` is text-only and cannot carry binary data without base64 encoding overhead. Future maintainers should not "fix" this to route through the provider.
|
||||
|
||||
## 3. Gap Analysis
|
||||
|
||||
| Requirement | Local | SSH |
|
||||
|---|---|---|
|
||||
| Source path validation (`lstat`) | Local `fs.lstat` | Local `fs.lstat` (source is always local) |
|
||||
| Symlink pre-scan | Local `fs.readdir` | Local `fs.readdir` (source is always local) |
|
||||
| Name deconfliction | Local `fs.lstat` on dest | Remote `stat` via relay/SFTP |
|
||||
| File copy | `fs.copyFile` | SFTP stream upload |
|
||||
| Directory creation | `fs.mkdir` | SFTP `mkdir` or relay `fs.createDir` |
|
||||
| Recursive directory copy | Local `readdir` + `copyFile` | Local `readdir` + SFTP upload per file |
|
||||
|
||||
Key insight: **source paths are always local** (they come from the user's OS file manager). Only the destination is remote. This means source validation (lstat, symlink pre-scan) stays unchanged — only the copy-to-destination step needs an SSH path.
|
||||
|
||||
## 4. Proposed Design
|
||||
|
||||
### 4.1 Strategy: Direct SFTP Upload
|
||||
|
||||
Use `ssh2`'s SFTP channel directly from the main process, reusing the existing `uploadFile`/`uploadDirectory`/`mkdirSftp` helpers from `ssh-relay-deploy-helpers.ts`. Do NOT route through the relay's JSON-RPC `fs.writeFile` because:
|
||||
|
||||
- `fs.writeFile` is text-only (string content over JSON-RPC).
|
||||
- Binary files (images, PDFs, compiled assets) would require base64 encoding + relay-side decode, adding complexity and ~33% bandwidth overhead.
|
||||
- The SFTP helpers already exist, are tested, and handle streaming correctly.
|
||||
|
||||
### 4.2 IPC Changes
|
||||
|
||||
**`api-types.ts`** — Add `connectionId` to the import args:
|
||||
|
||||
```ts
|
||||
importExternalPaths: (args: {
|
||||
sourcePaths: string[]
|
||||
destDir: string
|
||||
connectionId?: string
|
||||
}) => Promise<{ results: ImportItemResult[] }>
|
||||
```
|
||||
|
||||
**`preload/index.ts`** — Thread `connectionId` through the IPC invoke.
|
||||
|
||||
### 4.3 Main-Process Import Handler
|
||||
|
||||
Extend the `fs:importExternalPaths` handler in `filesystem-mutations.ts`:
|
||||
|
||||
```
|
||||
if (connectionId) {
|
||||
→ check SSH connection state; if reconnecting, return user-friendly error
|
||||
→ guard: if sourcePaths is empty, return { results: [] } immediately
|
||||
→ get SshConnection from session registry
|
||||
→ open SFTP channel
|
||||
→ show indeterminate "Importing files…" toast
|
||||
→ try:
|
||||
run SSH import path (4.4)
|
||||
finally:
|
||||
close SFTP channel (guaranteed cleanup)
|
||||
dismiss toast
|
||||
else
|
||||
→ resolveAuthorizedPath(destDir)
|
||||
→ existing local import path (unchanged)
|
||||
```
|
||||
|
||||
**Implementation constraint — `resolveAuthorizedPath` placement:** The current handler calls `resolveAuthorizedPath(destDir)` unconditionally before any copy work. This must be restructured: move `resolveAuthorizedPath(destDir)` inside the `else` (local) branch. For SSH imports, `destDir` is a remote path that does not exist on the local filesystem, so `resolveAuthorizedPath` will throw. The SSH branch skips local path authorization because remote paths are authorized by the SSH connection boundary itself (see Section 9).
|
||||
|
||||
**Connection-state check:** Before attempting `connection.sftp()`, the handler must inspect the connection state. If the connection is in `reconnecting` state, fail early with a toast: _"SSH connection is reconnecting — please try again in a moment."_ This avoids an unhelpful generic "Not connected" SFTP error that gives the user no guidance.
|
||||
|
||||
**Empty source paths:** If `sourcePaths` is an empty array, return `{ results: [] }` immediately without opening an SFTP channel. This avoids unnecessary channel overhead for a no-op.
|
||||
|
||||
**SFTP channel cleanup:** The SFTP channel opened for the import must be closed on all code paths — success, partial failure, or exception. The handler must use `try/finally` semantics around the upload loop. Note that individual `uploadFile` calls receive the `sftp` handle as a parameter and do not manage its lifecycle; the caller (the import handler) is solely responsible for closing the channel.
|
||||
|
||||
**In-progress feedback:** Show an indeterminate "Importing files…" toast when the IPC call begins and dismiss it when the import completes (success or failure). This costs almost nothing to implement and significantly improves the experience on slow connections where network latency makes the drop-to-toast gap noticeable.
|
||||
|
||||
### 4.4 SSH Import Pipeline
|
||||
|
||||
For each source path in the batch:
|
||||
|
||||
1. **Source validation** — unchanged. `lstat` the local source path. Reject symlinks, missing, permission-denied. Pre-scan directories for nested symlinks.
|
||||
|
||||
2. **Name deconfliction** — use SFTP `lstat` (not `stat`) on the remote destination to check for collisions, matching the local import's use of `lstat`. This ensures consistent collision semantics: a dangling symlink at the destination is still treated as "name taken." SFTP lstat throws `SSH_FX_NO_SUCH_FILE` (code 2) when the path doesn't exist — use this as the "no collision" signal.
|
||||
|
||||
3. **Upload** — for files, use `uploadFile(sftp, localPath, remotePath)`. For directories, use recursive SFTP mkdir + uploadFile. Reuse the existing helpers from `ssh-relay-deploy-helpers.ts` after extracting them to a shared location.
|
||||
|
||||
4. **Result reporting** — same per-item `ImportItemResult` schema. The renderer doesn't need to know whether the import went local or SSH.
|
||||
|
||||
### 4.5 Accessing the SFTP Channel
|
||||
|
||||
The `SshConnection` class already exposes `async sftp(): Promise<SFTPWrapper>`. The import handler needs access to the connection for a given `connectionId`.
|
||||
|
||||
Current architecture: `SshRelaySession` owns the connection lifecycle but doesn't directly expose the `SshConnection`. The `getSshFilesystemProvider()` dispatch only returns the `IFilesystemProvider` interface.
|
||||
|
||||
Options:
|
||||
|
||||
**Option A: Expose `SshConnection` via a session registry.**
|
||||
Add a `getSshConnection(connectionId)` function that returns the `SshConnection` from the `SshRelaySession` map. The import handler calls `connection.sftp()` directly.
|
||||
|
||||
**Option B: Add an `uploadFile` method to `IFilesystemProvider`.**
|
||||
Extend the provider interface with `uploadFile(localPath: string, remotePath: string): Promise<void>` and `uploadDirectory(localDir: string, remoteDir: string): Promise<void>`. The SSH provider implements them via SFTP; the local provider implements them as `copyFile`/`recursiveCopyDir`.
|
||||
|
||||
**Recommendation: Option A.** Option B pollutes the provider interface with a local↔remote transfer concern that only applies to import. The relay-based provider should stay focused on remote-side operations. A direct SFTP path from the import handler is simpler and keeps the provider interface clean.
|
||||
|
||||
### 4.6 Renderer Changes
|
||||
|
||||
**`useFileExplorerImport.ts`** — Pass `connectionId` from the active worktree:
|
||||
|
||||
```ts
|
||||
const connectionId = getConnectionId(activeWorktreeIdRef.current) ?? undefined
|
||||
const { results } = await window.api.fs.importExternalPaths({
|
||||
sourcePaths: paths,
|
||||
destDir: destinationDir,
|
||||
connectionId
|
||||
})
|
||||
```
|
||||
|
||||
This is the only renderer change needed. The rest of the import UX (drag state, highlight, toast, reveal) works identically for local and SSH.
|
||||
|
||||
### 4.7 Helper Extraction
|
||||
|
||||
Move `uploadFile`, `uploadDirectory`, and `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to a shared module (e.g., `src/main/ssh/sftp-upload.ts`). The relay deploy code imports from the new location. This avoids coupling the import feature to relay deployment internals.
|
||||
|
||||
**Async filesystem calls:** The existing `uploadDirectory` uses `readdirSync` and `statSync`, which block the event loop. During extraction, replace these with their async counterparts (`readdir` with `{ withFileTypes: true }` from `fs/promises`). The local import's `recursiveCopyDir` already uses async fs calls and serves as the template for this conversion.
|
||||
|
||||
## 5. Symlink Policy
|
||||
|
||||
Unchanged from the local import design. Source-side symlinks are rejected before upload begins. The pre-scan uses local `readdir` + `lstat`, which works identically regardless of the destination being local or remote.
|
||||
|
||||
## 6. Conflict Policy
|
||||
|
||||
Same as local: non-destructive, prompt-free deconfliction. The difference is that collision checks use SFTP `lstat` instead of local `lstat`. Using `lstat` (rather than `stat`) matches the local path's semantics: a symlink at the destination is treated as a collision even if its target doesn't exist.
|
||||
|
||||
SFTP lstat error handling:
|
||||
|
||||
- `SSH_FX_NO_SUCH_FILE` (status code 2) → no collision, name is available.
|
||||
- `SSH_FX_PERMISSION_DENIED` (status code 3) → fail the item.
|
||||
- Any other error → fail the item with the error message.
|
||||
|
||||
## 7. Performance Considerations
|
||||
|
||||
### 7.1 SFTP Channel Lifecycle
|
||||
|
||||
Open one SFTP channel per import gesture, not per file. Close it after all items are uploaded using `try/finally` to guarantee cleanup even on partial failure. Opening an SFTP subsystem has ~100ms overhead per channel due to the SSH handshake.
|
||||
|
||||
### 7.2 Sequential vs. Parallel Upload
|
||||
|
||||
v1: sequential upload (one file at a time). This matches the existing relay deploy behavior and avoids SFTP channel contention. SFTP supports multiple concurrent operations, but managing parallel uploads with error handling and progress adds complexity that isn't needed for v1.
|
||||
|
||||
### 7.3 Large File Handling
|
||||
|
||||
`uploadFile` uses `createReadStream` → `sftp.createWriteStream`, which streams data rather than buffering entire files into memory. This handles large files without OOM risk.
|
||||
|
||||
### 7.4 Network Latency
|
||||
|
||||
Unlike local imports, SSH imports are bounded by network throughput. For large drops, the user may see a noticeable delay between the drop gesture and the toast/reveal. v1 includes an indeterminate "Importing files…" toast during the upload to bridge this gap. Granular per-file progress UI is deferred to v2.
|
||||
|
||||
## 8. Error Handling
|
||||
|
||||
Same per-item error reporting as local imports, plus SSH-specific failures:
|
||||
|
||||
- **SSH connection in `reconnecting` state at drop time:** Fail immediately with toast: _"SSH connection is reconnecting — please try again in a moment."_ Do not attempt to open an SFTP channel.
|
||||
- **SSH connection lost during upload:** Fail remaining items. Partially uploaded files may be left on the remote — acceptable for v1 since partial files are visible in the explorer and can be deleted manually.
|
||||
- **SFTP channel failure:** Fail the entire import. The `finally` block still runs to release the channel handle.
|
||||
- **Remote disk full:** SFTP write stream error — fail the affected item.
|
||||
- **Permission denied on remote directory:** Fail the affected item.
|
||||
|
||||
Toast messages remain the same format:
|
||||
|
||||
- `Imported 5 items to ~/project/src`
|
||||
- `Imported 4 items to ~/project/src. 1 item was skipped.`
|
||||
- `Could not import dropped items`
|
||||
- `SSH connection is reconnecting — please try again in a moment`
|
||||
|
||||
### 8.1 Data Flow Paths
|
||||
|
||||
**Happy path:** Renderer sends `{ sourcePaths: ["/a.txt"], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state (connected) → opens SFTP → shows "Importing files…" toast → validates source locally → deconflicts name via SFTP stat → uploads via SFTP stream → closes SFTP → dismisses toast → returns `{ results: [{ path: "/remote/dir/a.txt", status: "ok" }] }` → renderer shows success toast and reveals file.
|
||||
|
||||
**Empty sourcePaths:** Renderer sends `{ sourcePaths: [], destDir: "/remote/dir", connectionId: "abc" }` → main returns `{ results: [] }` immediately, no SFTP channel opened, no toast shown.
|
||||
|
||||
**Nil connectionId (local fallback):** Renderer sends `{ sourcePaths: [...], destDir: "/local/dir" }` → main takes existing local import path unchanged.
|
||||
|
||||
**Error — reconnecting:** Renderer sends `{ sourcePaths: [...], destDir: "/remote/dir", connectionId: "abc" }` → main checks connection state → state is `reconnecting` → returns error → renderer shows "SSH connection is reconnecting — please try again in a moment" toast.
|
||||
|
||||
**Error — mid-upload failure:** Main opens SFTP → uploads file 1 OK → file 2 throws (e.g., permission denied) → file 2 marked as failed → file 3 continues → SFTP closed in `finally` → toast dismissed → returns mixed results → renderer shows "Imported 2 items … 1 item was skipped."
|
||||
|
||||
## 9. Security
|
||||
|
||||
- Source paths are still authorized via `authorizeExternalPath()` — unchanged.
|
||||
- Destination paths on the remote are not subject to local path authorization (they're on the remote host). The SSH connection itself is the authorization boundary.
|
||||
- SFTP operations run under the SSH user's permissions on the remote host.
|
||||
|
||||
## 10. Testing
|
||||
|
||||
### 10.1 Unit Tests (Main Process)
|
||||
|
||||
- SSH import handler routes to SFTP when `connectionId` is present.
|
||||
- SSH import handler falls back to local import when `connectionId` is absent.
|
||||
- Empty `sourcePaths` array returns `{ results: [] }` without opening an SFTP channel.
|
||||
- Reconnecting connection state returns a user-friendly error without attempting SFTP.
|
||||
- Name deconfliction works with SFTP stat (mock SFTP stat to simulate collisions).
|
||||
- Source-side symlink rejection works identically for SSH imports.
|
||||
- SFTP channel is opened once per gesture, not per file.
|
||||
- SFTP channel is closed after import completes (success or failure) — verify `finally` cleanup runs even when upload throws.
|
||||
- Partial failure (some files succeed, some fail) returns correct per-item results.
|
||||
- Indeterminate "Importing files…" toast is shown during upload and dismissed on completion.
|
||||
|
||||
### 10.2 Integration Tests
|
||||
|
||||
- Drop a single file into an SSH-connected explorer root → file appears on remote.
|
||||
- Drop a directory into an SSH-connected explorer → directory tree appears on remote.
|
||||
- Drop a file that collides with an existing remote file → deconflicted name used.
|
||||
- Drop onto a subdirectory row → file lands in that directory on remote.
|
||||
|
||||
### 10.3 Renderer Tests
|
||||
|
||||
- `useFileExplorerImport` passes `connectionId` when active worktree has an SSH connection.
|
||||
- `useFileExplorerImport` passes `undefined` for `connectionId` when local.
|
||||
|
||||
## 11. Implementation Plan
|
||||
|
||||
1. **Extract SFTP helpers** — Move `uploadFile`, `uploadDirectory`, `mkdirSftp` from `ssh-relay-deploy-helpers.ts` to `src/main/ssh/sftp-upload.ts`. Update relay deploy imports.
|
||||
|
||||
2. **Expose SSH connection accessor** — Add `getSshConnection(connectionId)` to the session registry so the import handler can obtain an SFTP channel.
|
||||
|
||||
3. **Add `connectionId` to import IPC** — Update `api-types.ts`, `preload/index.ts`, and the `fs:importExternalPaths` handler signature.
|
||||
|
||||
4. **Implement SSH import path** — In `filesystem-mutations.ts` (or a new `filesystem-import-ssh.ts`), add the SSH branch: open SFTP → validate sources locally → deconflict names via SFTP stat → upload via SFTP → close SFTP → return results.
|
||||
|
||||
5. **Thread `connectionId` in renderer** — Update `useFileExplorerImport.ts` to pass `connectionId` from the active worktree.
|
||||
|
||||
6. **Tests** — Unit tests for the SSH import path, integration tests for end-to-end flow.
|
||||
|
||||
## 12. Complexity Assessment
|
||||
|
||||
**Estimated difficulty: Medium.**
|
||||
|
||||
- The hardest part is already done — the local drag-drop UX, preload routing, and renderer import hook all exist and work.
|
||||
- SFTP upload helpers exist and are proven in relay deployment.
|
||||
- The main new work is: (a) wiring `connectionId` through the import IPC, (b) implementing SFTP-based name deconfliction, (c) connecting the import handler to the SSH connection's SFTP channel.
|
||||
- No relay protocol changes needed. No new renderer UI. No new preload routing.
|
||||
- Risk areas: SFTP error handling edge cases, ensuring the SFTP channel is cleaned up on all code paths, and testing against real SSH servers.
|
||||
|
||||
## 13. Open Questions
|
||||
|
||||
- Whether v2 should show granular per-file upload progress for SSH imports (v1 includes an indeterminate toast; per-file progress would require tracking bytes transferred).
|
||||
- Whether partial uploads should be cleaned up on failure (currently left on remote).
|
||||
- Whether to support drag-and-drop *from* the SSH explorer to the local OS (reverse direction).
|
||||
@@ -1,374 +0,0 @@
|
||||
# Design Document: External File Drop Import in File Explorer
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Orca's file explorer already supports drag-and-drop for moving items that originate inside the explorer, but it does not support dropping files or folders from the OS into the explorer to add them to the active worktree.
|
||||
|
||||
This document proposes a native file-drop import flow that works in both places users expect:
|
||||
|
||||
- Dropping onto the explorer background imports into the worktree root.
|
||||
- Dropping onto a directory row imports into that directory.
|
||||
- Dropping onto a file row imports into that file's parent directory.
|
||||
|
||||
The design follows the same high-level split used by VS Code: external/native drops are handled as imports, while in-explorer drags remain move operations. Superset is a useful reference for renderer-side drag-state handling: it treats `Files` drags as a distinct UX path with explicit hover state instead of trying to force them through the in-app DnD codepath.
|
||||
|
||||
## 2. Goals
|
||||
|
||||
- Let users drop external files and folders from Finder/Explorer/Linux file managers into Orca's file explorer.
|
||||
- Support root-level drops and nested directory drops.
|
||||
- Preserve the current in-explorer move behavior for `text/x-orca-file-path`.
|
||||
- Keep the implementation cross-platform across macOS, Linux, and Windows.
|
||||
- Avoid destructive overwrites by default.
|
||||
- Keep the import path performant for large multi-file and directory drops.
|
||||
|
||||
## 3. Non-Goals
|
||||
|
||||
- No drag-out export from Orca to the OS.
|
||||
- No cross-worktree move semantics for external drops. External drops always copy/import.
|
||||
- No full upload/progress manager in v1.
|
||||
- No overwrite prompt flow in v1.
|
||||
|
||||
## 4. Current State
|
||||
|
||||
Today the relevant pieces are split across three layers:
|
||||
|
||||
- `src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts` and `FileExplorerRow.tsx` handle only internal explorer drags via `text/x-orca-file-path`, and complete the action with `window.api.fs.rename(...)`.
|
||||
- `src/preload/index.ts` intercepts native OS drops before React sees them and classifies them only as `editor` or `terminal`.
|
||||
- `src/renderer/src/hooks/useGlobalFileDrop.ts` opens dropped files in the editor, while terminal panes insert dropped paths into the active PTY.
|
||||
|
||||
That means the explorer never receives a native-drop route, and there is no filesystem API that copies a dropped file tree into the worktree.
|
||||
|
||||
## 5. Reference Behavior
|
||||
|
||||
### 5.1 VS Code
|
||||
|
||||
VS Code explicitly separates:
|
||||
|
||||
- native drag/drop import (`NativeDragAndDropData` -> `ExternalFileImport.import(...)`)
|
||||
- in-explorer drag/drop move/copy (`handleExplorerDrop(...)`)
|
||||
|
||||
The important design takeaway is not the exact API shape. It is that external drops resolve a destination directory first and then run an import pipeline instead of trying to reuse the internal move path.
|
||||
|
||||
### 5.2 Superset
|
||||
|
||||
Superset's desktop app uses renderer-side `onDragOver` / `onDragLeave` / `onDrop` handling keyed off `e.dataTransfer.types.includes("Files")`, with explicit hover UI and defensive `getPathForFile(...)` handling.
|
||||
|
||||
The useful precedent for Orca is the UX structure:
|
||||
|
||||
- treat native file drags as their own interaction mode
|
||||
- show a clear copy/import affordance
|
||||
- clear drag state reliably on `drop` and `dragend`
|
||||
|
||||
## 6. Proposed UX
|
||||
|
||||
When the user drags external files over the file explorer:
|
||||
|
||||
- The explorer root shows a copy/import highlight when the drop target is the worktree root.
|
||||
- Directory rows highlight as valid copy targets.
|
||||
- Hovering a collapsed directory during a native drag auto-expands it after the same delay used for internal moves.
|
||||
- The cursor uses `dropEffect = "copy"`.
|
||||
|
||||
The explorer root drop surface must remain available even when the tree is empty, still loading, or showing a read error. In v1, the right sidebar should keep rendering a root-level explorer container for those states so users can still drop into the worktree root instead of losing the target entirely.
|
||||
|
||||
This requires restructuring the current early-return branches in `FileExplorer.tsx`. Today the component returns dedicated loading / error / empty placeholders before it renders the shared `ScrollArea`, so adding dataset markers only to the existing populated-tree path would still leave root import unavailable in those states.
|
||||
|
||||
When the user drops:
|
||||
|
||||
- On explorer background: import into the worktree root.
|
||||
- On directory row: import into that directory.
|
||||
- On file row: import into the parent directory.
|
||||
|
||||
In v1, "inside the folder" means dropping on that folder's row. Orca's explorer is a virtualized flat list rather than nested DOM containers, so arbitrary whitespace under a folder's rendered children is not treated as a separate interior drop zone.
|
||||
|
||||
After completion:
|
||||
|
||||
- Refresh the destination directory.
|
||||
- Reveal and flash the first imported path.
|
||||
- Show a summary toast, for example `Imported 3 items to src/components`.
|
||||
|
||||
The explorer should not auto-open dropped files in v1. Dropping into the explorer is an "add here" action, not an "open in editor" action.
|
||||
|
||||
## 7. Architecture
|
||||
|
||||
### 7.1 Extend Native Drop Routing
|
||||
|
||||
Add a third native-drop target in preload:
|
||||
|
||||
- `editor`
|
||||
- `terminal`
|
||||
- `file-explorer`
|
||||
|
||||
The current `getNativeFileDropTarget(...)` helper in `src/preload/index.ts` should become a richer resolver that walks `event.composedPath()` and extracts:
|
||||
|
||||
- the high-level target kind
|
||||
- the nearest explorer destination directory, if any
|
||||
|
||||
The explorer DOM should expose two dataset markers:
|
||||
|
||||
- `data-native-file-drop-target="file-explorer"` on the root scroll area
|
||||
- `data-native-file-drop-dir="<absolute dir path>"` on the root container and on each row drop target
|
||||
|
||||
Routing must fail closed for explorer drops. If preload sees `data-native-file-drop-target="file-explorer"` but cannot resolve a `destinationDir`, it should reject the gesture and emit no fallback `editor` drop event.
|
||||
|
||||
Why this is necessary: the preload layer consumes native OS `drop` events before React can read filesystem paths. If preload does not capture the destination directory at drop time, the renderer can no longer tell whether the user meant "root" or "inside this folder".
|
||||
|
||||
The relayed payload should become:
|
||||
|
||||
```ts
|
||||
type NativeFileDropEvent =
|
||||
| { paths: string[]; target: 'editor' }
|
||||
| { paths: string[]; target: 'terminal' }
|
||||
| { paths: string[]; target: 'file-explorer'; destinationDir: string }
|
||||
```
|
||||
|
||||
Preload/main must emit exactly one native-drop event per drop gesture.
|
||||
|
||||
Why: the preload layer already has the full `FileList`. Re-emitting one IPC message per path and asking the renderer to reconstruct the gesture via timing would be both fragile and slower under large drops.
|
||||
|
||||
**Impact on existing listeners:**
|
||||
Because the relay payload changes from `{ path: string }` to `{ paths: string[] }`, existing `ui.onFileDrop` handlers in:
|
||||
- `src/renderer/src/hooks/useGlobalFileDrop.ts` (editor target)
|
||||
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts` (terminal target)
|
||||
must be updated to loop over the `paths` array.
|
||||
|
||||
### 7.2 Renderer Explorer Drag State
|
||||
|
||||
`useFileExplorerDragDrop(...)` should handle two drag families:
|
||||
|
||||
- internal Orca drags: `text/x-orca-file-path`
|
||||
- external/native drags: `Files`
|
||||
|
||||
The existing move logic stays unchanged for internal drags.
|
||||
|
||||
For native drags, the hook should:
|
||||
|
||||
- accept `Files` in root and row `onDragOver`
|
||||
- set copy affordance and hover state
|
||||
- reuse the existing row auto-expand timer for directory targets
|
||||
- clear root/row highlight on `dragleave`, `dragend`, and after the import event fires
|
||||
|
||||
The hook should use the same explicit destination model as the drop router:
|
||||
|
||||
- root background -> worktree root
|
||||
- directory row -> that directory
|
||||
- file row -> file's parent directory
|
||||
|
||||
This follows Superset's approach of treating external drags as a distinct renderer interaction, even though the final import action is triggered from the preload-delivered event instead of the React `drop` handler.
|
||||
|
||||
### 7.3 New Filesystem Import IPC
|
||||
|
||||
Add a dedicated filesystem mutation:
|
||||
|
||||
```ts
|
||||
window.api.fs.importExternalPaths({
|
||||
sourcePaths: string[],
|
||||
destDir: string
|
||||
}): Promise<{
|
||||
results: Array<
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'imported'
|
||||
destPath: string
|
||||
kind: 'file' | 'directory'
|
||||
renamed: boolean
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'skipped'
|
||||
reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported'
|
||||
}
|
||||
| {
|
||||
sourcePath: string
|
||||
status: 'failed'
|
||||
reason: string
|
||||
}
|
||||
>
|
||||
}>
|
||||
```
|
||||
|
||||
Implementation lives alongside the existing filesystem mutations in `src/main/ipc/filesystem-mutations.ts`.
|
||||
|
||||
Behavior:
|
||||
|
||||
1. Authorize every source path with the existing external-path mechanism.
|
||||
2. Validate every source path from its unresolved path using `lstat(...)` before any canonicalization so top-level symlinks are rejected instead of being silently dereferenced by `realpath(...)`.
|
||||
3. Resolve `destDir` through `resolveAuthorizedPath(...)`.
|
||||
4. Copy, never rename.
|
||||
5. Support both files and directories.
|
||||
6. Return per-top-level-item results so the renderer can produce correct summary UX for success, partial success, and renames.
|
||||
|
||||
### 7.4 Copy Semantics
|
||||
|
||||
Use recursive copy semantics in the main process:
|
||||
|
||||
- File source: copy file bytes.
|
||||
- Directory source: create the top-level directory, then recursively copy descendants.
|
||||
|
||||
This should be implemented in Node-side filesystem code, not in the renderer, so path authorization and cross-platform behavior stay centralized.
|
||||
|
||||
### 7.5 Atomic Import Rules
|
||||
|
||||
Directory imports must be atomic at the top-level item boundary.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Before importing a dropped directory, pre-scan that directory tree for disallowed entries such as symlinks.
|
||||
- Source validation must inspect the dropped path itself with `lstat(...)` before calling helpers like `resolveAuthorizedPath(...)` that canonicalize existing paths.
|
||||
- If the pre-scan finds a disallowed entry, skip that top-level source entirely.
|
||||
- Do not create any destination files or directories for a top-level source that fails pre-scan.
|
||||
|
||||
Why: if recursive copy discovers a symlink halfway through, Orca would otherwise leave a partially imported tree behind. Pre-scan is the preferred v1 design because it is simpler and more performant than temp-directory staging while still avoiding partial output.
|
||||
|
||||
## 8. Conflict Policy
|
||||
|
||||
v1 should be non-destructive and prompt-free:
|
||||
|
||||
- Never overwrite an existing file or folder.
|
||||
- If a top-level dropped item collides with an existing name in `destDir`, generate a unique sibling name before copying.
|
||||
|
||||
Examples:
|
||||
|
||||
- `logo.png` -> `logo copy.png`
|
||||
- `logo.png` -> `logo copy 2.png`
|
||||
- `assets/` -> `assets copy/`
|
||||
|
||||
This matches Orca's current bias toward safe filesystem mutations and avoids blocking the drop on a modal confirmation flow.
|
||||
|
||||
Top-level deconfliction is sufficient for dropped directories because the copy target becomes a newly created directory. Once that top-level directory name is unique, nested collisions disappear inside that subtree.
|
||||
|
||||
If multiple dropped items collide with each other, the same deconfliction pass should run against the union of:
|
||||
|
||||
- already existing destination entries
|
||||
- names reserved earlier in the same import batch
|
||||
|
||||
The result payload must preserve whether each successful import was renamed by deconfliction.
|
||||
|
||||
## 9. Symlink Policy
|
||||
|
||||
Reject symlinks in v1.
|
||||
|
||||
Rationale:
|
||||
|
||||
- Symlink copy semantics differ across platforms.
|
||||
- Copying a symlink literal can produce confusing repository state.
|
||||
- Following symlinks can escape the dropped subtree and import unintended content.
|
||||
|
||||
If a dropped source or descendant is a symlink, fail that top-level item and surface a toast summary such as `Skipped 1 item containing symlinks`.
|
||||
|
||||
## 10. Renderer Flow
|
||||
|
||||
The renderer-side import path should be:
|
||||
|
||||
1. `window.api.ui.onFileDrop(...)` receives one gesture-scoped event: `{ target: 'file-explorer', paths, destinationDir }`.
|
||||
2. The explorer calls `window.api.fs.importExternalPaths({ sourcePaths: paths, destDir: destinationDir })`.
|
||||
3. On success or partial success, it calls `refreshDir(destinationDir)` once.
|
||||
4. It reveals and flashes the first successfully imported destination path, if any, by routing through Orca's existing expansion-aware reveal pipeline (`pendingExplorerReveal` / `useFileExplorerReveal`) or an equivalent mechanism that can expand collapsed ancestors before selecting the imported path.
|
||||
5. It clears drag state and shows one summary toast derived from the returned per-item results.
|
||||
|
||||
## 11. Error Handling
|
||||
|
||||
Failure modes should be explicit but non-destructive:
|
||||
|
||||
- Source path no longer exists: skip and report.
|
||||
- Permission denied: fail the affected item and report.
|
||||
- Unsupported symlink: skip and report.
|
||||
- Destination path unauthorized: fail the whole import.
|
||||
|
||||
Toast copy should summarize, not spam:
|
||||
|
||||
- Success: `Imported 5 items to src`
|
||||
- Partial: `Imported 4 items to src. 1 item was skipped.`
|
||||
- Failure: `Could not import dropped items`
|
||||
|
||||
The renderer should derive these counts from the returned result payload rather than inferring them from thrown exceptions.
|
||||
|
||||
## 12. Watcher and Refresh Strategy
|
||||
|
||||
The filesystem watcher in `useFileExplorerWatch.ts` is a useful backstop, but the import flow should still refresh explicitly after completion.
|
||||
|
||||
Why: native drops can create many files quickly, and the UX should not depend on watcher timing to make the destination directory show the new content.
|
||||
|
||||
The minimal explicit refresh is:
|
||||
|
||||
- `refreshDir(destinationDir)` after import
|
||||
|
||||
If imported content lands under directories that were already expanded, the watcher can reconcile the rest.
|
||||
|
||||
## 13. Performance
|
||||
|
||||
Performance is a core requirement for this feature.
|
||||
|
||||
### 13.1 Event Routing
|
||||
|
||||
- Preload should extract the native `FileList` once and relay one IPC event per drop gesture.
|
||||
- The renderer should not do timer-based gesture reconstruction or emit one IPC call per dropped path.
|
||||
|
||||
### 13.2 Main-Process Import
|
||||
|
||||
- Import work must stay in the main process so the renderer remains responsive.
|
||||
- The copy path should use native filesystem copy primitives or streaming I/O, not `readFile()` buffering whole files into memory.
|
||||
- Pre-scan should walk dropped directories once per top-level source to detect symlinks before copy starts.
|
||||
- Top-level deconfliction should happen once per dropped source before the copy loop, avoiding repeated deep-path collision checks.
|
||||
|
||||
### 13.3 UI Updates
|
||||
|
||||
- The renderer should call `refreshDir(destinationDir)` once per completed gesture.
|
||||
- The renderer should emit one summary toast per gesture.
|
||||
- The renderer should reveal only the first successful import rather than forcing multiple scroll/reveal passes.
|
||||
|
||||
### 13.4 Scope Control
|
||||
|
||||
To keep v1 fast and predictable, it should not include per-file progress rows, per-item toasts, or overwrite prompts inside the copy loop.
|
||||
|
||||
## 14. Testing
|
||||
|
||||
### 14.1 Preload / Main
|
||||
|
||||
- target resolution picks `file-explorer` when the composed path contains explorer markers
|
||||
- nearest `data-native-file-drop-dir` wins over outer containers
|
||||
- relay payload includes `destinationDir`
|
||||
- relay emits one event containing all dropped `paths`
|
||||
- file-explorer routing fails closed when the target marker is present but `destinationDir` is missing
|
||||
- editor-target drops still open every dropped file from the single gesture payload
|
||||
- terminal-target drops still insert every dropped path from the single gesture payload
|
||||
|
||||
### 14.2 Filesystem Mutation Tests
|
||||
|
||||
- imports a single file
|
||||
- imports multiple files in one batch
|
||||
- imports a directory recursively
|
||||
- deconflicts top-level filename collisions
|
||||
- deconflicts top-level directory collisions
|
||||
- rejects top-level symlink sources before canonicalization
|
||||
- skips a dropped directory with nested symlinks without leaving partial output
|
||||
- rejects unauthorized destinations
|
||||
- returns per-item results including rename metadata
|
||||
|
||||
### 14.3 Renderer Tests
|
||||
|
||||
- root drop surface remains active while the explorer is empty, loading, or showing a root read error
|
||||
- root highlight appears for `Files` drag
|
||||
- directory rows highlight and auto-expand for `Files` drag
|
||||
- file rows map to parent directory targets
|
||||
- one drop gesture triggers one import IPC call
|
||||
- one drop gesture produces one summary toast
|
||||
- success reveals the first imported destination path
|
||||
- success reveal still works when the imported path lives under ancestors that were collapsed before the drop
|
||||
|
||||
## 15. Implementation Plan
|
||||
|
||||
1. Extend native-drop typing and relay payload in preload/main to send one event per drop gesture with `paths[]`.
|
||||
2. Update existing editor and terminal `ui.onFileDrop` handlers in the renderer to accept `paths[]`.
|
||||
3. Refactor `FileExplorer.tsx` so the shared root explorer container stays mounted for loading, error, and empty states, then add explorer DOM markers for root and per-row destination directories.
|
||||
4. Teach `useFileExplorerDragDrop(...)` to track native `Files` drag state separately from internal move state.
|
||||
5. Add `fs.importExternalPaths(...)` to preload typings and main IPC with a per-item result schema.
|
||||
6. Implement pre-scan + recursive copy + top-level deconfliction + symlink rejection in `filesystem-mutations.ts`.
|
||||
7. Wire the explorer import success path through the existing expansion-aware reveal flow so drops into collapsed folders still reveal correctly.
|
||||
8. Add renderer import handling, refresh, reveal, and one-toast-per-gesture summary handling.
|
||||
9. Add tests across preload, IPC, and renderer.
|
||||
|
||||
## 16. Open Questions
|
||||
|
||||
- Whether v2 should support an overwrite confirmation flow like VS Code instead of prompt-free deconfliction.
|
||||
- Whether v2 should show progress UI for large directory imports.
|
||||
- Whether symlink rejection should later become a user-visible choice for trusted repos.
|
||||
@@ -1,478 +0,0 @@
|
||||
# Fix: Claude & Codex accounts intermittently return 401 Unauthorized
|
||||
|
||||
**Issue:** [#1284](https://github.com/stablyai/orca/issues/1284)
|
||||
**Status:** Proposed fix
|
||||
**Approach:** Add `readBackRefreshedTokens()` to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`
|
||||
|
||||
## Problem
|
||||
|
||||
Codex (and Claude) managed accounts intermittently return `401 Unauthorized` / `token_expired` errors after working normally for some time.
|
||||
|
||||
### Root Cause
|
||||
|
||||
Both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService` perform a **one-directional** auth sync: they always copy the managed account's credentials into the shared runtime path, but never read back refreshed tokens.
|
||||
|
||||
**The problematic flow (identical for both providers):**
|
||||
|
||||
1. User authenticates → credentials saved to managed storage
|
||||
2. `syncForCurrentSelection()` copies managed credentials → runtime path
|
||||
3. CLI runs using the runtime credentials
|
||||
4. OAuth access token expires over time. **CLI refreshes it and writes updated tokens back to the runtime path**
|
||||
5. Next PTY launch or rate-limit fetch triggers `syncForCurrentSelection()` again
|
||||
6. **The sync overwrites the runtime credentials with the stale managed copy** — the refreshed token is lost
|
||||
7. Next request uses the expired token → `401 Unauthorized`
|
||||
|
||||
**Codex specifics:**
|
||||
- Runtime path: `~/.codex/auth.json`
|
||||
- Managed path: `<userData>/codex-accounts/<id>/home/auth.json`
|
||||
- Affected: `CodexRuntimeHomeService.syncForCurrentSelection()` line 84
|
||||
|
||||
**Claude specifics:**
|
||||
- Runtime path: `~/.claude/.credentials.json` (+ Keychain on macOS)
|
||||
- Managed path: `<userData>/claude-accounts/<id>/auth/.credentials.json` (+ Keychain)
|
||||
- Affected: `ClaudeRuntimeAuthService.doSyncForCurrentSelection()` line 125
|
||||
- Note: Claude already has `lastWrittenCredentialsJson` and `detectExternalLoginAndUpdateSnapshot()` for the managed→system-default transition, but it **does not** read back refreshed tokens during steady-state sync — the same one-directional overwrite happens
|
||||
|
||||
### Why it's intermittent
|
||||
|
||||
- Tokens have a multi-hour lifetime, so the bug only surfaces after enough time passes for the original token to expire
|
||||
- The overwrite only happens on PTY launch or rate-limit fetch, not continuously
|
||||
- If the user re-authenticates, the cycle resets with a fresh token
|
||||
|
||||
### Affected code
|
||||
|
||||
**Codex:**
|
||||
- `src/main/codex-accounts/runtime-home-service.ts` — `syncForCurrentSelection()` (line 49-85)
|
||||
- Called from `prepareForCodexLaunch()` (line 39) and `prepareForRateLimitFetch()` (line 44)
|
||||
|
||||
**Claude:**
|
||||
- `src/main/claude-accounts/runtime-auth-service.ts` — `doSyncForCurrentSelection()` (line 96-131)
|
||||
- Called from `prepareForClaudeLaunch()` (line 47) and `prepareForRateLimitFetch()` (line 52)
|
||||
|
||||
## System Context
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Orca (main process) │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ CodexRuntimeHomeService │ │
|
||||
│ │ │ │
|
||||
│ │ lastSyncedAccountId: string | null │ │
|
||||
│ │ lastWrittenAuthJson: string | null ← NEW │ │
|
||||
│ │ │ │
|
||||
│ │ syncForCurrentSelection() │ │
|
||||
│ │ ├─ captureSystemDefaultSnapshotIfNeeded() │ │
|
||||
│ │ ├─ readBackRefreshedTokens() ← NEW │ │
|
||||
│ │ └─ writeRuntimeAuth() │ │
|
||||
│ │ │ │
|
||||
│ │ restoreSystemDefaultSnapshot() │ │
|
||||
│ │ └─ detectExternalLoginAndUpdateSnapshot() ← NEW│ │
|
||||
│ └──────────┬──────────────────────────┬─────────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌───────▼───────┐ ┌────────▼──────────┐ │
|
||||
│ │ Managed Home │ │ Runtime Home │ │
|
||||
│ │ <userData>/ │ │ ~/.codex/ │ │
|
||||
│ │ codex-accounts│ │ auth.json │ │
|
||||
│ │ /<id>/home/ │ │ │ │
|
||||
│ │ auth.json │ │ │ │
|
||||
│ └───────────────┘ └────────┬──────────┘ │
|
||||
└──────────────────────────────────────────┼────────────────────┘
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ Codex CLI │
|
||||
│ │
|
||||
│ reads auth.json
|
||||
│ refreshes token
|
||||
│ writes back │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
## Data Flow: Token Refresh Preservation
|
||||
|
||||
```
|
||||
PTY launch / rate-limit fetch
|
||||
│
|
||||
▼
|
||||
syncForCurrentSelection()
|
||||
│
|
||||
├─ captureSystemDefaultSnapshotIfNeeded()
|
||||
│
|
||||
├─ Is lastSyncedAccountId === activeAccount.id?
|
||||
│ │
|
||||
│ YES → readBackRefreshedTokens()
|
||||
│ │ │
|
||||
│ │ ├─ Read ~/.codex/auth.json (current runtime)
|
||||
│ │ ├─ Compare to lastWrittenAuthJson
|
||||
│ │ │ │
|
||||
│ │ │ DIFFER → Codex CLI refreshed the token
|
||||
│ │ │ │ Write runtime auth.json back
|
||||
│ │ │ │ to managed home auth.json
|
||||
│ │ │ │
|
||||
│ │ │ MATCH → No external changes, continue
|
||||
│ │ │
|
||||
│ │ └─ (try/catch: failures log + continue)
|
||||
│ │
|
||||
│ NO → Skip read-back (account switch in progress)
|
||||
│
|
||||
├─ Read managed auth.json
|
||||
├─ writeRuntimeAuth(contents)
|
||||
│ ├─ Write to ~/.codex/auth.json
|
||||
│ └─ Record lastWrittenAuthJson = contents
|
||||
│
|
||||
└─ lastSyncedAccountId = activeAccount.id
|
||||
```
|
||||
|
||||
## Data Flow: External Login Detection (managed → system-default)
|
||||
|
||||
```
|
||||
User deselects managed account (activeAccount = null)
|
||||
│
|
||||
▼
|
||||
syncForCurrentSelection()
|
||||
│
|
||||
├─ lastSyncedAccountId !== null? (was managed)
|
||||
│ │
|
||||
│ YES → restoreSystemDefaultSnapshot()
|
||||
│ │ │
|
||||
│ │ ├─ detectExternalLoginAndUpdateSnapshot()
|
||||
│ │ │ │
|
||||
│ │ │ ├─ lastWrittenAuthJson !== null?
|
||||
│ │ │ ├─ Read current ~/.codex/auth.json
|
||||
│ │ │ ├─ Compare to lastWrittenAuthJson
|
||||
│ │ │ │ │
|
||||
│ │ │ │ DIFFER → External login detected
|
||||
│ │ │ │ │ (e.g. `codex auth login`)
|
||||
│ │ │ │ │ Delete stale snapshot
|
||||
│ │ │ │ │ Clear lastWrittenAuthJson
|
||||
│ │ │ │ │ Return true (skip restore)
|
||||
│ │ │ │ │
|
||||
│ │ │ │ MATCH → No external login
|
||||
│ │ │ │ Return false (do restore)
|
||||
│ │ │ │
|
||||
│ │ │ └─ (try/catch: failures → return false)
|
||||
│ │ │
|
||||
│ │ └─ If not external: restore snapshot as before
|
||||
│ │
|
||||
│ NO → Skip (was never managed)
|
||||
│
|
||||
└─ lastSyncedAccountId = null
|
||||
```
|
||||
|
||||
## Fix: Add `readBackRefreshedTokens()` to Both Services
|
||||
|
||||
The fix adds the same read-back mechanism to both `CodexRuntimeHomeService` and `ClaudeRuntimeAuthService`. The core logic is identical: before overwriting runtime credentials, compare the file to what Orca last wrote. If they differ, the CLI refreshed the token — write it back to managed storage.
|
||||
|
||||
### What each service needs
|
||||
|
||||
| Change | Codex (`runtime-home-service.ts`) | Claude (`runtime-auth-service.ts`) |
|
||||
|--------|-----------------------------------|-------------------------------------|
|
||||
| Add `lastWrittenAuthJson` field | **NEW** | Already exists as `lastWrittenCredentialsJson` |
|
||||
| Track writes in `writeRuntime*()` | **NEW** | Already done (line 271) |
|
||||
| `readBackRefreshedTokens()` | **NEW** | **NEW** (missing despite having the tracking field) |
|
||||
| `detectExternalLoginAndUpdateSnapshot()` | **NEW** | Already exists (line 224) |
|
||||
| `clearLastWritten*()` for re-auth | **NEW** | **NEW** (same re-auth clobbering risk) |
|
||||
|
||||
### Key differences between services
|
||||
|
||||
| Aspect | Claude | Codex |
|
||||
|--------|--------|-------|
|
||||
| Auth storage | Keychain (macOS) + `.credentials.json` | `auth.json` only |
|
||||
| Sync model | Async (serialized via `mutationQueue`) | Synchronous |
|
||||
| Managed path | `managedAuthPath/.credentials.json` | `managedHomePath/auth.json` |
|
||||
| Tracking field | `lastWrittenCredentialsJson` | `lastWrittenAuthJson` |
|
||||
|
||||
### Codex implementation
|
||||
|
||||
Add a `lastWrittenAuthJson` field and three new behaviors:
|
||||
|
||||
1. **`writeRuntimeAuth()`** — record what was written
|
||||
2. **`readBackRefreshedTokens()`** — before overwriting, check if Codex CLI refreshed the token
|
||||
3. **`detectExternalLoginAndUpdateSnapshot()`** — on managed→system-default transition, detect external logins
|
||||
|
||||
### Pseudocode
|
||||
|
||||
```typescript
|
||||
export class CodexRuntimeHomeService {
|
||||
private lastSyncedAccountId: string | null = null
|
||||
|
||||
// Why: tracks the auth.json content Orca last wrote to ~/.codex/auth.json.
|
||||
// On managed→system-default transition, if the file differs from this value,
|
||||
// an external login (e.g. `codex auth login`) overwrote it — so Orca adopts
|
||||
// the file as the new system default instead of restoring a stale snapshot.
|
||||
// Between syncs, if the file differs, Codex CLI refreshed the token — so
|
||||
// Orca writes back the refreshed token to managed storage.
|
||||
private lastWrittenAuthJson: string | null = null
|
||||
|
||||
// ... constructor, prepare methods unchanged ...
|
||||
|
||||
syncForCurrentSelection(): void {
|
||||
this.captureSystemDefaultSnapshotIfNeeded()
|
||||
|
||||
const settings = this.store.getSettings()
|
||||
const activeAccount = this.getActiveAccount(
|
||||
settings.codexManagedAccounts,
|
||||
settings.activeCodexManagedAccountId
|
||||
)
|
||||
if (!activeAccount) {
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot()
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json')
|
||||
if (!existsSync(activeAuthPath)) {
|
||||
console.warn(
|
||||
'[codex-runtime-home] Active managed account is missing auth.json, restoring system default'
|
||||
)
|
||||
this.store.updateSettings({ activeCodexManagedAccountId: null })
|
||||
if (this.lastSyncedAccountId !== null) {
|
||||
this.restoreSystemDefaultSnapshot()
|
||||
this.lastSyncedAccountId = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NEW: Before overwriting runtime auth, check if Codex CLI refreshed
|
||||
// the token since our last write. If so, preserve those refreshed tokens
|
||||
// back to managed storage so they aren't lost.
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
this.readBackRefreshedTokens(activeAuthPath)
|
||||
}
|
||||
|
||||
this.lastSyncedAccountId = activeAccount.id
|
||||
this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'))
|
||||
}
|
||||
|
||||
// Why: Codex CLI refreshes expired OAuth tokens and writes them back to
|
||||
// ~/.codex/auth.json. If we detect the runtime file differs from what Orca
|
||||
// last wrote, the CLI must have refreshed — so we write the updated tokens
|
||||
// back to managed storage before overwriting runtime with managed state.
|
||||
// This is the Codex analog of the read-back logic implied by
|
||||
// lastWrittenCredentialsJson in ClaudeRuntimeAuthService.
|
||||
private readBackRefreshedTokens(managedAuthPath: string): void {
|
||||
try {
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing to compare against — first sync or after restart.
|
||||
// Skip read-back to avoid capturing stale/unknown state.
|
||||
if (this.lastWrittenAuthJson === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (runtimeContents === this.lastWrittenAuthJson) {
|
||||
return
|
||||
}
|
||||
|
||||
// Codex CLI refreshed tokens at runtime — preserve them in managed storage.
|
||||
writeFileAtomically(managedAuthPath, runtimeContents, { mode: 0o600 })
|
||||
} catch (error) {
|
||||
// Why: read-back is best-effort. A transient fs error (permissions,
|
||||
// file locked by another process) must not block the forward sync
|
||||
// path — the worst case is one more stale-token cycle, which is
|
||||
// strictly better than failing the entire sync.
|
||||
console.warn('[codex-runtime-home] Failed to read back refreshed tokens:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private restoreSystemDefaultSnapshot(): void {
|
||||
// Why: detect whether an external tool (e.g. `codex auth login`) overwrote
|
||||
// auth.json while a managed account was active. If so, that external login
|
||||
// becomes the new system default — skip the stale snapshot restore.
|
||||
if (this.detectExternalLoginAndUpdateSnapshot()) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
if (!existsSync(snapshotPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.writeRuntimeAuth(readFileSync(snapshotPath, 'utf-8'))
|
||||
}
|
||||
|
||||
// Why: mirrors ClaudeRuntimeAuthService.detectExternalLoginAndUpdateSnapshot().
|
||||
// If the runtime auth.json differs from what Orca last wrote, something
|
||||
// external changed it. That external state should become the new system
|
||||
// default rather than being overwritten by a potentially stale snapshot.
|
||||
private detectExternalLoginAndUpdateSnapshot(): boolean {
|
||||
if (this.lastWrittenAuthJson === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const runtimeAuthPath = this.getRuntimeAuthPath()
|
||||
if (!existsSync(runtimeAuthPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const currentAuth = readFileSync(runtimeAuthPath, 'utf-8')
|
||||
if (currentAuth === this.lastWrittenAuthJson) {
|
||||
return false
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// External login detected — adopt current state as the new system default.
|
||||
const snapshotPath = this.getSystemDefaultSnapshotPath()
|
||||
rmSync(snapshotPath, { force: true })
|
||||
this.lastWrittenAuthJson = null
|
||||
return true
|
||||
}
|
||||
|
||||
private writeRuntimeAuth(contents: string): void {
|
||||
writeFileAtomically(this.getRuntimeAuthPath(), contents, { mode: 0o600 })
|
||||
// Why: record what we wrote so readBackRefreshedTokens() and
|
||||
// detectExternalLoginAndUpdateSnapshot() can detect external changes.
|
||||
this.lastWrittenAuthJson = contents
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Re-auth safety: clearing `lastWrittenAuthJson`
|
||||
|
||||
When `CodexAccountService.doReauthenticateAccount()` runs, it writes fresh tokens to managed storage via `codex login`, then calls `syncForCurrentSelection()`. Without intervention, the read-back logic would see that runtime differs from `lastWrittenAuthJson` (because Codex CLI may have refreshed the runtime token between syncs) and write the stale runtime content back to managed — overwriting the fresh re-auth tokens.
|
||||
|
||||
**Fix:** `CodexRuntimeHomeService` must expose a method to clear `lastWrittenAuthJson` so that the re-auth caller can signal "managed storage was externally updated, skip read-back on next sync."
|
||||
|
||||
```typescript
|
||||
// Called by CodexAccountService before syncForCurrentSelection() after re-auth or add-account.
|
||||
clearLastWrittenAuthJson(): void {
|
||||
this.lastWrittenAuthJson = null
|
||||
}
|
||||
```
|
||||
|
||||
The same applies to `doAddAccount()`, which also writes managed auth then syncs. Both callers must call `clearLastWrittenAuthJson()` before `syncForCurrentSelection()`.
|
||||
|
||||
### Changes to `CodexAccountService`
|
||||
|
||||
Both `doAddAccount()` and `doReauthenticateAccount()` write fresh tokens to managed storage (via `codex login`) then call `syncForCurrentSelection()`. They must clear the tracking field first to prevent read-back from overwriting the fresh tokens:
|
||||
|
||||
```typescript
|
||||
// In doAddAccount(), after runCodexLogin succeeds:
|
||||
this.runtimeHome.clearLastWrittenAuthJson()
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
|
||||
// In doReauthenticateAccount(), after runCodexLogin succeeds:
|
||||
this.runtimeHome.clearLastWrittenAuthJson()
|
||||
this.runtimeHome.syncForCurrentSelection()
|
||||
```
|
||||
|
||||
### Safety considerations
|
||||
|
||||
- **Only reads back when Orca owns the runtime auth** (`lastSyncedAccountId === activeAccount.id`), so external `codex login` changes are not accidentally captured into the wrong managed account
|
||||
- **Skips read-back when `lastWrittenAuthJson` is null** (first sync, after restart, or after re-auth/add) — avoids capturing stale or unknown state that Orca didn't write
|
||||
- **try/catch around read-back** — a transient fs error (permissions, locked file) logs a warning but does not block the forward sync. Worst case: one more stale-token cycle, strictly better than a sync failure
|
||||
- **try/catch around external login detection** — same rationale; detection failure falls through to normal snapshot restore, which is the safe default
|
||||
- **Atomic write** to managed auth prevents partial-write corruption
|
||||
- **No behavior change for account switches** — when switching accounts, `lastSyncedAccountId` differs from the new account ID, so no read-back occurs
|
||||
- **No behavior change for system-default flow** — read-back only runs when a managed account is active and was previously synced
|
||||
- **External login detection on managed→system-default transition** — if `codex auth login` ran while a managed account was active, the snapshot is stale. Deleting it and clearing `lastWrittenAuthJson` lets the external login persist as the new system default
|
||||
- **In-memory tracking only** — `lastWrittenAuthJson` is not persisted to disk. After an Orca restart, the field is null, and the first sync performs a clean write without read-back. This is intentionally conservative: we'd rather do one redundant overwrite than risk reading back unknown state
|
||||
|
||||
### Claude implementation
|
||||
|
||||
Claude already has `lastWrittenCredentialsJson` tracking and `detectExternalLoginAndUpdateSnapshot()`. It only needs two additions:
|
||||
|
||||
1. **`readBackRefreshedTokens()`** — same logic as Codex, adapted for Claude's async model and Keychain
|
||||
2. **`clearLastWrittenCredentialsJson()`** — for re-auth safety (same pattern as Codex)
|
||||
|
||||
```typescript
|
||||
// In ClaudeRuntimeAuthService:
|
||||
|
||||
// Add to doSyncForCurrentSelection(), before writeRuntimeCredentials():
|
||||
if (this.lastSyncedAccountId === activeAccount.id) {
|
||||
await this.readBackRefreshedTokens(activeAccount)
|
||||
}
|
||||
|
||||
// Why: Claude CLI refreshes expired OAuth tokens and writes them back to
|
||||
// .credentials.json (and Keychain on macOS). If we detect the runtime file
|
||||
// differs from what Orca last wrote, the CLI must have refreshed.
|
||||
private async readBackRefreshedTokens(account: ClaudeManagedAccount): Promise<void> {
|
||||
try {
|
||||
if (this.lastWrittenCredentialsJson === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const paths = this.pathResolver.getRuntimePaths()
|
||||
if (!existsSync(paths.credentialsPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const runtimeContents = readFileSync(paths.credentialsPath, 'utf-8')
|
||||
if (runtimeContents === this.lastWrittenCredentialsJson) {
|
||||
return
|
||||
}
|
||||
|
||||
// CLI refreshed tokens — write back to managed storage.
|
||||
if (process.platform === 'darwin') {
|
||||
await writeManagedClaudeKeychainCredentials(account.id, runtimeContents)
|
||||
} else {
|
||||
const credentialsPath = join(account.managedAuthPath, '.credentials.json')
|
||||
writeFileAtomically(credentialsPath, runtimeContents, { mode: 0o600 })
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[claude-runtime-auth] Failed to read back refreshed tokens:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Exposed for ClaudeAccountService to call before sync after re-auth/add.
|
||||
clearLastWrittenCredentialsJson(): void {
|
||||
this.lastWrittenCredentialsJson = null
|
||||
}
|
||||
```
|
||||
|
||||
### Changes to `ClaudeAccountService`
|
||||
|
||||
Same pattern as Codex — both `doAddAccount()` and `doReauthenticateAccount()` must clear the tracking field before syncing:
|
||||
|
||||
```typescript
|
||||
// In doAddAccount(), after login succeeds:
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson()
|
||||
await this.runtimeAuth.syncForCurrentSelection()
|
||||
|
||||
// In doReauthenticateAccount(), after login succeeds:
|
||||
this.runtimeAuth.clearLastWrittenCredentialsJson()
|
||||
await this.runtimeAuth.syncForCurrentSelection()
|
||||
```
|
||||
|
||||
### Structural alignment
|
||||
|
||||
The two services should stay structurally aligned. Both now have the same three mechanisms:
|
||||
|
||||
| Mechanism | Codex | Claude |
|
||||
|-----------|-------|--------|
|
||||
| Track what we wrote | `lastWrittenAuthJson` | `lastWrittenCredentialsJson` |
|
||||
| Read back refreshed tokens | `readBackRefreshedTokens()` | `readBackRefreshedTokens()` |
|
||||
| Detect external logins | `detectExternalLoginAndUpdateSnapshot()` | `detectExternalLoginAndUpdateSnapshot()` |
|
||||
| Clear tracking on re-auth | `clearLastWrittenAuthJson()` | `clearLastWrittenCredentialsJson()` |
|
||||
|
||||
Future changes to either service should be cross-checked against the other.
|
||||
|
||||
### Files to modify
|
||||
|
||||
**Codex:**
|
||||
- `src/main/codex-accounts/runtime-home-service.ts` — add `lastWrittenAuthJson` field, `readBackRefreshedTokens()`, `detectExternalLoginAndUpdateSnapshot()`, `clearLastWrittenAuthJson()`, update `writeRuntimeAuth()` and `restoreSystemDefaultSnapshot()`
|
||||
- `src/main/codex-accounts/service.ts` — add `clearLastWrittenAuthJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
|
||||
- `src/main/codex-accounts/runtime-home-service.test.ts` — add test cases
|
||||
|
||||
**Claude:**
|
||||
- `src/main/claude-accounts/runtime-auth-service.ts` — add `readBackRefreshedTokens()`, `clearLastWrittenCredentialsJson()`, call read-back in `doSyncForCurrentSelection()`
|
||||
- `src/main/claude-accounts/service.ts` — add `clearLastWrittenCredentialsJson()` calls in `doAddAccount()` and `doReauthenticateAccount()`
|
||||
- `src/main/claude-accounts/runtime-auth-service.test.ts` — add test cases
|
||||
|
||||
**Test cases (both services):**
|
||||
- Token read-back when CLI refreshes tokens between syncs
|
||||
- No read-back on first sync (tracking field is null)
|
||||
- No read-back on account switch (`lastSyncedAccountId` differs)
|
||||
- External login detection on managed→system-default transition
|
||||
- Graceful degradation when read-back throws (fs error)
|
||||
- Re-auth does not lose fresh tokens: after clearing tracking field + sync, managed storage retains the re-auth tokens
|
||||
- Add-account does not lose fresh tokens: same pattern as re-auth
|
||||
@@ -1,443 +0,0 @@
|
||||
# Fix: Missing single-instance lock corrupts `orca-runtime.json` + `endpoint.env` on every relaunch
|
||||
|
||||
**Issue:** [#1312](https://github.com/stablyai/orca/issues/1312)
|
||||
**Status:** Proposed fix
|
||||
**Approach:** Add `app.requestSingleInstanceLock()`, clear owned metadata on clean exit, and sweep orphaned sockets on startup.
|
||||
|
||||
## Problem
|
||||
|
||||
Orca v1.3.24 does not call `app.requestSingleInstanceLock()`. Every launch of the AppImage / `.app` bundle starts a new Electron main process that unconditionally:
|
||||
|
||||
1. Opens a fresh Unix socket `o-<NEW_PID>-<runtimeId-prefix>.sock`.
|
||||
2. Atomically rewrites `<userData>/orca-runtime.json` with the new pid / socket / runtimeId / authToken, clobbering the previously-running instance's metadata.
|
||||
3. Picks a new random port and rewrites `<userData>/agent-hooks/endpoint.env` with the new port + token.
|
||||
|
||||
The earlier Orca keeps its socket + hook-port alive, but the canonical metadata files no longer point at it. When the most-recent instance quits, the socket is `rmSync`'d in `OrcaRuntimeRpcServer.stop()` but the metadata file is left pointing at the dead pid. `orca status` then returns `runtime.state = 'stale_bootstrap'` even though earlier Orca instances are still running healthily.
|
||||
|
||||
### Root cause
|
||||
|
||||
Three distinct gaps in the main-process lifecycle:
|
||||
|
||||
1. **No single-instance lock.** `grep -rn requestSingleInstanceLock src/` returns zero matches. Every launch boots a full second Electron process instead of focusing the existing window.
|
||||
2. **No metadata clear on clean exit.** `src/main/runtime/runtime-metadata.ts:29` exports `clearRuntimeMetadata()` but it is never called. The `will-quit` handler in `src/main/index.ts:516` stops the RPC server (removes the socket) but leaves `orca-runtime.json` on disk pointing at a dead pid + missing socket. The safety comment in `src/main/runtime/runtime-rpc.ts:132-136` explicitly declines to clear metadata, citing the risk of erasing another live runtime's bootstrap during restarts / updates / dev overlap — that concern is real but is the *symptom* of missing single-instance, not a principled design.
|
||||
3. **No startup sweep of orphaned sockets.** A process killed by SIGKILL / OOM-kill skips `OrcaRuntimeRpcServer.stop()` entirely, leaving `o-<dead-pid>-*.sock` files in `<userData>/` with no cleanup path. The reporter observed three such orphans on a single live system.
|
||||
|
||||
### Symptom (from the issue)
|
||||
|
||||
Healthy state with one orca running (pid 50926):
|
||||
|
||||
```json
|
||||
{
|
||||
"app": { "running": true, "pid": 50926 },
|
||||
"runtime": { "state": "ready", "reachable": true, "runtimeId": "2ad0..." },
|
||||
"graph": { "state": "ready" }
|
||||
}
|
||||
```
|
||||
|
||||
After launching a second instance and quitting it while the first stays open:
|
||||
|
||||
```json
|
||||
{
|
||||
"app": { "running": false, "pid": null },
|
||||
"runtime": { "state": "stale_bootstrap", "reachable": false, "runtimeId": null },
|
||||
"graph": { "state": "not_running" }
|
||||
}
|
||||
```
|
||||
|
||||
The `stale_bootstrap` branch is distinguished at `src/cli/runtime/status.ts:17-20` — "metadata file exists but pid not running" vs. "no metadata at all".
|
||||
|
||||
### Affected code
|
||||
|
||||
- `src/main/index.ts` — app lifecycle entry; `app.whenReady` at line 315, `will-quit` at line 516.
|
||||
- `src/main/runtime/runtime-rpc.ts` — `OrcaRuntimeRpcServer.start()` line 51, `stop()` line 112 (with the deferred-cleanup comment at lines 132-136).
|
||||
- `src/main/runtime/runtime-metadata.ts` — `writeRuntimeMetadata` / `readRuntimeMetadata` / `clearRuntimeMetadata`.
|
||||
- `src/main/agent-hooks/server.ts` — `writeEndpointFile` line 1299, `stop()` line 1223 (with a parallel deferred-cleanup comment at lines 1230-1237).
|
||||
- `src/shared/runtime-bootstrap.ts` — `RuntimeMetadata` shape and `getRuntimeMetadataPath()`.
|
||||
- `src/main/startup/configure-process.ts` — `configureDevUserDataPath(isDev)` redirects dev runs to `orca-dev` userData.
|
||||
- `src/cli/runtime/status.ts` — CLI reader, never writes metadata.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Current (buggy) state
|
||||
|
||||
Every launch boots a new Electron main. All live instances race on the same `orca-runtime.json` / `endpoint.env`, and SIGKILL'd predecessors leave orphaned sockets behind.
|
||||
|
||||
```
|
||||
<userData>/ (e.g. ~/.config/orca)
|
||||
┌────────────────────────────────────────────┐
|
||||
│ orca-runtime.json ← clobbered on every │
|
||||
│ launch (last writer │
|
||||
│ wins, pid may be │
|
||||
│ dead) │
|
||||
│ agent-hooks/endpoint.env ← same race │
|
||||
│ o-<pid-A>-*.sock (live, owned by A) │
|
||||
│ o-<pid-B>-*.sock (orphan — B SIGKILL'd) │
|
||||
│ o-<pid-C>-*.sock (orphan — C OOM-killed)│
|
||||
└────────────────────────────────────────────┘
|
||||
▲ ▲ ▲
|
||||
│ writes │ writes │ writes
|
||||
│ │ │
|
||||
┌───────┴──┐ ┌──────┴───┐ ┌─────┴────┐
|
||||
│ Electron │ │ Electron │ │ Electron │
|
||||
│ #1 │ │ #2 │ │ #3 │
|
||||
│ (live, │ │ (live, │ │ (quit; │
|
||||
│ hook │ │ hook │ │ left │
|
||||
│ HTTP) │ │ HTTP) │ │ stale │
|
||||
└──────────┘ └──────────┘ │ meta) │
|
||||
└──────────┘
|
||||
▲
|
||||
│ reads orca-runtime.json
|
||||
│ → sees wrong/dead pid
|
||||
┌───────┴───────┐
|
||||
│ CLI (status) │ reports 'stale_bootstrap'
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Post-fix state
|
||||
|
||||
A single Electron owns the userData. Second launches fire `second-instance` and exit. The ownership guard at `clearRuntimeMetadataIfOwned()` protects the auto-updater handoff. Sweep runs at startup.
|
||||
|
||||
```
|
||||
<userData>/
|
||||
┌────────────────────────────────────────────┐
|
||||
│ orca-runtime.json ← one writer (pid A) │
|
||||
│ agent-hooks/endpoint.env │
|
||||
│ o-<pid-A>-*.sock (live, owned by A) │
|
||||
│ (orphans swept on next start) │
|
||||
└────────────────────────────────────────────┘
|
||||
▲
|
||||
│ writes
|
||||
┌───────┴──────────────┐
|
||||
│ Electron #1 (holds │──── hook HTTP ──►
|
||||
│ single-instance lock)│
|
||||
└──────────▲───────────┘
|
||||
│ 'second-instance' event
|
||||
┌──────────┴───────────┐
|
||||
│ Electron #2 (boots, │
|
||||
│ lock fails, focuses │
|
||||
│ #1's window, quits) │── transient, no writes
|
||||
└──────────────────────┘
|
||||
▲
|
||||
│ reads orca-runtime.json
|
||||
┌───────┴───────┐
|
||||
│ CLI (status) │ reports 'ready'
|
||||
└───────────────┘
|
||||
|
||||
Ownership guard site: src/main/runtime/runtime-metadata.ts
|
||||
→ clearRuntimeMetadataIfOwned(userData, ownedPid, ownedRuntimeId)
|
||||
```
|
||||
|
||||
### Data flow
|
||||
|
||||
**Happy path — single instance steady state**
|
||||
|
||||
```
|
||||
[user action] → Electron #1 main loop
|
||||
→ Electron #1 RPC server (Unix socket) handles IPC
|
||||
→ hook HTTP server answers localhost requests
|
||||
→ orca-runtime.json unchanged (only written at start)
|
||||
→ CLI reads orca-runtime.json → 'ready'
|
||||
```
|
||||
|
||||
**First-launch cold start**
|
||||
|
||||
```
|
||||
[launch] → configureDevUserDataPath(is.dev) (startup/configure-process.ts)
|
||||
→ app.requestSingleInstanceLock() → true (main/index.ts)
|
||||
→ sweepOrphanedRuntimeSockets(userData) (runtime-rpc.ts start())
|
||||
→ OrcaRuntimeRpcServer.start() binds o-<pid>-*.sock
|
||||
→ writeRuntimeMetadata({pid, runtimeId, ...}) (runtime-metadata.ts)
|
||||
→ agent-hooks server writes endpoint.env
|
||||
→ openMainWindow()
|
||||
```
|
||||
|
||||
**Second-launch rejected**
|
||||
|
||||
```
|
||||
[launch] → configureDevUserDataPath(is.dev)
|
||||
→ app.requestSingleInstanceLock() → false (lock held by #1)
|
||||
→ app.quit() (transient process exits)
|
||||
→ Electron #1 receives 'second-instance' event
|
||||
→ Electron #1 restores + focuses mainWindow
|
||||
→ no writes to orca-runtime.json or endpoint.env
|
||||
```
|
||||
|
||||
**Auto-updater handoff (two orderings, both safe)**
|
||||
|
||||
```
|
||||
Ordering X: old clears first
|
||||
old.runtimeRpc.stop() rmSync's o-<oldPid>-*.sock
|
||||
old.clearRuntimeMetadataIfOwned() — current.pid == oldPid → clear
|
||||
new.writeRuntimeMetadata() — fresh file, no conflict
|
||||
|
||||
Ordering Y: new writes first
|
||||
new.writeRuntimeMetadata() — file now points at newPid/newRuntimeId
|
||||
old.clearRuntimeMetadataIfOwned() — current.pid != oldPid → SUPPRESSED
|
||||
new continues undisturbed
|
||||
|
||||
Invariant: the ownership guard (pid + runtimeId match) is what makes
|
||||
both orderings safe. Without it, Ordering Y would erase the new
|
||||
process's just-written metadata.
|
||||
```
|
||||
|
||||
## Proposed fix
|
||||
|
||||
Three surgical changes, in decreasing order of importance:
|
||||
|
||||
### 1. Single-instance lock (primary — kills ~90% of the bug surface)
|
||||
|
||||
In `src/main/index.ts`, immediately **after** `configureDevUserDataPath(is.dev)` (line 89) and **before** any handler registration or `app.whenReady(...)`, gate the rest of the module on a new helper `acquireSingleInstanceLock()` (see "Helper extraction" in the testing section):
|
||||
|
||||
```ts
|
||||
function focusExistingWindow(): void {
|
||||
// Why: focus the existing window instead of spawning a parallel Electron
|
||||
// process that would clobber orca-runtime.json and endpoint.env.
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
// Pre-window case: the active instance is still booting and will call
|
||||
// openMainWindow() from whenReady(). No action needed here.
|
||||
}
|
||||
|
||||
if (!acquireSingleInstanceLock(app, focusExistingWindow)) {
|
||||
if (is.dev) {
|
||||
console.log(
|
||||
'Another Orca instance is already running against this userData path — focusing existing window.'
|
||||
)
|
||||
}
|
||||
app.quit()
|
||||
// Why: early-return is what prevents the service constructors and handler
|
||||
// registrations below (whenReady, before-quit, will-quit, etc.) from
|
||||
// running in a process that is already losing the lock race.
|
||||
return
|
||||
}
|
||||
// All existing app lifecycle setup below.
|
||||
```
|
||||
|
||||
**Placement rationale (from codex review):** the lock must come *after* `configureDevUserDataPath(is.dev)` so dev (`orca-dev` userData) and packaged (`orca` userData) instances lock in separate namespaces — Electron derives the lock identity from the `userData` path. Placing it before would force dev and packaged builds to serialize against each other, which is the opposite of the current dev/prod isolation contract.
|
||||
|
||||
**Dev-mode behavior:** kept unconditional. `configureDevUserDataPath` already isolates `pnpm dev` from packaged runs, so devs who want two instances already get the answer by running both a packaged build and a dev build. Allowing multi-instance against the same userData reopens the exact corruption this fix targets.
|
||||
|
||||
**Dev-mode ergonomics:** when the lock fails in dev mode, write a single `console.log('Another Orca instance is already running against this userData path — focusing existing window.')` immediately before `app.quit()`. This makes the behavior discoverable in the `pnpm dev` console so internal devs don't mistake a silent exit for a broken launcher. Packaged runs skip the log — there's no attached console.
|
||||
|
||||
### 2. Clear owned metadata on clean exit (secondary — hardens `stale_bootstrap` reporting)
|
||||
|
||||
Add a guarded clear in `src/main/runtime/runtime-metadata.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Why: clearing metadata unconditionally would race with a sibling Orca
|
||||
* process during auto-updater handoff or dev/prod overlap — see the comment
|
||||
* in runtime-rpc.ts:132-136. The ownership guard preserves the original
|
||||
* safety invariant while still letting us report 'not_running' (not
|
||||
* 'stale_bootstrap') after a clean exit.
|
||||
*/
|
||||
export function clearRuntimeMetadataIfOwned(
|
||||
userDataPath: string,
|
||||
ownedPid: number,
|
||||
ownedRuntimeId: string
|
||||
): void {
|
||||
const current = readRuntimeMetadata(userDataPath)
|
||||
if (!current) return
|
||||
if (current.pid !== ownedPid) return
|
||||
if (current.runtimeId !== ownedRuntimeId) return
|
||||
clearRuntimeMetadata(userDataPath)
|
||||
}
|
||||
```
|
||||
|
||||
In `src/main/index.ts` `will-quit` handler (line 516-548), the clear must be **awaited before Electron exits**, not fire-and-forget. The existing handler uses a two-pass `preventDefault()` pattern with `disconnectDaemon().finally(app.quit())`. The existing `void runtimeRpc.stop().catch(...)` at line 530-534 currently fires independently of that chain; Electron may exit before `runtimeRpc.stop()` resolves, and a `.then(clear)` appended to it would race the second-pass `app.quit()`. Fold both into the awaited chain instead — and do the work **inside** the `!daemonDisconnectDone` guard so `runtimeRpc.stop()` fires exactly once, on the first pass:
|
||||
|
||||
```ts
|
||||
if (!daemonDisconnectDone) {
|
||||
e.preventDefault()
|
||||
// Why: capture ownership synchronously (before any await) so the guard
|
||||
// still has the right pid/runtimeId to compare against if shutdown
|
||||
// partially clears global state. Evaluating these inside .then() would
|
||||
// let a later teardown path null them out mid-chain.
|
||||
const ownedPid = process.pid
|
||||
const ownedRuntimeId = runtime?.getRuntimeId()
|
||||
// Why: the construction AND the allSettled() must both live inside the
|
||||
// `!daemonDisconnectDone` guard. The will-quit handler re-fires after
|
||||
// app.quit() below; without this guard, the second pass would re-invoke
|
||||
// runtimeRpc.stop() (redundant rmSync on an already-removed socket) and
|
||||
// re-run the ownership-guarded clear against a metadata file that may
|
||||
// now belong to the auto-updater's replacement process.
|
||||
const rpcStopAndClear = runtimeRpc
|
||||
? runtimeRpc
|
||||
.stop()
|
||||
.then(() => {
|
||||
if (ownedRuntimeId) {
|
||||
clearRuntimeMetadataIfOwned(app.getPath('userData'), ownedPid, ownedRuntimeId)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[runtime] Failed to stop local RPC transport:', error)
|
||||
})
|
||||
: Promise.resolve()
|
||||
// Why: Promise.allSettled — we need BOTH the daemon disconnect and the RPC
|
||||
// stop + owned-metadata clear to complete before Electron exits. Using
|
||||
// allSettled (not all) preserves the existing fail-open posture: if
|
||||
// disconnectDaemon rejects, we still quit instead of hanging the app.
|
||||
Promise.allSettled([disconnectDaemon(), rpcStopAndClear]).then(() => {
|
||||
daemonDisconnectDone = true
|
||||
app.quit()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Remove the standalone `void runtimeRpc.stop().catch(...)` at line 530-534 — it is folded into `rpcStopAndClear` above.
|
||||
|
||||
**Why compare-before-clear is not over-engineering:** `autoUpdater.quitAndInstall()` quits the old process immediately and relaunches. The new process may already be starting (writing its own metadata) while the old `will-quit` is running. An unconditional clear would delete the new process's fresh metadata. Codex confirmed this is the correct posture.
|
||||
|
||||
### 3. Orphaned-socket sweep on startup (tertiary — hygiene)
|
||||
|
||||
In `OrcaRuntimeRpcServer.start()` (`src/main/runtime/runtime-rpc.ts:51`), before creating the new listener, enumerate `<userData>/o-*.sock` and remove any whose pid component is dead:
|
||||
|
||||
```ts
|
||||
// Why: processes killed by SIGKILL / OOM-kill skip stop() and leave behind
|
||||
// o-<pid>-*.sock files. Sweep dead-pid sockets on startup so the userData
|
||||
// directory does not accumulate orphans over the app's lifetime.
|
||||
function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(userDataPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const match = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/.exec(entry)
|
||||
if (!match) continue
|
||||
const pid = Number(match[1])
|
||||
if (!Number.isFinite(pid) || pid === ownPid) continue
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
// Pid is alive — leave its socket alone. Another Orca instance owns it.
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') {
|
||||
try {
|
||||
rmSync(join(userDataPath, entry), { force: true })
|
||||
} catch {
|
||||
// Best-effort sweep; a permission error is fine to ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Constraints:** only sweep sockets with the exact `o-<digits>-<base64url-ish>.sock` shape; skip anything else. Never touch the current process's own pid even though the stricter `start()` flow already rmSync's it.
|
||||
|
||||
**Windows note:** this sweep is POSIX-only by construction — on Windows the transport is a named pipe (`\\.\pipe\orca-<pid>-<suffix>`) and named pipes don't leave filesystem entries in `userData`. No sweep needed.
|
||||
|
||||
**Socket-name invariant:** the sweep regex `^o-(\d+)-[A-Za-z0-9_-]+\.sock$` must stay in lockstep with `createRuntimeTransportMetadata()` in `runtime-rpc.ts`, which emits `o-${pid}-${endpointSuffix}.sock`. The cheap guard — and what this doc recommends — is a unit test that constructs a transport name via `createRuntimeTransportMetadata()` and asserts the sweep regex matches it; any future change to the socket-name shape trips the test. Longer-term, if the transport name grows more fields, the preferred answer is to export a shared constant or factory from `runtime-rpc.ts` that both the creator and the sweep regex consume, so the two can never drift by construction. Deferred here to keep the diff surgical.
|
||||
|
||||
## Deliberately not in scope
|
||||
|
||||
- **No CLI-side reader fallback.** Scanning for any live `o-*.sock` in `getCliStatus()` would require a per-socket sidecar metadata file (authToken, runtimeId) since today only one shared `orca-runtime.json` exists. That is a multi-instance-by-design change, not a bug fix. The fix above assumes the intended model is one Orca per userData.
|
||||
- **No per-instance userData.** The env-var override `ORCA_USER_DATA_PATH` at `src/cli/runtime/metadata.js` already exists for power users who genuinely want parallel runtimes. No change.
|
||||
- **No unlink of `endpoint.env` on stop.** The deferred-cleanup rationale at `src/main/agent-hooks/server.ts:1230-1237` (TOCTOU with a concurrent writer) is still correct under the single-instance lock: auto-update handoff still briefly has two processes sharing the file. Leaving the file unchanged on quit matches the existing fail-open policy and no user symptom motivates changing it.
|
||||
|
||||
## Testing strategy
|
||||
|
||||
### Unit tests (`src/main/runtime/runtime-metadata.test.ts`, extend)
|
||||
|
||||
- `clearRuntimeMetadataIfOwned` with matching `{pid, runtimeId}` → file removed.
|
||||
- `clearRuntimeMetadataIfOwned` with mismatched pid → file retained.
|
||||
- `clearRuntimeMetadataIfOwned` with mismatched runtimeId → file retained (simulates another instance having overwritten while we were still alive).
|
||||
- `clearRuntimeMetadataIfOwned` with no file → no-op, no throw.
|
||||
|
||||
### Unit tests (new `src/main/runtime/runtime-socket-sweep.test.ts`)
|
||||
|
||||
Seed a temp userData directory with four entries so the three retention branches are distinct:
|
||||
|
||||
- `o-1-aaaa.sock` → own-pid-skipped branch (test passes `ownPid: 1`, so this is skipped via the `pid === ownPid` early-exit).
|
||||
- `o-<process.pid>-bbbb.sock` → alive-but-not-own branch (`process.kill(pid, 0)` succeeds without `ESRCH` → retained).
|
||||
- `o-99999999-cccc.sock` → dead-pid branch (`process.kill(pid, 0)` throws `ESRCH` → swept).
|
||||
- `foo.sock` → non-matching-shape branch (regex miss → retained untouched).
|
||||
|
||||
Run sweep with `ownPid = 1` and assert: the `o-99999999-*.sock` file is gone; the other three remain. Using a synthetic `ownPid` (init pid `1`, effectively always alive on POSIX and never the test runner's own pid) means each of the three retained-entries covers a *distinct* code path, instead of collapsing "own-pid-skipped" and "alive-non-own-pid-retained" into the same observation.
|
||||
|
||||
### Helper extraction for the single-instance lock
|
||||
|
||||
Extract the lock acquisition into `src/main/startup/single-instance-lock.ts`:
|
||||
|
||||
```ts
|
||||
export function acquireSingleInstanceLock(
|
||||
app: Electron.App,
|
||||
onSecondInstance: () => void
|
||||
): boolean {
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
return false
|
||||
}
|
||||
app.on('second-instance', onSecondInstance)
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
`src/main/index.ts` wires it up inline after `configureDevUserDataPath(is.dev)`. Because the module's top-level cannot `return`, gate the file-writing init (`initDataPath` / `initStatsPath` / `initClaudeUsagePath` / `initCodexUsagePath` / `enableMainProcessGpuFeatures` / `installDevParentWatchdog` / `installDevParentDisconnectQuit`) behind an `if (hasSingleInstanceLock)` block and let the lifecycle handler registrations fire unconditionally — `app.quit()` prevents `whenReady` from ever dispatching, so `runtime` / `runtimeRpc` / `store` / `stats` stay `null` and every shutdown handler short-circuits via optional chaining:
|
||||
|
||||
```ts
|
||||
const hasSingleInstanceLock = acquireSingleInstanceLock(app, focusExistingWindow)
|
||||
if (!hasSingleInstanceLock) {
|
||||
if (is.dev) {
|
||||
console.log(
|
||||
'[single-instance] Another Orca instance is already running against this userData path — focusing existing window.'
|
||||
)
|
||||
}
|
||||
app.quit()
|
||||
}
|
||||
|
||||
if (hasSingleInstanceLock) {
|
||||
installDevParentDisconnectQuit(is.dev)
|
||||
installDevParentWatchdog(is.dev)
|
||||
initDataPath()
|
||||
initStatsPath()
|
||||
initClaudeUsagePath()
|
||||
initCodexUsagePath()
|
||||
enableMainProcessGpuFeatures()
|
||||
}
|
||||
```
|
||||
|
||||
### Unit test of the helper (`src/main/startup/single-instance-lock.test.ts`)
|
||||
|
||||
With a fake `app` (stub `requestSingleInstanceLock()` + `on()`):
|
||||
|
||||
- When `requestSingleInstanceLock()` returns `false` → helper returns `false` and does NOT register `'second-instance'`.
|
||||
- When it returns `true` → helper returns `true` and registers exactly one `'second-instance'` listener that invokes the callback.
|
||||
|
||||
### Integration assertion (narrow)
|
||||
|
||||
Do NOT try to assert "downstream service constructors were not invoked" by re-importing `src/main/index.ts`. Module-level side effects at lines 67-101 (dev watchdog, shell path hydration, userData path configuration) run before the lock check and would fire regardless, either failing the test or requiring heavy mocking.
|
||||
|
||||
Instead, the integration surface the fix cares about is already covered by: (a) the helper unit test above, and (b) the manual repro below. If deeper coverage is wanted later, the right move is to refactor `src/main/index.ts` so the `whenReady`-guarded setup is exported as a testable function — that is a separate refactor and deliberately out of scope for this fix.
|
||||
|
||||
### Manual repro (matches the issue's steps)
|
||||
|
||||
1. Launch packaged Orca. `cp ~/.config/orca/orca-runtime.json /tmp/before.json`.
|
||||
2. Launch the AppImage / `.app` a second time. Verify the existing window receives focus (no new window appears) and `stat` shows `orca-runtime.json` mtime is unchanged.
|
||||
3. Quit Orca. Verify `~/.config/orca/orca-runtime.json` is gone and `orca status --json` reports `runtime.state = 'not_running'` (not `'stale_bootstrap'`).
|
||||
4. Kill a running Orca with `SIGKILL`. Verify that the next Orca launch removes the orphaned `o-<killed-pid>-*.sock` during `OrcaRuntimeRpcServer.start()`.
|
||||
|
||||
### Regression watch — auto-updater
|
||||
|
||||
`autoUpdater.quitAndInstall()` in `src/main/updater.ts:194` triggers the relaunch flow. The handoff is short (~1s) but non-atomic, so the design relies on the ownership guard to keep both interleavings safe:
|
||||
|
||||
- **Socket is removed before the new process binds.** The old process's `runtimeRpc.stop()` `rmSync`'s `o-<oldPid>-*.sock` as part of teardown. The new process then binds its own `o-<newPid>-*.sock`. The brief window of inconsistency is "metadata absent" + "no socket", never "metadata points at a live-but-wrong pid with a still-mounted old socket."
|
||||
- **New writes before old clears (Ordering Y).** If the new process calls `writeRuntimeMetadata()` before the old process's `clearRuntimeMetadataIfOwned()` runs, the guard sees `current.pid !== ownedPid` (and `current.runtimeId !== ownedRuntimeId`) and suppresses the clear. The new metadata survives.
|
||||
- **Old clears before new writes (Ordering X).** The old process finds its own pid + runtimeId in the file, clears it, and exits. The new process then writes its fresh metadata onto an empty slot. Same end state.
|
||||
- **CLI behavior during the ~1s window.** `orca status` transitions `ready` → `not_running` (clean, not `stale_bootstrap`) → `starting` → `ready`. It never reports a wrong-pid reading, because the only two observable file states are "file for old pid" (pre-teardown) and "file for new pid" (post-write); the guard prevents a mixed state.
|
||||
|
||||
A future regression report that catches the transient `not_running` during an auto-update should be recognized as intended behavior.
|
||||
|
||||
### Cross-platform coverage
|
||||
|
||||
- **macOS / Linux / Windows:** single-instance lock path is identical (Electron handles the OS-level plumbing).
|
||||
- **Windows:** named-pipe transport means no socket-sweep work; assert the sweep early-returns on `platform === 'win32'` (or simply does nothing because no matching entries exist).
|
||||
- **Dev mode:** lock acquired against `orca-dev` userData; asserted by running `pnpm dev` twice and seeing the second exit immediately.
|
||||
|
||||
## Confidence
|
||||
|
||||
High. The diagnosis matches the reported symptom and filesystem evidence one-for-one; the fix adds only calls that Electron and the existing metadata module already expose. Second opinion from codex flagged the `configureDevUserDataPath` ordering constraint and the `clearRuntimeMetadataIfOwned` ownership guard, both incorporated above.
|
||||
|
||||
Concretely, the fix closes the reported data-integrity scenarios: double-click relaunch, `gtk-launch` relaunch, clean-quit of a second instance while the first stays open, and orphaned `o-<pid>-*.sock` files left by SIGKILL / OOM-kill. It deliberately leaves two narrow scenarios open: (1) the auto-updater handoff, where `orca status` may transiently report `not_running` for ~1s between the old process's clear and the new process's write — covered in the regression-watch section above; and (2) deliberate multi-instance launches via `ORCA_USER_DATA_PATH` / `ORCA_DEV_USER_DATA_PATH`, which remain an explicit power-user escape hatch with isolated userData per instance and are not in scope for this fix.
|
||||
@@ -1,628 +0,0 @@
|
||||
# Fix per-session PTY fd leak in the current daemon
|
||||
|
||||
## Status: resolved (2026-05-01)
|
||||
|
||||
**Native-side root cause confirmed and patched.** E2E validation under both Node and Electron ABIs: 50 spawn/kill cycles, ptmx count stays at baseline (0). Before the patch, the same harness reproduced a 1-fd-per-spawn linear leak (5 → 55 ptmx fds over 50 cycles).
|
||||
|
||||
## Root cause (actual)
|
||||
|
||||
The leak is a native-side off-by-one in `node-pty@1.1.0`'s `pty_posix_spawn` cleanup loop on macOS. In `src/unix/pty.cc`:
|
||||
|
||||
- **Allocation** (lines 697-701) walks `low_fds[0..2]`, allocating via `posix_openpt(O_RDWR)`. The loop `break`s at the first `low_fds[count] >= STDERR_FILENO`, so `count` holds the index of the last allocated slot (0, 1, or 2). These are decoy `/dev/ptmx` handles used as a workaround for a macOS pty race condition that ensures the real master fd lands above `STDERR_FILENO`.
|
||||
- **Cleanup** (lines 781-783, buggy):
|
||||
```c
|
||||
for (; count > 0; count--) {
|
||||
close(low_fds[count]);
|
||||
}
|
||||
```
|
||||
When the typical case triggers (`break` at `count=0`, i.e. the very first `posix_openpt` returned an fd ≥ 2), the loop body never executes — `low_fds[0]` is never closed. Every spawn leaks one ptmx handle.
|
||||
|
||||
Upstream fixed this in commit [`af053f2`](https://github.com/microsoft/node-pty/commit/af053f2) (PR #882, 2026-01-28), but version 1.1.0 (pinned here) predates that fix.
|
||||
|
||||
**Applied fix:** minimal 3-line backport in `config/patches/node-pty@1.1.0.patch`:
|
||||
|
||||
```c
|
||||
- for (; count > 0; count--) {
|
||||
- close(low_fds[count]);
|
||||
+ for (size_t i = 0; i <= count && i < 3; i++) {
|
||||
+ close(low_fds[i]);
|
||||
}
|
||||
```
|
||||
|
||||
The `i < 3` bound is an over-cautious defense against the pathological case where all three `posix_openpt` calls returned fds < `STDERR_FILENO` (loop exhausts to `count=3`, which would otherwise read `low_fds[3]` — UB in upstream's fix too, though unreachable in practice).
|
||||
|
||||
## Why the JS-side `destroy()` discipline (below) is still correct
|
||||
|
||||
The native patch alone closes the leak. But the JS-side changes landed in `pty-subprocess.ts`, `session.ts`, `terminal-host.ts`, `local-pty-provider.ts`, and `relay/pty-handler.ts` remain load-bearing for separate reasons:
|
||||
|
||||
1. **SIGHUP-to-recycled-pid hazard.** `UnixTerminal.destroy()` registers `_socket.once('close', () => this.kill('SIGHUP'))`. After the child is reaped, its pid can be recycled to an unrelated user process (Chrome tab, editor, etc.) before the socket close event fires. Neutralizing `proc.kill` on POSIX before `destroy()` prevents delivering SIGHUP to a stranger.
|
||||
2. **`forceKill`/`signal` guard against recycled pid.** `process.kill(proc.pid, …)` after `onExit` targets a potentially-recycled pid. The internal `dead` guard converts these into no-ops.
|
||||
3. **Daemon shutdown determinism.** `TerminalHost.dispose()` → `Session.forceKillAndDisposeSubprocess()` reaps stubborn children via SIGKILL and releases the master fd synchronously, bypassing the 5s `KILL_TIMEOUT_MS` fallback.
|
||||
4. **Belt-and-suspenders fd release.** Calling `destroy()` on every teardown path remains the contract that will keep holding if the pinned node-pty is ever replaced or upgraded to a build without the bug.
|
||||
|
||||
## Original problem statement (for historical context)
|
||||
|
||||
## Problem
|
||||
|
||||
The current `daemon-v4` is leaking PTY master file descriptors (`ptmx`) at a rate roughly 20× the live session count. In a real reported case, the live v4 daemon was holding **269 `ptmx` fds for only 13 active terminals** — every closed terminal should have returned its `ptmx` to the OS, but the daemon keeps accumulating them until it hits macOS's `kern.tty.ptmx_max=511` cap and every new terminal (Orca or otherwise) fails with "cannot allocate any more pty devices."
|
||||
|
||||
This is a distinct bug from the orphaned-daemon issue handled in [auto-kill-old-daemons.md](./auto-kill-old-daemons.md):
|
||||
|
||||
| Bug | Fd leak unit | Lifetime | Fix surface |
|
||||
|---|---|---|---|
|
||||
| Orphaned daemons | Whole daemon process | Across app upgrades | `daemon-init.ts`, `daemon-pty-router.ts` |
|
||||
| **This doc** | Per-PTY `ptmx` | Within any node-pty-hosting process | `pty-subprocess.ts` (daemon), `local-pty-provider.ts` (legacy), `relay/pty-handler.ts` (SSH) + wiring |
|
||||
|
||||
Both bugs compound each other (stale daemons × leaky sessions), but either one is sufficient to exhaust the cap in a few days of normal use. Fixing orphan daemons alone would only slow down the bleed — the PTY wrappers themselves must stop leaking.
|
||||
|
||||
The same missing `destroy()` call exists in all three node-pty-hosting code paths (daemon, legacy local provider, SSH relay), so the fix is scoped to all three.
|
||||
|
||||
### Root cause
|
||||
|
||||
The master ptmx fd is owned by a `tty.ReadStream` that node-pty stores on the `UnixTerminal` instance as `_socket` (see `node_modules/node-pty/lib/unixTerminal.js`). That socket wraps the fd via libuv. The fd only gets closed when the socket is destroyed, and the socket is only destroyed by one of:
|
||||
|
||||
1. `UnixTerminal.destroy()` — calls `_close()`, then `_socket.destroy()`, then `_writeStream.dispose()`.
|
||||
2. The socket's own `'close'` / `'error'` event firing, which hits the `self._socket.on('close', ...)` wiring inside node-pty's constructor.
|
||||
|
||||
None of our current teardown paths reliably trigger either. Specifically:
|
||||
|
||||
1. **`forceKill` via raw `process.kill(pid, 'SIGKILL')`** (`pty-subprocess.ts:159-168`) kills the child but does nothing to the libuv socket on the parent side. The slave fd closing in the kernel does not, by itself, cause the parent's read-side socket to emit `'close'` on all platforms / node versions — and even when it does, it races GC.
|
||||
2. **`dead = true` after a thrown native error** (`pty-subprocess.ts:127-157`) leaves the `_socket` fully alive. We just stop touching `proc`; the read stream keeps the fd.
|
||||
3. **Session `dispose()` / `handleSubprocessExit`** (`session.ts:189-228`, `session.ts:250-271`) never call anything that reaches `_socket.destroy()`. They kill the child and move on.
|
||||
|
||||
So the fd leaks until the JS wrapper is GC'd and libuv finalizes the stream — which may be much later, or never, within the daemon's lifetime. Evidence from the reported case is consistent with this: 269 ptmx − 13 live ≈ 256 orphaned fds from terminals that were opened and closed during the daemon's lifetime, which matches a few days of create/kill churn at ~50-100 terminal ops/day.
|
||||
|
||||
node-pty already exposes the fix: `UnixTerminal.prototype.destroy` (unixTerminal.js:219) and `WindowsTerminal.prototype.destroy` (windowsTerminal.js:141). We just never call it.
|
||||
|
||||
## Goals
|
||||
|
||||
- After a PTY terminates (by any path — natural exit, kill, force-kill, native throw, disposal), the master fd is released to the OS on the same tick as teardown.
|
||||
- The fix covers every code path in the repo that spawns `node-pty`: the daemon, the legacy local provider, and the SSH relay.
|
||||
- Zero regressions to the `dead` flag pattern (`pty-subprocess.ts:119-122`) that prevents `Napi::Error` from killing the daemon.
|
||||
- No renderer-visible change. Public IPC / RPC contracts are untouched.
|
||||
- Measurable: a repeatable test harness spawns N PTYs, terminates them via each exit path, and asserts the fd count returns to baseline.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Fixing the orphaned-daemon issue. Handled in [auto-kill-old-daemons.md](./auto-kill-old-daemons.md).
|
||||
- Killing daemon-side sessions when a worktree is deleted. Separate follow-up.
|
||||
- Upgrading or replacing `node-pty`. We are calling node-pty's existing `destroy()` — no library change needed.
|
||||
- Adding a generic "fd leak monitor" watchdog. Observability belongs in a separate effort (see Related work).
|
||||
|
||||
## Design decisions
|
||||
|
||||
### Call `node-pty`'s `destroy()` on every teardown path
|
||||
|
||||
The fix is exactly that. Each provider already wraps the raw `IPty` in its own small handle/struct. Add a single `dispose()` entry point on those wrappers whose body does:
|
||||
|
||||
```ts
|
||||
try {
|
||||
;(proc as unknown as { destroy?: () => void }).destroy?.()
|
||||
} catch {
|
||||
/* swallow — already torn down, or native-side error we can't recover from */
|
||||
}
|
||||
```
|
||||
|
||||
That is the primary mechanism. `destroy()` is what drives `_close()` → `_socket.destroy()` → `_writeStream.dispose()` inside node-pty, which is what releases the master fd. Everything else the wrapper needs (idempotency, callback nulling, SIGKILL fallback for unresponsive children) is a local concern layered around that single call.
|
||||
|
||||
### Why not reinvent `destroy()`
|
||||
|
||||
An earlier draft of this design proposed a hand-rolled body: null callbacks + raw SIGKILL + `proc.kill()` in try/catch. That was wrong. None of those steps touch `_socket`, which is the thing holding the fd. The only reliable trigger is the close path node-pty already ships — `UnixTerminal.prototype.destroy` on POSIX, `WindowsTerminal.prototype.destroy` on ConPTY. Duplicating that logic in our wrappers would (a) not work on POSIX anyway because `_socket` is private and (b) drift from upstream on any future node-pty update. Forward to `destroy()`, layer idempotency and error-swallowing on top, stop.
|
||||
|
||||
### Shared handle contract
|
||||
|
||||
Each provider exposes a dispose entry point with the same semantics: idempotent, synchronous, throws never. The daemon already has a `SubprocessHandle` type that is the right place to hang it; the local provider and relay each have equivalent internal records (`ptyProcesses` / `ManagedPty`) and get a small helper function that wraps the same `destroy()` call plus their provider-specific bookkeeping (listener disposal, map cleanup).
|
||||
|
||||
```ts
|
||||
// src/main/daemon/session.ts
|
||||
export type SubprocessHandle = {
|
||||
pid: number
|
||||
write(data: string): void
|
||||
resize(cols: number, rows: number): void
|
||||
kill(): void
|
||||
forceKill(): void
|
||||
signal(sig: string): void
|
||||
onData(cb: (data: string) => void): void
|
||||
onExit(cb: (code: number) => void): void
|
||||
/** Release the native PTY handle via node-pty's own destroy() path.
|
||||
* Idempotent. Safe to call after exit. Called by Session on every teardown
|
||||
* path (natural exit, kill, force-kill, native throw, session dispose). */
|
||||
dispose(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Daemon: `pty-subprocess.ts`
|
||||
|
||||
Track a `disposed` flag. `dispose()`:
|
||||
|
||||
1. If already disposed, return.
|
||||
2. Mark `disposed = true` and `dead = true`.
|
||||
3. Null the JS-side `onDataCb` / `onExitCb` references so the wrapper stops fanning out anything after destroy.
|
||||
4. **On POSIX only, neutralize `proc.kill` on this instance.** Replace it with a no-op before step 5. This closes the pid-recycle SIGHUP hazard described below. Windows is exempt because `WindowsTerminal.destroy` *is* a call to `kill()` — neutralizing it turns destroy() into a no-op and leaks the ConPTY agent.
|
||||
5. Call `proc.destroy()` inside try/catch. On Unix this runs `_close()` → `_socket.destroy()` → `_writeStream.dispose()`; on Windows it defers to the ConPTY agent's close via `_deferNoArgs(_this.kill())` (`windowsTerminal.js:141-146`).
|
||||
|
||||
```ts
|
||||
dispose(): void {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
dead = true
|
||||
onDataCb = null
|
||||
onExitCb = null
|
||||
// Why: node-pty's UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`
|
||||
// (unixTerminal.js:219-229). On the dispose-while-alive path (e.g. daemon shutdown
|
||||
// of an active session), SIGTERM has already been sent. The socket close fires
|
||||
// asynchronously; by then the child may have exited and its pid been recycled
|
||||
// to an unrelated process. Without this neutralization, SIGHUP can be delivered
|
||||
// to a Chrome tab, editor, or other user process — silent cross-app corruption.
|
||||
// `_socket.destroy()` still releases the fd; only the dangerous SIGHUP is removed.
|
||||
//
|
||||
// Platform guard: WindowsTerminal.destroy() implements the ConPTY close by
|
||||
// CALLING `this.kill()` via `_deferNoArgs` (windowsTerminal.js:141-146). If we
|
||||
// neutralize `kill` on Windows, destroy() becomes a no-op and the ConPTY
|
||||
// agent leaks. The SIGHUP hazard is POSIX-only, so the neutralization is too.
|
||||
if (process.platform !== 'win32') {
|
||||
;(proc as unknown as { kill?: (sig?: string) => void }).kill = () => {}
|
||||
}
|
||||
try {
|
||||
;(proc as unknown as { destroy?: () => void }).destroy?.()
|
||||
} catch {
|
||||
/* swallow — already torn down, or native-side error we can't recover from */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`kill` neutralization lives on this `proc` instance only — the wrapper's exposed `kill()` / `forceKill()` entry points call `process.kill(proc.pid, sig)` directly (`pty-subprocess.ts:149-175`), which is unaffected. Callers that want to send SIGTERM/SIGKILL still can; what they can't do after dispose is have node-pty's internal close listener deliver an unexpected SIGHUP against a stale pid.
|
||||
|
||||
No extra SIGKILL is needed from `dispose()`: `forceKill` remains available as a separate entry point for the "we need the child gone now" case. Callers that want both (Session on timeout) call `forceKill()` then `dispose()`.
|
||||
|
||||
### Daemon: Session / TerminalHost wiring
|
||||
|
||||
`Session.dispose()` (`session.ts:189-228`) and `Session.handleSubprocessExit()` (`session.ts:250-271`) each call `this.subprocess.dispose()`:
|
||||
|
||||
- `handleSubprocessExit`: call it immediately after updating `_state = 'exited'` and before fan-out to `attachedClients`. try/catch around the call — a throwing dispose must not prevent exit-code delivery.
|
||||
- `dispose()`: call it at the end, after `emulator.dispose()` and after the attached-client fanout.
|
||||
- `forceDispose()` (`session.ts:319-347`): same as `dispose()`.
|
||||
|
||||
`subprocess.dispose()` is idempotent because `handleSubprocessExit` and the outer `dispose()` can both fire for the same session (manual kill → child exits → handleSubprocessExit runs → later Session.dispose runs).
|
||||
|
||||
`TerminalHost.dispose()` (`terminal-host.ts:215-240`) already kills every session. Replace the `session.kill()` call in the shutdown loop with a new `session.forceKillAndDisposeSubprocess()` method, so daemon shutdown reaps stubborn children (SIGKILL) AND releases the PTY master fd synchronously — without depending on the 5s `KILL_TIMEOUT_MS` eventually calling `forceDispose`:
|
||||
|
||||
```ts
|
||||
for (const [, session] of this.sessions) {
|
||||
session.detachAllClients()
|
||||
session.forceKillAndDisposeSubprocess() // synchronous; bypasses the 5s timer
|
||||
}
|
||||
// Preserve existing post-loop cleanup: sessions.clear(), killedTombstones.clear(),
|
||||
// and any cleanup callbacks fire unchanged after the loop. Only the per-session
|
||||
// kill() → forceKillAndDisposeSubprocess() substitution is new.
|
||||
```
|
||||
|
||||
**Why `forceKillAndDisposeSubprocess` instead of `kill() + disposeSubprocess()`.** Today's shutdown path is `session.kill()` (SIGTERM, start 5s timer) → if the child ignores SIGTERM, `forceDispose()` eventually fires and sends SIGKILL. That 5s fallback is load-bearing: shells with traps, hung compilers, and long-running processes with signal handlers routinely ignore SIGTERM. The earlier draft dropped `killTimer` during disposeSubprocess, which would close the fd but leave the child orphaned at the OS level — worse than today's behavior. `forceKillAndDisposeSubprocess` explicitly sends SIGKILL (via `subprocess.forceKill()`) before releasing the fd, so stubborn children are still reaped synchronously:
|
||||
|
||||
```ts
|
||||
// Public: orderly-shutdown path. Force-kills the child (SIGKILL is not
|
||||
// ignorable), then releases the PTY master fd synchronously. Used only by
|
||||
// TerminalHost.dispose() — renderer reconnects cold after daemon exit.
|
||||
forceKillAndDisposeSubprocess(): void {
|
||||
// Why: forceKill before the subprocess dispose. disposeSubprocess below
|
||||
// neutralizes node-pty's kill() on POSIX, which is what the internal SIGHUP
|
||||
// close listener would call. forceKill uses process.kill(pid, 'SIGKILL')
|
||||
// directly (pty-subprocess.ts:159-168) — unaffected by the neutralization,
|
||||
// because it does not go through proc.kill. SIGKILL is not ignorable; any
|
||||
// child that would have survived the 5s timer is reaped immediately.
|
||||
try {
|
||||
this.subprocess.forceKill()
|
||||
} catch {
|
||||
/* swallow — child may already be gone */
|
||||
}
|
||||
this.#teardownSubprocess()
|
||||
}
|
||||
```
|
||||
|
||||
`disposeSubprocess()` is a tight helper — it flips the session to the terminal state, cancels any pending timers, and forwards to the subprocess dispose. It deliberately does not fan out `onExit` to attached clients, because `TerminalHost.dispose()` is the orderly-shutdown path and the renderer will reconnect cold.
|
||||
|
||||
**Shared `#teardown` helper.** Both `Session.dispose()` and `Session.disposeSubprocess()` mutate the same state (`_disposed`, `_state`, `killTimer`, `shellReadyTimer`) and forward to `this.subprocess.dispose()`. Route them through a single private helper `#teardownSubprocess()` so the state transition is defined exactly once — two dispose paths that drift on which flag gets flipped would be a merge hazard (a future change to one that forgets the other silently drops exit events):
|
||||
|
||||
```ts
|
||||
// Session — shared private helper, called by both public paths.
|
||||
#teardownSubprocess(): void {
|
||||
if (this._disposed) return
|
||||
this._disposed = true
|
||||
// Note: `_state = 'exited'` is NOT set here — the outer caller (Session.dispose,
|
||||
// Session.forceDispose) is responsible for the state transition AFTER capturing
|
||||
// any invariants that depend on the pre-flip value. See the `wasTerminating`
|
||||
// capture in Session.dispose for the load-bearing example.
|
||||
if (this.killTimer) { clearTimeout(this.killTimer); this.killTimer = null }
|
||||
if (this.shellReadyTimer) { clearTimeout(this.shellReadyTimer); this.shellReadyTimer = null }
|
||||
try {
|
||||
this.subprocess.dispose()
|
||||
} catch (err) {
|
||||
// Why: dispose() is documented never to throw, but if it does we must not
|
||||
// prevent callers from completing their own cleanup (fanout, map removal).
|
||||
console.warn('[Session] subprocess.dispose() threw:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Public: orderly-shutdown path. Does NOT broadcast onExit to clients; the
|
||||
// renderer reconnects cold after TerminalHost.dispose(). Sets _state = 'exited'
|
||||
// after teardown because this path has no pre-flip invariants to preserve.
|
||||
disposeSubprocess(): void {
|
||||
this.#teardownSubprocess()
|
||||
this._state = 'exited'
|
||||
}
|
||||
|
||||
// Public: existing Session.dispose() — MUST capture `wasTerminating` BEFORE
|
||||
// calling #teardownSubprocess, because the check depends on `_state !== 'exited'`
|
||||
// being the PRE-flip value. Forgetting this check would orphan processes that
|
||||
// ignored SIGTERM (the `_isTerminating && _state !== 'exited'` guard at
|
||||
// session.ts:200 is the only path that calls `subprocess.forceKill()` in the
|
||||
// dispose-while-terminating case).
|
||||
dispose(): void {
|
||||
// Why: captured BEFORE the `_state = 'exited'` flip at line 230. This check
|
||||
// guards the "dispose while kill() was already in flight" case — if true,
|
||||
// the child hasn't reaped yet and we need to forceKill it here (the 5s
|
||||
// killTimer is also about to be cleared by #teardownSubprocess). Matches
|
||||
// the existing invariant at session.ts:200. Do not move this line below
|
||||
// #teardownSubprocess or the `_state = 'exited'` assignment.
|
||||
const wasTerminating = this._isTerminating && this._state !== 'exited'
|
||||
const clientsToNotify = wasTerminating ? this.attachedClients.slice() : []
|
||||
|
||||
if (wasTerminating) {
|
||||
// Existing forceDispose semantics — child hasn't reaped yet, force it now.
|
||||
try { this.subprocess.forceKill() } catch { /* already dead */ }
|
||||
this._exitCode = -1
|
||||
this._isTerminating = false
|
||||
}
|
||||
|
||||
this.#teardownSubprocess()
|
||||
this._state = 'exited'
|
||||
|
||||
this.attachedClients = []
|
||||
this.preReadyStdinQueue = []
|
||||
this.postReadyFlushGate.clear()
|
||||
this.emulator.dispose()
|
||||
|
||||
for (const client of clientsToNotify) {
|
||||
client.onExit(-1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Single source of truth for the state transition. If we ever need to add a new piece of tear-down state, one edit keeps both paths in sync. The asymmetry (`_state = 'exited'` lives in the public methods, not the helper) is deliberate: `dispose()` has a pre-flip invariant (`wasTerminating`) to preserve, `disposeSubprocess()` doesn't. Forcing both through a single helper that flipped `_state` would reintroduce the ordering bug.
|
||||
|
||||
**`forceDispose()` must also route through the helper.** `forceDispose` fires from the 5s `killTimer` when a SIGTERM'd child refused to exit (`session.ts:319-347`). This is the exact kill-timeout path this fd-leak fix targets — if `forceDispose` doesn't call `subprocess.dispose()`, the ptmx fd leaks on every force-kill. The current body flips `_disposed = true` directly at `session.ts:324`, which would short-circuit a later `Session.dispose()` call from `TerminalHost` tombstone cleanup at `#teardownSubprocess`'s `if (this._disposed) return` guard, silently skipping `subprocess.dispose()`.
|
||||
|
||||
```ts
|
||||
// Private: fires from the 5s killTimer when SIGTERM was ignored.
|
||||
// `_state === 'exited'` already short-circuits above; below that guard the
|
||||
// session is still running, so the fd is still open. MUST release it.
|
||||
private forceDispose(): void {
|
||||
if (this._state === 'exited') {
|
||||
return
|
||||
}
|
||||
// Why: unlike dispose(), forceDispose has no pre-flip invariants to capture.
|
||||
// The caller (killTimer) has no attached-client fanout obligation beyond
|
||||
// what the helper + the existing client loop below cover. Route the
|
||||
// subprocess tear-down through the shared helper so `proc.destroy()` runs
|
||||
// exactly once and `_disposed` is flipped in the same transition.
|
||||
try { this.subprocess.forceKill() } catch { /* already dead */ }
|
||||
this._exitCode = -1
|
||||
this._isTerminating = false
|
||||
|
||||
this.#teardownSubprocess() // ← sets _disposed = true, calls subprocess.dispose()
|
||||
this._state = 'exited'
|
||||
|
||||
const clients = this.attachedClients
|
||||
this.attachedClients = []
|
||||
this.preReadyStdinQueue = []
|
||||
this.postReadyFlushGate.clear()
|
||||
this.emulator.dispose()
|
||||
|
||||
for (const client of clients) {
|
||||
client.onExit(-1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Order matters. `forceKill()` must run BEFORE `#teardownSubprocess()` — the helper's `subprocess.dispose()` neutralizes `proc.kill` on POSIX, but `subprocess.forceKill()` uses `process.kill(proc.pid, 'SIGKILL')` directly and is unaffected by that neutralization. If we reorder, nothing changes functionally, but the explicit order documents the invariant.
|
||||
|
||||
After `session.forceKillAndDisposeSubprocess()`, the 5s `killTimer` started by any prior `kill()` is cleared, so `forceDispose` cannot fire against an already-disposed subprocess. Even if the timer somehow survived the clear, `_state` is now `'exited'` and `forceDispose`'s early-return guard (`session.ts:320`) would short-circuit it.
|
||||
|
||||
### LocalPtyProvider
|
||||
|
||||
`local-pty-provider.ts` stores each spawned `IPty` in a module-level `ptyProcesses` map and has two teardown sites: `shutdown(id)` (single PTY) and `killAll()` / `safeKillAndClean()` (bulk). Both currently call `proc.kill()` and delete the map entry.
|
||||
|
||||
Change: add a `destroyPtyProcess(proc)` helper. Same shape as `disposeManagedPty` in the relay: on POSIX, neutralize `proc.kill` before calling `destroy()` to close the same SIGHUP-to-recycled-pid hazard; on Windows, skip the neutralization because `WindowsTerminal.destroy` calls `kill()` internally. Invoke the helper at the end of `safeKillAndClean` (after listener disposal, after `proc.kill()`), at the end of `shutdown` (same spot), and in the natural-exit `onExit` path after `clearPtyState(id)`.
|
||||
|
||||
```ts
|
||||
function destroyPtyProcess(proc: IPty): void {
|
||||
// Why: same SIGHUP-to-recycled-pid hazard as the daemon and relay paths.
|
||||
// UnixTerminal.destroy() registers `_socket.once('close', () => this.kill('SIGHUP'))`.
|
||||
// LocalPtyProvider's own kill() has already fired; if the close listener
|
||||
// delivers a trailing SIGHUP after pid recycling, it lands on a random user
|
||||
// process. Local machines recycle pids fast — regression risk is highest here.
|
||||
// Windows exempt: WindowsTerminal.destroy IS a kill() call via _deferNoArgs.
|
||||
if (process.platform !== 'win32') {
|
||||
;(proc as unknown as { kill?: (sig?: string) => void }).kill = () => {}
|
||||
}
|
||||
try {
|
||||
;(proc as unknown as { destroy?: () => void }).destroy?.()
|
||||
} catch {
|
||||
/* swallow — already torn down */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The natural-exit `onExit` invocation is load-bearing: without it, a shell that exits cleanly (the common case) never releases its ptmx fd until the next GC. `onExit` is also the only path that runs without an external kill having been issued, so the neutralization is defensive against future reorderings — today `proc.kill` is a no-op after the child is reaped, but the kernel may still emit the close event.
|
||||
|
||||
### Relay: `relay/pty-handler.ts`
|
||||
|
||||
`PtyHandler` stores each `IPty` on a `ManagedPty` record. Teardown happens in five places:
|
||||
|
||||
- `shutdown(params)` — SIGTERM + 5s SIGKILL fallback, or SIGKILL immediately when `immediate === true`.
|
||||
- The `onExit` wiring inside `wireAndStore` — deletes the map entry.
|
||||
- `spawn()`'s stale-context path (`pty-handler.ts:144-154`) — client reconnected before the PTY response was delivered, so the PTY is orphaned and gets SIGTERM + 5s SIGKILL fallback.
|
||||
- `dispose()` — loops every managed pty and shuts it down.
|
||||
- `sendSignal()` — public entry point that forwards arbitrary signals; must keep working after dispose is queued.
|
||||
|
||||
Change (ordering matters):
|
||||
|
||||
1. **`onExit` wiring**: after the map-delete, call `disposeManagedPty(managed)`. This is the natural-exit path — the child has already exited and been reaped. Neutralizing `pty.kill` here is defensive: any trailing invocation from the socket close listener is no-op'd regardless of pid-recycle timing. The fd is released synchronously.
|
||||
2. **`shutdown(params)` with `immediate === true`**: after `managed.pty.kill('SIGKILL')`, call `disposeManagedPty(managed)`. SIGKILL has already reaped the child.
|
||||
3. **`shutdown(params)` graceful path (SIGTERM)**: do **not** call `destroy()` immediately after the SIGTERM send. The 5s `killTimer` and the natural `onExit` already cover fd release via step 1. Calling `destroy()` right after SIGTERM collapses the graceful-shutdown window and risks interrupting shell `EXIT` traps or cleanup hooks that run between SIGTERM and shell exit. If the `killTimer` fires (SIGKILL fallback), the SIGKILL reaps the child and `onExit` (step 1) calls `disposeManagedPty` on its own — **do not add a redundant call inside the killTimer closure**. The `disposed` guard makes a redundant call harmless but wiring it in both places is a merge hazard.
|
||||
4. **`spawn()` stale-context killTimer**: this path (`pty-handler.ts:149-153`) already sends SIGTERM then SIGKILL at 5s if the PTY wasn't acked. The relevant teardown path is `onExit` (step 1), which now calls `disposeManagedPty`. No code change needed in the stale-context branch itself — the onExit wiring in `wireAndStore` runs for this PTY the same as for any other. Confirmed covered.
|
||||
5. **`dispose()` loop**: call `disposeManagedPty` for each active managed pty AFTER `managed.pty.kill('SIGTERM')`. This is the relay-shutdown path — we accept that any in-flight shell cleanup is cut short because the relay process itself is exiting.
|
||||
|
||||
The common shape, extracted into a helper:
|
||||
|
||||
```ts
|
||||
function disposeManagedPty(managed: ManagedPty): void {
|
||||
if (managed.disposed) return
|
||||
managed.disposed = true
|
||||
// Why: clear any pending 5s SIGKILL fallback timer. If graceful-shutdown
|
||||
// armed a killTimer and the child then exited cleanly (firing onExit →
|
||||
// disposeManagedPty), the timer would otherwise fire later and attempt
|
||||
// pty.kill('SIGKILL') on an already-disposed instance. The ptys.has(id)
|
||||
// guard inside the timer short-circuits today, but symmetry is clearer.
|
||||
if (managed.killTimer) {
|
||||
clearTimeout(managed.killTimer)
|
||||
managed.killTimer = undefined
|
||||
}
|
||||
// Why: same SIGHUP-to-recycled-pid hazard as the daemon fix — neutralize
|
||||
// node-pty's kill on the instance before calling destroy(). The relay
|
||||
// typically runs on Linux remote hosts where pid recycling is fast.
|
||||
// Windows exempt: WindowsTerminal.destroy calls kill() internally, so
|
||||
// neutralizing it turns destroy() into a no-op.
|
||||
if (process.platform !== 'win32') {
|
||||
;(managed.pty as unknown as { kill?: (sig?: string) => void }).kill = () => {}
|
||||
}
|
||||
try {
|
||||
;(managed.pty as unknown as { destroy?: () => void }).destroy?.()
|
||||
} catch {
|
||||
/* swallow */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`disposed` flag on `ManagedPty`.** The flag is new — add it to the `ManagedPty` type. Purpose: prevent `disposeManagedPty` from running twice (both `onExit` and `shutdown` can fire for the same PTY), and — more importantly — let `sendSignal`, `writeData`, and other pre-existing entry points short-circuit if the caller tries to use a disposed PTY:
|
||||
|
||||
```ts
|
||||
private async sendSignal(params: Record<string, unknown>): Promise<void> {
|
||||
// ... existing signal validation ...
|
||||
const managed = this.ptys.get(id)
|
||||
if (!managed || managed.disposed) {
|
||||
throw new Error(`PTY "${id}" not found`)
|
||||
}
|
||||
managed.pty.kill(signal) // ← on POSIX this is neutralized-to-no-op only
|
||||
// AFTER disposeManagedPty runs. The early return
|
||||
// above means we never reach this line post-dispose.
|
||||
}
|
||||
```
|
||||
|
||||
Without the `disposed` guard, a `sendSignal('SIGTERM')` call landing after `onExit` → `disposeManagedPty` has neutralized `managed.pty.kill` would silently succeed (return success, do nothing). The `disposed` check converts the silent failure into the existing "not found" error, which is what callers already handle.
|
||||
|
||||
The graceful-SIGTERM path of `shutdown` remains unchanged (SIGTERM + 5s killTimer that sends SIGKILL). Fd release on that path happens either via the `onExit` hook (step 1) when the shell exits cleanly, or via the killTimer → SIGKILL → `onExit` chain if SIGTERM was ignored. Under no circumstance does `shutdown`'s graceful branch synchronously call `destroy()`.
|
||||
|
||||
### Why not just replace `node-pty`
|
||||
|
||||
Tempting, and probably correct long-term. Out of scope here: node-pty's API surface is deeply woven into the daemon, LocalPtyProvider, and the relay. Swapping it is a multi-week effort with its own regression surface. This bug is a one-line miss against an API the library already ships — fix that first.
|
||||
|
||||
### Why not a daemon-level fd watchdog
|
||||
|
||||
A periodic "count my open fds, restart if over threshold" watchdog would mask the bug, not fix it. It also introduces the exact timer-based lifecycle the orphan-daemon design [explicitly rejected](./auto-kill-old-daemons.md) for the same reasons (cold-restore instead of warm-reattach, false positives against live work). Observability yes, auto-restart no.
|
||||
|
||||
## Interaction states
|
||||
|
||||
Because this fix sits below the IPC layer, the renderer sees no state changes. All user-visible interaction states are identical to today:
|
||||
|
||||
| Trigger | User-visible state | Behind the scenes (new) |
|
||||
|---|---|---|
|
||||
| Click "Close terminal" | Tab closes immediately | `terminal:kill` IPC → `Session.kill()` → `handleSubprocessExit` → `subprocess.dispose()` → `proc.destroy()` → fd released |
|
||||
| Shell exits naturally (`exit` / Ctrl-D) | Tab shows exit code | `handleSubprocessExit` → `subprocess.dispose()` → `proc.destroy()` → fd released |
|
||||
| Daemon is killed mid-session | Renderer reconnects cold on next app launch | On orderly shutdown: `TerminalHost.dispose()` loop → per-session `forceKillAndDisposeSubprocess()` — SIGKILL first (stubborn children reaped), then `proc.destroy()` releases the fd synchronously. Does not depend on the 5s `KILL_TIMEOUT_MS`. |
|
||||
| Child hits `Napi::Error` race | Silent (today) | Silent (same) — next teardown trigger reaches `subprocess.dispose()`, which forwards to `proc.destroy()` regardless of `dead` |
|
||||
| Session.kill hits `KILL_TIMEOUT_MS` | User sees "force-closed" log entry (unchanged) | `forceDispose` → `forceKill` + `subprocess.dispose()` → fd released |
|
||||
| SSH worktree session closed | Tab closes immediately | Relay `PtyHandler.shutdown` → SIGTERM + `managed.pty.destroy()` on exit → fd released on remote host |
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Renderer Main (IPC) Daemon (TerminalHost) node-pty
|
||||
│ │ │ │
|
||||
│ terminal:kill │ │ │
|
||||
├────────────────────────▶│ RPC: kill(sessionId) │ │
|
||||
│ ├───────────────────────────▶│ Session.kill() │
|
||||
│ │ ├─ subprocess.kill() ─────▶│ (SIGTERM)
|
||||
│ │ │ │
|
||||
│ │ │◀── onExit(code) ─────────│
|
||||
│ │ │ handleSubprocessExit │
|
||||
│ │ │ ├─ state='exited' │
|
||||
│ │ │ ├─ subprocess.dispose()─┼─▶ destroy() →
|
||||
│ │ │ │ │ _close() +
|
||||
│ │ │ │ │ _socket.destroy() +
|
||||
│ │ │ │ │ _writeStream.dispose()
|
||||
│ │ │ │ │ → ptmx fd released ✔
|
||||
│ │ │ └─ fanout onExit │
|
||||
│ │◀─── event: 'exit' ─────────│ │
|
||||
│◀── terminal:exit ───────│ │ │
|
||||
```
|
||||
|
||||
### Failure paths
|
||||
|
||||
**Natural exit.** Child dies, `proc` emits `onExit` → `handleSubprocessExit` → `subprocess.dispose()` → `proc.destroy()` → `_socket.destroy()` → fd released, same tick.
|
||||
|
||||
**Explicit kill (`Session.kill`).** SIGTERM → child exits → same path as above.
|
||||
|
||||
**Kill timeout.** Child ignores SIGTERM → `KILL_TIMEOUT_MS` elapses → `forceDispose` → `subprocess.forceKill()` (SIGKILL) + `subprocess.dispose()` (`proc.destroy()`). Fd released within 5s worst case.
|
||||
|
||||
**Native throw.** `proc.write/resize/kill` throws `Napi::Error` → `dead = true`. Session is wedged but still reachable. The next teardown trigger (user close, daemon shutdown, worktree close) reaches `subprocess.dispose()`, which forwards to `proc.destroy()`. The destroy() body itself is try/catch-wrapped — if the native side is already half-torn-down and throws, we swallow; the socket side still gets destroyed by any prior event.
|
||||
|
||||
**Daemon shutdown.** `TerminalHost.dispose()` iterates sessions and calls `session.forceKillAndDisposeSubprocess()` synchronously. The ordering is: SIGKILL (`subprocess.forceKill()` → `process.kill(pid, 'SIGKILL')`) → `subprocess.dispose()` → `proc.destroy()`. SIGKILL is not ignorable, so any child that would have survived the normal 5s `KILL_TIMEOUT_MS` SIGTERM window is reaped before the fd release. On POSIX, every `ptmx` fd is released before the daemon process exits. On Windows, `WindowsTerminal.destroy` defers via `_deferNoArgs` — dispose is enqueued but may not run synchronously; this is acceptable because ConPTY is not subject to the POSIX `ptmx` cap (see Risks).
|
||||
|
||||
**Local provider (non-daemon).** `LocalPtyProvider.shutdown(id)` / `killAll()` / natural `onExit` all route through the new `destroyPtyProcess(proc)` helper, same `proc.destroy()` call. Fd released symmetrically with the daemon path.
|
||||
|
||||
**Relay (SSH).** `PtyHandler.shutdown` and `PtyHandler.dispose` both call `managed.pty.destroy()` after the existing kill. On relay shutdown the loop is synchronous, so ptmx fds in the remote host's relay process are released before the relay exits.
|
||||
|
||||
## Architectural fit
|
||||
|
||||
```
|
||||
┌─ Daemon (forked) ───────────────┐ ┌─ Main process ─────────────┐ ┌─ Relay (remote host) ────┐
|
||||
│ TerminalHost │ │ LocalPtyProvider │ │ PtyHandler │
|
||||
│ └─ Session │ │ └─ ptyProcesses map │ │ └─ ManagedPty map │
|
||||
│ └─ SubprocessHandle │ │ (proc.destroy ←NEW)│ │ (pty.destroy ←NEW)│
|
||||
│ └─ dispose() ←NEW │ │ │ │ │
|
||||
│ └─ proc.destroy()│ └────────────────────────────┘ └──────────────────────────┘
|
||||
└─────────────────────────────────┘ │ │
|
||||
│ └───────────┬─────────────────────┘
|
||||
└───────────────┐ │
|
||||
▼ ▼
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ node-pty destroy() │
|
||||
│ → _close() → _socket.destroy() → fd freed │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three providers, one shared fix: forward the local dispose call to `node-pty.destroy()`. No new layer, no new dependency direction. Each provider already owns its handle lifecycle — we are filling a gap in three existing chains, not creating them.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
### Files to change
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/main/daemon/session.ts` | Add `dispose(): void` to `SubprocessHandle` type. Add `forceKillAndDisposeSubprocess()` + `disposeSubprocess()` public methods. Add private `#teardownSubprocess()` helper that does NOT flip `_state` (see `dispose` section). Call `subprocess.dispose()` from `handleSubprocessExit`, `dispose`, `forceDispose`. Preserve the `wasTerminating = this._isTerminating && this._state !== 'exited'` invariant by capturing it BEFORE any state flip in the refactored `dispose`. |
|
||||
| `src/main/daemon/pty-subprocess.ts` | Implement `dispose()` on the returned handle. Idempotent. Nulls callbacks, sets `dead = true`, gates the `proc.kill = noop` neutralization on `process.platform !== 'win32'`, forwards to `(proc as unknown as { destroy?: () => void }).destroy?.()` in try/catch. |
|
||||
| `src/main/daemon/terminal-host.ts` | In `dispose()`, call `session.forceKillAndDisposeSubprocess()` instead of `session.kill()`. Preserves the SIGKILL-fallback behavior without depending on the 5s timer. |
|
||||
| `src/main/providers/local-pty-provider.ts` | Add `destroyPtyProcess(proc)` helper: gates `proc.kill = noop` neutralization on POSIX, then calls `proc.destroy()` in try/catch. Invoke from `safeKillAndClean`, `shutdown`, and the `onExit` path (after `clearPtyState`). |
|
||||
| `src/relay/pty-handler.ts` | Add `disposed: boolean` field to `ManagedPty`. In `wireAndStore`'s `onExit` (after map delete) and in `dispose()` loop, call `disposeManagedPty(managed)`. In `shutdown` immediate-path (after SIGKILL), call `disposeManagedPty`. In the 5s killTimer SIGKILL fallback (graceful path and spawn stale-context path), also call `disposeManagedPty`. Do **not** call it in the graceful SIGTERM branch. Guard `sendSignal`, `writeData`, `resize`, `getCwd`, `getInitialCwd`, `clearBuffer`, `hasChildProcesses`, `getForegroundProcess`, `attach` with `managed.disposed` checks (treat disposed as "not found"). |
|
||||
| `src/main/daemon/session.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
|
||||
| `src/main/daemon/terminal-host.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
|
||||
| `src/main/daemon/terminal-host-startup.test.ts` | Add `dispose: vi.fn()` to `mockSubprocess`. |
|
||||
| `src/main/daemon/production-launcher.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
|
||||
| `src/main/daemon/daemon-pty-provider.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
|
||||
| `src/main/daemon/reattach-snapshot.test.ts` | Add `dispose: vi.fn()` to `createMockSubprocess`. |
|
||||
| `src/main/daemon/__tests__/pty-fd-leak.test.ts` (new) | Integration test for the daemon path: spawn N sessions, tear them down via each path, assert fd count returns to baseline. |
|
||||
| `src/main/providers/__tests__/local-pty-fd-leak.test.ts` (new) | Same test, targeting `LocalPtyProvider` directly. |
|
||||
| `src/relay/__tests__/pty-fd-leak.test.ts` (new) | Same test, targeting `PtyHandler` directly. |
|
||||
|
||||
### Testing plan
|
||||
|
||||
The hard part of this fix is not the code — it's proving the leak is closed in all three providers. Two layers:
|
||||
|
||||
#### 1. Unit-level handle behaviour
|
||||
|
||||
Per provider, for each exit path (`kill`, `forceKill`, native throw simulated by calling `dispose` after an artificial dead state, natural exit), assert:
|
||||
|
||||
- `proc.destroy()` is called exactly once per PTY (spy on the mock `IPty`).
|
||||
- Calling the provider's dispose a second time is a no-op (idempotency).
|
||||
- After dispose, subsequent `write/resize/kill` are no-ops and do not throw.
|
||||
- No `Napi::Error` surfaces from any follow-on method call.
|
||||
|
||||
#### 2. Integration-level fd count
|
||||
|
||||
One test file per provider (`src/main/daemon/__tests__/pty-fd-leak.test.ts`, `src/main/providers/__tests__/local-pty-fd-leak.test.ts`, `src/relay/__tests__/pty-fd-leak.test.ts`), same shape:
|
||||
|
||||
```
|
||||
baseline = countPtmxFds(process.pid)
|
||||
for i in 1..50:
|
||||
pty = provider.spawn(...)
|
||||
provider.shutdown(pty.id) // or forceKill / dispose / let it exit naturally
|
||||
await exit event
|
||||
after = countPtmxFds(process.pid)
|
||||
assert after === baseline
|
||||
```
|
||||
|
||||
Loop once per exit path per provider.
|
||||
|
||||
**Platform-specific `countPtmxFds` implementation:**
|
||||
|
||||
- **macOS:** `lsof -p <pid> -Ffn` → parse as (fd, name) pairs → count pairs whose fd is numeric (not `cwd`/`txt`/`rtd`) AND whose name is exactly `/dev/ptmx`. The `-Ffn` machine-readable mode emits one field per line prefixed with `f` (fd) or `n` (name); a naive `startsWith('n')` filter that doesn't cross-check the preceding `f` row false-matches on non-fd rows whose NAME happens to be `/dev/ptmx`. Never use `grep ptmx` unqualified — it false-matches on env vars and any path containing the substring.
|
||||
- **Linux:** iterate `/proc/<pid>/fd/*`, `readlink` each entry, match against the regex `/^(\/dev\/ptmx$|\/dev\/pts\/ptmx$|anon_inode:\[?ptmx\]?$)/`. The master pty fd does not show as `/dev/ptmx` on modern Linux kernels — depending on the pty backend and kernel version, `readlink` returns `anon_inode:[ptmx]` (devpts backend on many distros) or `/dev/pts/ptmx`. A naive `grep /dev/ptmx` yields zero matches and the test passes vacuously — **zero regression coverage** instead of real coverage. CI runs on `ubuntu-latest`; this matters.
|
||||
- **Windows:** skip entirely (ConPTY doesn't expose ptmx; rely on the unit-level `destroy()` spy).
|
||||
|
||||
**Mandatory `beforeAll` counter smoke.** Before the first assertion, spawn one real PTY, measure `countPtmxFds`, kill it, measure again, and assert `beforeSpawn < afterSpawn` and `afterKill <= beforeSpawn`. If either check fails, the counter itself is broken on this platform — skip the whole suite with a clear message (`skip('ptmx counter not functional on this platform')`). Otherwise, the fd-count assertions could go green on a broken counter and we'd think the fix landed when it didn't.
|
||||
|
||||
```ts
|
||||
beforeAll(async () => {
|
||||
const baseline = countPtmxFds(process.pid)
|
||||
const probe = pty.spawn('/bin/sh', ['-c', 'sleep 30'], { /* ... */ })
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const withProbe = countPtmxFds(process.pid)
|
||||
probe.kill('SIGKILL')
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
const afterKill = countPtmxFds(process.pid)
|
||||
if (!(withProbe > baseline) || !(afterKill <= baseline)) {
|
||||
// Why: if the counter can't even observe spawn/kill deltas on a known-good
|
||||
// PTY, it can't observe the leak we're trying to prove closed. Vacuously
|
||||
// passing assertions would give false confidence. Skip instead.
|
||||
return describe.skip('ptmx counter smoke failed; skipping fd-leak suite')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
These tests spawn 50 real subprocesses each and must be gated behind an env-var skip (not a vitest tag — vitest 4 doesn't ship built-in tag filtering in this repo's config). Use `describe.skipIf(!process.env.RUN_SLOW_TESTS)`. Every spawn inside the loop must be wrapped in try/finally that force-kills the child regardless of assertion outcome — otherwise a mid-loop failure leaks the remaining subprocesses into the test runner, corrupting the next suite's baseline.
|
||||
|
||||
#### 3. Manual verification
|
||||
|
||||
Reproduce the reported 269/13 case locally:
|
||||
1. Start a fresh daemon.
|
||||
2. Baseline: `lsof -p $(pgrep -f daemon-entry) | grep -c ptmx` → small (one per live session).
|
||||
3. Churn: open and close 50 terminals via the UI.
|
||||
4. Verify the count returns to baseline within seconds. Before the fix, it stays at ~50.
|
||||
5. Repeat against a non-daemon worktree (LocalPtyProvider path) and against an SSH worktree (relay path).
|
||||
|
||||
### Rollout
|
||||
|
||||
No flag needed. The change is pure bug-fix with strictly smaller resource footprint. Ship with the orphan-daemon fix in the same release — they are complementary and together close the full leak story.
|
||||
|
||||
### Regression surface
|
||||
|
||||
| Regression | Mitigation |
|
||||
|---|---|
|
||||
| `dispose()` throws, breaking exit-code fanout to clients | Wrap every dispose call site in try/catch. `destroy()` call itself is also in try/catch inside the wrapper. |
|
||||
| `proc.destroy()` is not present on an older/forked node-pty build | Optional chain: `(proc as unknown as { destroy?: () => void }).destroy?.()`. Current pinned version has it on both UnixTerminal and WindowsTerminal; tests assert it's called. |
|
||||
| Double-dispose double-frees the native handle | Idempotency guard (`if (disposed) return`) at the top of dispose. node-pty's own `destroy()` is also safe to call twice — `_close()` is idempotent (it replaces `write`/`end` with no-ops and flips `_writable`/`_readable` to false the first time; the second call overwrites no-ops with no-ops). `_socket.destroy()` and `_writeStream.dispose()` are both idempotent per Node stream semantics. |
|
||||
| `dead` flag pattern regresses, `Napi::Error` kills daemon | All existing `dead`-guarded methods stay exactly as-is. `dispose()` sets `dead = true` and then calls `destroy()`; it adds to the pattern, does not replace it. |
|
||||
| Newly-nulled onData callback drops final data burst | Session already fans out synchronously in `handleSubprocessData` before exit. Nulling after exit is safe. |
|
||||
| Neutralizing `proc.kill` on Windows turns `destroy()` into a no-op (ConPTY leak) | `WindowsTerminal.destroy` calls `kill()` via `_deferNoArgs` — we gate neutralization on `process.platform !== 'win32'`. Verified in `pty-subprocess.ts` and `local-pty-provider.ts` and `relay/pty-handler.ts` helpers. Test: mock `process.platform = 'win32'`; assert `proc.kill` is NOT replaced with noop; assert `destroy()` is still called. |
|
||||
| Shared `#teardownSubprocess` helper flips `_state` before callers capture `wasTerminating`, orphaning stubborn children | Helper explicitly does NOT flip `_state`. Each public method (`dispose`, `disposeSubprocess`, `forceKillAndDisposeSubprocess`) owns its own `_state = 'exited'` line and captures any pre-flip invariants before calling the helper. Test: session with `_isTerminating=true`, `_state='running'` calls `dispose()`; assert `subprocess.forceKill()` is called before `subprocess.dispose()`. |
|
||||
| `sendSignal` / `writeData` / `resize` silently no-op after relay `onExit` neutralized `managed.pty.kill` | Every public entry point on `PtyHandler` that touches a `ManagedPty` checks `managed.disposed` first and treats disposed as "not found" (existing error path). Converts silent failure to an explicit error callers already handle. |
|
||||
| `TerminalHost.dispose()` drops 5s SIGKILL fallback for stubborn children | New `forceKillAndDisposeSubprocess` method explicitly sends SIGKILL via `subprocess.forceKill()` before releasing the fd. SIGKILL is not ignorable. Test: session whose child ignores SIGTERM; call `forceKillAndDisposeSubprocess`; assert child is reaped AND fd is released, both synchronously. |
|
||||
| LocalPtyProvider delivers trailing SIGHUP to recycled pid | `destroyPtyProcess(proc)` mirrors the daemon and relay helpers: POSIX-guarded `proc.kill = noop` neutralization before `destroy()`. Covers natural-exit, `safeKillAndClean`, and `shutdown` paths. |
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Severity | Why acceptable |
|
||||
|---|---|---|
|
||||
| `destroy()` doesn't actually release the fd | Low | We are literally calling node-pty's canonical close path (`_close()` → `_socket.destroy()` → `_writeStream.dispose()`). Only way this is ineffective is a node-pty bug or a version regression — and the integration fd-count tests catch both before merge. |
|
||||
| Idempotent dispose hides a real double-free bug | Low | Idempotency only skips work; it does not suppress errors. |
|
||||
| `TerminalHost.dispose()` synchronous loop blocks daemon shutdown | Low | `destroy()` on each PTY is a handful of syscalls; 50 sessions ≈ single-digit ms. |
|
||||
| `destroy()` on Windows defers via `_deferNoArgs` | Low | ConPTY never hit the ptmx cap (it's a POSIX resource). Windows dispose is correctness-for-symmetry, not a load-bearing leak fix. |
|
||||
| The real leak is elsewhere (e.g., an fd leak outside the PTY code path) | Low | Integration tests directly measure fd reclamation across a full spawn/kill lifecycle. If the fix works there, it addresses the observed symptom. |
|
||||
|
||||
## Related work (out of scope)
|
||||
|
||||
- **Orphaned legacy daemons**: [auto-kill-old-daemons.md](./auto-kill-old-daemons.md) — complementary fix targeting daemon-level lifecycle.
|
||||
- **Worktree-delete does not kill sessions**: separate follow-up. Today, deleting a worktree from the sidebar leaves its PTY sessions live in the daemon, which is a different leak vector.
|
||||
- **Daemon fd observability**: expose an IPC `daemon:stats` handler so Orca can surface fd count in a debug view. Would let us detect regressions in production rather than waiting for user reports.
|
||||
- **Replace `node-pty`**: out of scope; see "Why not just replace node-pty" above.
|
||||
@@ -1,350 +0,0 @@
|
||||
# Focus-Follows-Mouse for Terminal Panes
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-08
|
||||
|
||||
## Summary
|
||||
|
||||
Add a Ghostty-style `focus-follows-mouse` behavior to Orca's terminal panes: when enabled, hovering a split makes it the active pane (cursor focus + input routing + opacity update) without requiring a click. Scoped to splits within a single tab, default off, immediate on `mouseenter`. Safety guards preserve text selections, pane drags, window-focus semantics, and overlays.
|
||||
|
||||
## Motivation
|
||||
|
||||
Orca's terminal panes already borrow heavily from Ghostty's look and feel (themes, dividers, drag handles, close dialog, URL tooltip). Focus-follows-mouse is one of the few Ghostty interaction conventions Orca doesn't yet mirror. Users coming from Ghostty expect it; users who've never used it can leave the default off and see no change.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
|
||||
- Hovering a terminal split in the current tab activates it when the setting is enabled.
|
||||
- A new `GlobalSettings.terminalFocusFollowsMouse` boolean persisted in user settings.
|
||||
- A new toggle in Settings → Terminal → Pane Styling.
|
||||
- Full unit test coverage of the gating logic.
|
||||
|
||||
**Out of scope:**
|
||||
|
||||
- Cross-tab hover activation (hovering a background tab does nothing).
|
||||
- Hovering non-terminal panes (file editor, source control, sidebars) — this setting only affects terminal splits.
|
||||
- Adjustable hover delay / settle time. Immediate switching matches Ghostty and is what users asking for Ghostty parity expect.
|
||||
- Migrating `PaneStyleOptions` into a split style-vs-behavior type. Bundling that refactor here would balloon the diff for marginal clarity. Left as a follow-up for when a second behavior flag lands.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Cross-window focus. If Orca isn't the OS-focused window, hovering a pane must not switch focus.
|
||||
- Breaking existing click-to-focus. Clicking a pane must continue to work exactly as today; this feature is additive.
|
||||
|
||||
## Design
|
||||
|
||||
### Data model and settings plumbing
|
||||
|
||||
**New field on `GlobalSettings`** (`src/shared/types.ts`):
|
||||
|
||||
```ts
|
||||
terminalFocusFollowsMouse: boolean
|
||||
```
|
||||
|
||||
**Default value** (`src/shared/constants.ts` → `getDefaultSettings`):
|
||||
|
||||
```ts
|
||||
terminalFocusFollowsMouse: false
|
||||
```
|
||||
|
||||
Default off matches Ghostty. Existing users upgrading receive the default automatically since `getDefaultSettings` is used as the fallback for missing fields on read — no migration needed.
|
||||
|
||||
**Extension of `PaneStyleOptions`** (`src/renderer/src/lib/pane-manager/pane-manager-types.ts`):
|
||||
|
||||
```ts
|
||||
export type PaneStyleOptions = {
|
||||
splitBackground?: string
|
||||
paneBackground?: string
|
||||
inactivePaneOpacity?: number
|
||||
activePaneOpacity?: number
|
||||
opacityTransitionMs?: number
|
||||
dividerThicknessPx?: number
|
||||
// Why this behavior flag lives on "style" options: this type is already
|
||||
// the single runtime-settings bag the PaneManager exposes. Splitting into
|
||||
// separate style vs behavior types is a refactor worth its own change when
|
||||
// a second behavior flag lands. See docs/focus-follows-mouse-design.md.
|
||||
focusFollowsMouse?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
**Plumbing path** (same pattern already used by `terminalInactivePaneOpacity`):
|
||||
|
||||
1. `GlobalSettings.terminalFocusFollowsMouse` — persisted
|
||||
2. `resolvePaneStyleOptions` in `src/renderer/src/lib/terminal-theme.ts:103` extracts the style-related fields from `GlobalSettings` and returns them. Add `'terminalFocusFollowsMouse'` to the `Pick<GlobalSettings, ...>` parameter union, and add `focusFollowsMouse: settings.terminalFocusFollowsMouse` to the returned object (no clamping needed — boolean pass-through, unlike the existing numeric fields that go through `clampNumber`).
|
||||
3. `applyTerminalAppearance` in `src/renderer/src/components/terminal-pane/terminal-appearance.ts:48` calls `manager.setPaneStyleOptions({...})` — add `focusFollowsMouse: paneStyles.focusFollowsMouse` to that object literal.
|
||||
4. `PaneManager.setPaneStyleOptions()` stores into `this.styleOptions`. The `handlePaneMouseEnter` method reads `this.styleOptions.focusFollowsMouse` fresh on every event, so toggling the setting takes effect immediately without re-wiring listeners.
|
||||
|
||||
### Runtime event wiring
|
||||
|
||||
**Pure gate helper** — extracted to a new file so it can be unit tested without a DOM.
|
||||
|
||||
`src/renderer/src/lib/pane-manager/focus-follows-mouse.ts`:
|
||||
|
||||
```ts
|
||||
export type FocusFollowsMouseInput = {
|
||||
featureEnabled: boolean
|
||||
activePaneId: number | null
|
||||
hoveredPaneId: number
|
||||
mouseButtons: number // from MouseEvent.buttons bitmask
|
||||
windowHasFocus: boolean // from document.hasFocus()
|
||||
managerDestroyed: boolean
|
||||
}
|
||||
|
||||
/** Pure gate: returns true iff the hovered pane should be activated.
|
||||
* Isolated from DOM so it can be unit-tested without an environment. */
|
||||
export function shouldFollowMouseFocus(input: FocusFollowsMouseInput): boolean {
|
||||
if (!input.featureEnabled) return false
|
||||
if (input.managerDestroyed) return false
|
||||
if (input.activePaneId === input.hoveredPaneId) return false
|
||||
// Why event.buttons !== 0: any held mouse button means a selection or a
|
||||
// drag is in progress. Switching focus mid-drag would break xterm.js text
|
||||
// selection and the pane drag-to-reorder flow. This single check also
|
||||
// covers drag-to-reorder: the drag is always button-held, so buttons is
|
||||
// always non-zero during it. No separate drag-state gate needed.
|
||||
if (input.mouseButtons !== 0) return false
|
||||
// Why document.hasFocus: if Orca isn't the OS-focused window, the mouse
|
||||
// event is from the user passing through on their way to another app.
|
||||
// We must not hijack focus in that case. Also returns false when DevTools
|
||||
// is focused (DevTools runs in a separate WebContents) — accepted.
|
||||
if (!input.windowHasFocus) return false
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
**Updated `createPaneDOM` signature** (`src/renderer/src/lib/pane-manager/pane-lifecycle.ts`):
|
||||
|
||||
```ts
|
||||
export function createPaneDOM(
|
||||
id: number,
|
||||
options: PaneManagerOptions,
|
||||
dragState: DragReorderState,
|
||||
dragCallbacks: DragReorderCallbacks,
|
||||
onPointerDown: (id: number) => void,
|
||||
onMouseEnter: (id: number, event: MouseEvent) => void // NEW
|
||||
): ManagedPaneInternal
|
||||
```
|
||||
|
||||
**New listener** attached in `createPaneDOM` next to the existing `pointerdown` listener:
|
||||
|
||||
```ts
|
||||
container.addEventListener('mouseenter', (event) => {
|
||||
onMouseEnter(id, event)
|
||||
})
|
||||
```
|
||||
|
||||
The pane DOM layer stays dumb — it knows nothing about settings, active state, or window focus. All gating logic lives in the PaneManager callback.
|
||||
|
||||
**PaneManager wiring** (`src/renderer/src/lib/pane-manager/pane-manager.ts`, inside `createPaneInternal`):
|
||||
|
||||
```ts
|
||||
private createPaneInternal(): ManagedPaneInternal {
|
||||
const id = this.nextPaneId++
|
||||
const pane = createPaneDOM(
|
||||
id,
|
||||
this.options,
|
||||
this.dragState,
|
||||
this.getDragCallbacks(),
|
||||
(paneId) => {
|
||||
if (!this.destroyed && this.activePaneId !== paneId) {
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
},
|
||||
(paneId, event) => {
|
||||
this.handlePaneMouseEnter(paneId, event)
|
||||
}
|
||||
)
|
||||
this.panes.set(id, pane)
|
||||
return pane
|
||||
}
|
||||
|
||||
/** Focus-follows-mouse entry point. Collects gate inputs from the manager
|
||||
* and delegates to the pure gate helper.
|
||||
*
|
||||
* Invariant for future contributors: modal overlays (context menus, close
|
||||
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
|
||||
* the pane container. If a future overlay is rendered inside a .pane
|
||||
* element, mouseenter will still fire on the pane underneath and this
|
||||
* handler will incorrectly switch focus. Keep overlays out of the pane. */
|
||||
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
|
||||
if (
|
||||
shouldFollowMouseFocus({
|
||||
featureEnabled: this.styleOptions.focusFollowsMouse ?? false,
|
||||
activePaneId: this.activePaneId,
|
||||
hoveredPaneId: paneId,
|
||||
mouseButtons: event.buttons,
|
||||
windowHasFocus: document.hasFocus(),
|
||||
managerDestroyed: this.destroyed
|
||||
})
|
||||
) {
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Why no explicit drag-state gate:** An earlier draft of this design included a `dragSourcePaneId !== null` check as belt-and-suspenders. Verification against `src/renderer/src/lib/pane-manager/pane-drag-reorder.ts:77-103` showed it's strictly redundant:
|
||||
|
||||
- The drag only activates while the user is holding a mouse button (pointerdown → drag threshold → pointerup). During that entire window, `event.buttons !== 0` on any mouseenter.
|
||||
- `dragSourcePaneId` is unconditionally cleared at line 101 inside the `if (dragging)` branch of the pointerup handler, so it cannot outlive a pointerup.
|
||||
- Therefore `mouseButtons !== 0` is sufficient on its own. Adding a second check would be dead code.
|
||||
|
||||
### Settings UI
|
||||
|
||||
**Placement** — under the existing "Pane Styling" section in `src/renderer/src/components/settings/TerminalPane.tsx`, below the `<div className="grid gap-4 md:grid-cols-2">` that holds Inactive Pane Opacity + Divider Thickness. The new toggle is a full-width row (toggles don't balance well next to numeric fields in a 2-column grid).
|
||||
|
||||
**Toggle shape** — mirrors the existing `role="switch"` pattern used by e.g. "Nest Workspaces" in `GeneralPane.tsx`:
|
||||
|
||||
```tsx
|
||||
<SearchableSetting
|
||||
title="Focus Follows Mouse"
|
||||
description="Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting."
|
||||
keywords={['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Focus Follows Mouse</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's
|
||||
focus-follows-mouse setting. Selections and window switching stay safe.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.terminalFocusFollowsMouse}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.terminalFocusFollowsMouse ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.terminalFocusFollowsMouse ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
```
|
||||
|
||||
**Settings search integration** — add a new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` in `src/renderer/src/components/settings/terminal-search.ts:34` (alongside "Inactive Pane Opacity" and "Divider Thickness"):
|
||||
|
||||
```ts
|
||||
{
|
||||
title: 'Focus Follows Mouse',
|
||||
description: 'Hovering a terminal pane activates it without needing to click.',
|
||||
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
|
||||
}
|
||||
```
|
||||
|
||||
This surfaces the toggle in the settings search box for queries like "focus", "hover", or "ghostty". No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator) since it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically.
|
||||
|
||||
### Edge cases
|
||||
|
||||
**Gate-covered (code enforces):**
|
||||
|
||||
| Case | Gate |
|
||||
| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Mouse button held mid-selection | `mouseButtons !== 0` |
|
||||
| Mid-drag-to-reorder | `mouseButtons !== 0` (drag is always button-held) |
|
||||
| Orca window unfocused (alt-tab) | `!document.hasFocus()` |
|
||||
| DevTools panel focused | `!document.hasFocus()` — DevTools runs in a separate WebContents so the main document loses focus. Feature pauses until DevTools is closed or the pane is clicked. Acceptable — same behavior as any other focus loss. |
|
||||
| Hover the already-active pane | `activePaneId === hoveredPaneId` |
|
||||
| Manager destroyed mid-event | `managerDestroyed` |
|
||||
|
||||
**Implicitly safe (relies on platform behavior, documented):**
|
||||
|
||||
- **Context menus / close-confirm dialogs / command palette** — These render as React portals above the pane DOM. `mouseenter` does not reach the pane when an overlay is open. If a future contributor ever renders an overlay _inside_ the pane container, this assumption breaks — a code comment flags this.
|
||||
- **Tab / worktree switching** — Hidden PaneManagers' DOM doesn't receive events. No cross-manager interference possible.
|
||||
- **Single-pane layouts** — The `activePaneId === hoveredPaneId` early-return handles this with no special case.
|
||||
- **Right-click** — `contextmenu` doesn't trigger `mouseenter` (no boundary crossing). Existing right-click behavior is preserved.
|
||||
|
||||
**Intentionally accepted quirks:**
|
||||
|
||||
- **Setting toggled ON while hovering a non-active pane** → feature doesn't kick in until next `mouseenter`. User must wiggle the mouse. The fix (track last hovered pane and replay on setting change) adds persistent state for a rare case. Not worth it.
|
||||
- **Traversal flicker** (A → C via B on a three-pane layout) → briefly focuses B. Accepted as the cost of Ghostty-parity immediate switching. Confirmed during brainstorming.
|
||||
- **Window-resize-under-stationary-mouse** → if pane boundaries cross the cursor during a resize, focus can shift to the newly-hovered pane even though the user didn't move the mouse. Arguably correct; adding a "suppress during resize" gate introduces fragile state.
|
||||
|
||||
### Error handling
|
||||
|
||||
The mouseenter handler does no I/O, no promises, no external state lookups. No error surface worth defending:
|
||||
|
||||
- `document.hasFocus()` — synchronous boolean, cannot throw
|
||||
- `event.buttons` — number, cannot throw
|
||||
- `this.styleOptions.focusFollowsMouse ?? false` — missing settings fail safe (feature off)
|
||||
- `setActivePane` — already handles pane-not-found silently at `pane-manager.ts:188`
|
||||
|
||||
No try/catch added. If `setActivePane` throws, propagating to the window `error` handler is the correct failure mode — silently swallowing would hide a real bug.
|
||||
|
||||
### Testing strategy
|
||||
|
||||
**Constraint:** vitest runs with `environment: 'node'` (no jsdom). No synthetic event dispatch in automated tests.
|
||||
|
||||
**1. Unit tests — `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`**
|
||||
|
||||
Table of inputs against `shouldFollowMouseFocus`, one case per gate plus happy paths. Expected coverage: every branch.
|
||||
|
||||
- Happy path: all gates pass → `true`
|
||||
- Feature disabled → `false`
|
||||
- Manager destroyed → `false`
|
||||
- Hover the already-active pane → `false`
|
||||
- Mouse button held (primary `buttons=1`, secondary `=2`, both `=3`) → `false` each
|
||||
- Window lacks OS focus → `false`
|
||||
- `activePaneId === null` (theoretically-unreachable defensive case — `createInitialPane` always sets `activePaneId` before mouse events are possible, but the gate logic must still behave correctly if this state ever occurs) → `true`
|
||||
|
||||
**2. Manual smoke test checklist (PR description)**
|
||||
|
||||
- [ ] Toggle persists across app restart.
|
||||
- [ ] Multi-split: hovering an inactive pane activates it.
|
||||
- [ ] Start text selection in pane A, drag into pane B. Selection extends normally; focus does NOT switch mid-drag.
|
||||
- [ ] Drag a pane by its drag-handle (the top strip that appears on hover). Release on a drop zone. Focus/activation does NOT flicker during the drag.
|
||||
- [ ] Cmd-Tab out, move mouse over a different Orca pane, Cmd-Tab back. Previously-active pane still active.
|
||||
- [ ] Open DevTools, focus the DevTools panel, move mouse over a different Orca pane. Focus does NOT switch. Close DevTools, wiggle mouse → focus-follows-mouse resumes.
|
||||
- [ ] Open close-terminal confirmation, hover a different pane. No focus shift.
|
||||
- [ ] Hover a URL in an inactive pane. Focus-follows-mouse activates the pane. Verify the `Cmd+click to open` URL tooltip still appears correctly on the newly-activated pane (tests for interaction between our `setActivePane → terminal.focus()` call and xterm.js's `WebLinksAddon` hover tracking).
|
||||
- [ ] Disable the setting. Hover does nothing; click still works.
|
||||
- [ ] Single-pane layout with the setting enabled. No crash, pane stays focused.
|
||||
- [ ] Three-pane layout, quick A→C sweep. Traversal flicker visible but tolerable.
|
||||
|
||||
**Explicitly not tested:**
|
||||
|
||||
- DOM event dispatch integration (no jsdom, not worth adding)
|
||||
- Settings UI render (no precedent in the codebase for per-control UI tests)
|
||||
- Electron's `document.hasFocus()` semantics (trust the platform)
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
|
||||
| `src/shared/types.ts` | Add `terminalFocusFollowsMouse: boolean` to `GlobalSettings` |
|
||||
| `src/shared/constants.ts` | Default `terminalFocusFollowsMouse: false` in `getDefaultSettings` |
|
||||
| `src/renderer/src/lib/pane-manager/pane-manager-types.ts` | Add `focusFollowsMouse?: boolean` to `PaneStyleOptions` with a commented rationale |
|
||||
| `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` | **New file.** Pure `shouldFollowMouseFocus` gate helper |
|
||||
| `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` | **New file.** Unit tests for the gate helper |
|
||||
| `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` | Add `onMouseEnter` param to `createPaneDOM`; attach `mouseenter` listener on pane container |
|
||||
| `src/renderer/src/lib/pane-manager/pane-manager.ts` | Pass new callback from `createPaneInternal`; add `handlePaneMouseEnter` private method |
|
||||
| `src/renderer/src/components/settings/TerminalPane.tsx` | Add `SearchableSetting` toggle under Pane Styling |
|
||||
| `src/renderer/src/components/settings/terminal-search.ts` | Add entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` |
|
||||
| `src/renderer/src/lib/terminal-theme.ts` | Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through |
|
||||
| `src/renderer/src/components/terminal-pane/terminal-appearance.ts` | Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call at line 48 |
|
||||
|
||||
## Build Order
|
||||
|
||||
1. Add `terminalFocusFollowsMouse` to `GlobalSettings` type and default. Build passes; no behavior change.
|
||||
2. Add `focusFollowsMouse` to `PaneStyleOptions`. Build passes; no behavior change.
|
||||
3. Create `focus-follows-mouse.ts` pure helper + `focus-follows-mouse.test.ts`. Tests pass.
|
||||
4. Update `createPaneDOM` signature and wire `mouseenter` listener.
|
||||
5. Add `handlePaneMouseEnter` in `PaneManager` and the new callback in `createPaneInternal`.
|
||||
6. Thread `terminalFocusFollowsMouse → focusFollowsMouse` through `resolvePaneStyleOptions` and `applyTerminalAppearance`.
|
||||
7. Add the settings toggle UI in `TerminalPane.tsx` and the search entry in `terminal-search.ts`.
|
||||
8. Run the manual smoke test checklist against a local build.
|
||||
|
||||
## Risks & Open Questions
|
||||
|
||||
**Risk: traversal flicker on three-pane layouts may feel worse than expected.** Mitigation: the user explicitly chose immediate switching for Ghostty parity. If complaints arrive, adding a `terminalFocusFollowsMouseDelayMs` setting is a clean follow-up without schema breakage.
|
||||
|
||||
**Risk: overlay assumption could break if a future contributor renders a modal inside the pane container.** Mitigation: a code comment on `handlePaneMouseEnter` documents the "overlays must live outside the pane container" invariant so it surfaces during review of any future overlay changes.
|
||||
|
||||
**Open: should the setting description link to Ghostty's docs?** Currently the description just says "Mirrors Ghostty's focus-follows-mouse setting." No external link. If Orca's settings UI supports hyperlinks in descriptions elsewhere, we could link to Ghostty's config docs — otherwise leave as plain text. Not a blocker.
|
||||
@@ -1,837 +0,0 @@
|
||||
# Focus-Follows-Mouse Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a Ghostty-style `focus-follows-mouse` behavior to Orca's terminal panes. When enabled, hovering a split activates it (cursor focus, input routing, opacity update) without a click.
|
||||
|
||||
**Architecture:** Extend `PaneStyleOptions` with a `focusFollowsMouse?: boolean` flag. Attach a `mouseenter` listener on each pane container in `createPaneDOM`. Delegate all gating logic to a pure `shouldFollowMouseFocus` helper (no DOM, unit-testable) that checks five gates: feature enabled, manager not destroyed, not already active, no mouse button held, window has OS focus. Thread the setting through the existing `resolvePaneStyleOptions → applyTerminalAppearance → setPaneStyleOptions` pipeline.
|
||||
|
||||
**Tech Stack:** TypeScript, Electron (renderer), React (settings UI), xterm.js (terminal), vanilla DOM (PaneManager), vitest (tests, node environment).
|
||||
|
||||
**Spec:** [docs/focus-follows-mouse-design.md](./focus-follows-mouse-design.md) — read this first for design rationale, edge case reasoning, and the full manual smoke test checklist.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**New files (2):**
|
||||
|
||||
- `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` — Pure gate helper. Exports `FocusFollowsMouseInput` type and `shouldFollowMouseFocus(input)` function. Zero DOM dependencies.
|
||||
- `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` — Unit tests for the pure helper. Table-style assertions, one case per gate.
|
||||
|
||||
**Modified files (9):**
|
||||
|
||||
- `src/shared/types.ts` — Add `terminalFocusFollowsMouse: boolean` field to `GlobalSettings` type.
|
||||
- `src/shared/constants.ts` — Add `terminalFocusFollowsMouse: false` default in `getDefaultSettings`.
|
||||
- `src/renderer/src/lib/pane-manager/pane-manager-types.ts` — Add `focusFollowsMouse?: boolean` to `PaneStyleOptions`.
|
||||
- `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` — Add `onMouseEnter` parameter to `createPaneDOM`, attach a `mouseenter` listener on the pane container.
|
||||
- `src/renderer/src/lib/pane-manager/pane-manager.ts` — Add `handlePaneMouseEnter` private method. Pass the new callback from `createPaneInternal`.
|
||||
- `src/renderer/src/lib/terminal-theme.ts` — Extend `resolvePaneStyleOptions` to pass `focusFollowsMouse` through.
|
||||
- `src/renderer/src/components/terminal-pane/terminal-appearance.ts` — Add `focusFollowsMouse: paneStyles.focusFollowsMouse` to the `setPaneStyleOptions` call.
|
||||
- `src/renderer/src/components/settings/TerminalPane.tsx` — Add a `SearchableSetting` toggle under Pane Styling.
|
||||
- `src/renderer/src/components/settings/terminal-search.ts` — Add an entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`.
|
||||
|
||||
Each task below produces a standalone, type-checking commit. The sequence is chosen so the build stays green at every step.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add `terminalFocusFollowsMouse` to GlobalSettings
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/types.ts` (insert field in `GlobalSettings` type around line 311)
|
||||
- Modify: `src/shared/constants.ts` (insert default in `getDefaultSettings` around line 83)
|
||||
|
||||
Because `GlobalSettings` is an exact type, both the type declaration and the `getDefaultSettings` initializer must be updated together or `tsc` fails. Combine both edits in one commit.
|
||||
|
||||
- [ ] **Step 1: Add the field to the GlobalSettings type**
|
||||
|
||||
Edit `src/shared/types.ts`. Find the line with `terminalDividerThicknessPx: number` (near line 311) and add the new field immediately after it:
|
||||
|
||||
```ts
|
||||
terminalDividerThicknessPx: number
|
||||
terminalFocusFollowsMouse: boolean
|
||||
terminalScrollbackBytes: number
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the default value in getDefaultSettings**
|
||||
|
||||
Edit `src/shared/constants.ts`. Find the line `terminalDividerThicknessPx: 3,` in `getDefaultSettings` (near line 83) and add the default immediately after it:
|
||||
|
||||
```ts
|
||||
terminalDividerThicknessPx: 3,
|
||||
terminalFocusFollowsMouse: false,
|
||||
terminalScrollbackBytes: 10_000_000,
|
||||
```
|
||||
|
||||
**Why default `false`:** Matches Ghostty's default. Existing users upgrading receive this default automatically because `src/main/persistence.ts:73-75` merges persisted settings over `defaults.settings` (`{ ...defaults.settings, ...parsed.settings }`), so any new field not in the persisted JSON falls through to the default. No explicit migration needed.
|
||||
|
||||
- [ ] **Step 3: Run typecheck to verify both edits**
|
||||
|
||||
Run: `pnpm run tc:web && pnpm run tc:node`
|
||||
Expected: Both pass. No output beyond the tsgo banner.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/types.ts src/shared/constants.ts
|
||||
git commit -m "feat: add terminalFocusFollowsMouse to GlobalSettings (default off)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Extend `PaneStyleOptions` with `focusFollowsMouse`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/src/lib/pane-manager/pane-manager-types.ts` (line 23-30, the `PaneStyleOptions` type)
|
||||
|
||||
- [ ] **Step 1: Add the field with a commented rationale**
|
||||
|
||||
Edit `src/renderer/src/lib/pane-manager/pane-manager-types.ts`. Replace the existing `PaneStyleOptions` type definition (lines 23-30) with:
|
||||
|
||||
```ts
|
||||
export type PaneStyleOptions = {
|
||||
splitBackground?: string
|
||||
paneBackground?: string
|
||||
inactivePaneOpacity?: number
|
||||
activePaneOpacity?: number
|
||||
opacityTransitionMs?: number
|
||||
dividerThicknessPx?: number
|
||||
// Why this behavior flag lives on "style" options: this type is already
|
||||
// the single runtime-settings bag the PaneManager exposes. Splitting into
|
||||
// separate style vs behavior types is a refactor worth its own change
|
||||
// when a second behavior flag lands. See docs/focus-follows-mouse-design.md.
|
||||
focusFollowsMouse?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
Run: `pnpm run tc:web`
|
||||
Expected: Pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/lib/pane-manager/pane-manager-types.ts
|
||||
git commit -m "refactor: add focusFollowsMouse to PaneStyleOptions type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create the pure `shouldFollowMouseFocus` gate helper (TDD)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts`
|
||||
- Create: `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
|
||||
|
||||
This is the only part of the feature with isolated business logic, so it's the only part we can TDD. We write the test file first, run it to see it fail, then create the implementation to make it pass.
|
||||
|
||||
- [ ] **Step 1: Write the failing test file**
|
||||
|
||||
Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts` with the following contents:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldFollowMouseFocus, type FocusFollowsMouseInput } from './focus-follows-mouse'
|
||||
|
||||
describe('shouldFollowMouseFocus', () => {
|
||||
// Base input where every gate passes. Individual tests flip one field
|
||||
// at a time to assert that each gate blocks focus independently.
|
||||
const base: FocusFollowsMouseInput = {
|
||||
featureEnabled: true,
|
||||
activePaneId: 1,
|
||||
hoveredPaneId: 2,
|
||||
mouseButtons: 0,
|
||||
windowHasFocus: true,
|
||||
managerDestroyed: false
|
||||
}
|
||||
|
||||
it('switches focus when all gates pass', () => {
|
||||
expect(shouldFollowMouseFocus(base)).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks when the feature is disabled', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, featureEnabled: false })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the manager is destroyed', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, managerDestroyed: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when hovering the already-active pane', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, hoveredPaneId: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks while the primary mouse button is held (buttons=1)', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks while the secondary mouse button is held (buttons=2)', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks while multiple buttons are held (buttons=3)', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, mouseButtons: 3 })).toBe(false)
|
||||
})
|
||||
|
||||
it('blocks when the window does not have OS focus', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, windowHasFocus: false })).toBe(false)
|
||||
})
|
||||
|
||||
// Defensive case: createInitialPane always sets activePaneId before any
|
||||
// mouse events are possible in production, but the gate must still behave
|
||||
// correctly if the state ever occurs (e.g. future refactor of init flow).
|
||||
it('switches when activePaneId is null (defensive)', () => {
|
||||
expect(shouldFollowMouseFocus({ ...base, activePaneId: null })).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test file to confirm it fails**
|
||||
|
||||
Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
|
||||
Expected: FAIL with an error resolving `./focus-follows-mouse` (the implementation file doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Create the implementation file**
|
||||
|
||||
Create `src/renderer/src/lib/pane-manager/focus-follows-mouse.ts` with the following contents:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Pure decision logic for the focus-follows-mouse feature. Kept free of
|
||||
* DOM/event dependencies so it can be unit-tested under vitest's node env.
|
||||
*
|
||||
* See docs/focus-follows-mouse-design.md for rationale behind each gate.
|
||||
*/
|
||||
|
||||
export type FocusFollowsMouseInput = {
|
||||
featureEnabled: boolean
|
||||
activePaneId: number | null
|
||||
hoveredPaneId: number
|
||||
mouseButtons: number // MouseEvent.buttons bitmask
|
||||
windowHasFocus: boolean // document.hasFocus()
|
||||
managerDestroyed: boolean
|
||||
}
|
||||
|
||||
/** Returns true iff the hovered pane should be activated. */
|
||||
export function shouldFollowMouseFocus(input: FocusFollowsMouseInput): boolean {
|
||||
if (!input.featureEnabled) return false
|
||||
if (input.managerDestroyed) return false
|
||||
if (input.activePaneId === input.hoveredPaneId) return false
|
||||
// Why event.buttons !== 0: any held mouse button means a selection or
|
||||
// a drag is in progress. Switching focus mid-drag would break xterm.js
|
||||
// text selection and the pane drag-to-reorder flow. This single check
|
||||
// also covers drag-to-reorder, since the drag is always button-held.
|
||||
// See pane-drag-reorder.ts:77-103 for the drag state lifecycle.
|
||||
if (input.mouseButtons !== 0) return false
|
||||
// Why document.hasFocus: if Orca isn't the OS-focused window, the mouse
|
||||
// event is from the user passing through on their way to another app.
|
||||
// Also returns false when DevTools is focused (DevTools runs in a
|
||||
// separate WebContents) — accepted. Users close DevTools or click to
|
||||
// resume normal behavior.
|
||||
if (!input.windowHasFocus) return false
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test file again to confirm all tests pass**
|
||||
|
||||
Run: `pnpm exec vitest run src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts`
|
||||
Expected: PASS — 9 tests passing, 0 failing.
|
||||
|
||||
- [ ] **Step 5: Run the full test suite to confirm nothing else broke**
|
||||
|
||||
Run: `pnpm run test`
|
||||
Expected: All pre-existing tests still pass, plus the 9 new ones.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/lib/pane-manager/focus-follows-mouse.ts src/renderer/src/lib/pane-manager/focus-follows-mouse.test.ts
|
||||
git commit -m "feat: add pure shouldFollowMouseFocus gate helper"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire the `mouseenter` listener through `createPaneDOM` and `PaneManager`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/src/lib/pane-manager/pane-lifecycle.ts` (update `createPaneDOM` signature around line 23-29, attach listener around line 126)
|
||||
- Modify: `src/renderer/src/lib/pane-manager/pane-manager.ts` (add import around line 28, update `createPaneInternal` around line 287, add `handlePaneMouseEnter` after it)
|
||||
|
||||
Both files must be updated in the same commit: `createPaneDOM` gains a required parameter that its sole caller (`createPaneInternal`) must provide, so partial landings break the build.
|
||||
|
||||
- [ ] **Step 1: Add the `onMouseEnter` parameter to `createPaneDOM`**
|
||||
|
||||
Edit `src/renderer/src/lib/pane-manager/pane-lifecycle.ts`. Find the `createPaneDOM` function signature (around line 23-29) and update it:
|
||||
|
||||
```ts
|
||||
export function createPaneDOM(
|
||||
id: number,
|
||||
options: PaneManagerOptions,
|
||||
dragState: DragReorderState,
|
||||
dragCallbacks: DragReorderCallbacks,
|
||||
onPointerDown: (id: number) => void,
|
||||
onMouseEnter: (id: number, event: MouseEvent) => void
|
||||
): ManagedPaneInternal {
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Attach the `mouseenter` listener next to the existing `pointerdown` listener**
|
||||
|
||||
In the same file, find the existing `pointerdown` listener (around line 126):
|
||||
|
||||
```ts
|
||||
// Focus handler: clicking a pane makes it active and explicitly focuses
|
||||
// the terminal. We must call focus: true here because after DOM reparenting
|
||||
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
|
||||
// native click-to-focus on its internal textarea may not fire reliably.
|
||||
container.addEventListener('pointerdown', () => {
|
||||
onPointerDown(id)
|
||||
})
|
||||
|
||||
return pane
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
// Focus handler: clicking a pane makes it active and explicitly focuses
|
||||
// the terminal. We must call focus: true here because after DOM reparenting
|
||||
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
|
||||
// native click-to-focus on its internal textarea may not fire reliably.
|
||||
container.addEventListener('pointerdown', () => {
|
||||
onPointerDown(id)
|
||||
})
|
||||
|
||||
// Focus-follows-mouse handler: when the setting is enabled, hovering a
|
||||
// pane makes it active. All gating (feature flag, drag-in-progress,
|
||||
// window focus, etc.) lives in the PaneManager callback — this layer
|
||||
// just forwards the event.
|
||||
container.addEventListener('mouseenter', (event) => {
|
||||
onMouseEnter(id, event)
|
||||
})
|
||||
|
||||
return pane
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Import the gate helper in `pane-manager.ts`**
|
||||
|
||||
Edit `src/renderer/src/lib/pane-manager/pane-manager.ts`. Find the existing imports (around line 20-28) and add a new import immediately after the existing pane-manager-related imports:
|
||||
|
||||
```ts
|
||||
import { createPaneDOM, openTerminal, attachWebgl, disposePane } from './pane-lifecycle'
|
||||
import { shouldFollowMouseFocus } from './focus-follows-mouse'
|
||||
import {
|
||||
findPaneChildren,
|
||||
removeDividers,
|
||||
promoteSibling,
|
||||
wrapInSplit,
|
||||
safeFit,
|
||||
refitPanesUnder
|
||||
} from './pane-tree-ops'
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Pass the new callback from `createPaneInternal`**
|
||||
|
||||
In the same file, find the `createPaneInternal` method (around line 287-302):
|
||||
|
||||
```ts
|
||||
private createPaneInternal(): ManagedPaneInternal {
|
||||
const id = this.nextPaneId++
|
||||
const pane = createPaneDOM(
|
||||
id,
|
||||
this.options,
|
||||
this.dragState,
|
||||
this.getDragCallbacks(),
|
||||
(paneId) => {
|
||||
if (!this.destroyed && this.activePaneId !== paneId) {
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
this.panes.set(id, pane)
|
||||
return pane
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
private createPaneInternal(): ManagedPaneInternal {
|
||||
const id = this.nextPaneId++
|
||||
const pane = createPaneDOM(
|
||||
id,
|
||||
this.options,
|
||||
this.dragState,
|
||||
this.getDragCallbacks(),
|
||||
(paneId) => {
|
||||
if (!this.destroyed && this.activePaneId !== paneId) {
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
},
|
||||
(paneId, event) => {
|
||||
this.handlePaneMouseEnter(paneId, event)
|
||||
}
|
||||
)
|
||||
this.panes.set(id, pane)
|
||||
return pane
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus-follows-mouse entry point. Collects gate inputs from the manager
|
||||
* and delegates to the pure gate helper.
|
||||
*
|
||||
* Invariant for future contributors: modal overlays (context menus, close
|
||||
* dialogs, command palette) must be rendered as portals/siblings OUTSIDE
|
||||
* the pane container. If a future overlay is ever rendered inside a .pane
|
||||
* element, mouseenter will still fire on the pane underneath and this
|
||||
* handler will incorrectly switch focus. Keep overlays out of the pane.
|
||||
*/
|
||||
private handlePaneMouseEnter(paneId: number, event: MouseEvent): void {
|
||||
if (
|
||||
shouldFollowMouseFocus({
|
||||
featureEnabled: this.styleOptions.focusFollowsMouse ?? false,
|
||||
activePaneId: this.activePaneId,
|
||||
hoveredPaneId: paneId,
|
||||
mouseButtons: event.buttons,
|
||||
windowHasFocus: document.hasFocus(),
|
||||
managerDestroyed: this.destroyed
|
||||
})
|
||||
) {
|
||||
this.setActivePane(paneId, { focus: true })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run typecheck to verify both files**
|
||||
|
||||
Run: `pnpm run tc:web`
|
||||
Expected: Pass. If it fails with "Expected 6 arguments, but got 5" at `createPaneDOM`, you forgot to update the caller in `createPaneInternal`.
|
||||
|
||||
- [ ] **Step 6: Run the full test suite**
|
||||
|
||||
Run: `pnpm run test`
|
||||
Expected: All tests pass, including the 9 from Task 3.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/lib/pane-manager/pane-lifecycle.ts src/renderer/src/lib/pane-manager/pane-manager.ts
|
||||
git commit -m "feat: wire mouseenter listener through PaneManager for focus-follows-mouse"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Thread the setting through `resolvePaneStyleOptions` and `applyTerminalAppearance`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/src/lib/terminal-theme.ts` (around line 103-120)
|
||||
- Modify: `src/renderer/src/components/terminal-pane/terminal-appearance.ts` (around line 48-55)
|
||||
|
||||
At this point the gate helper reads `this.styleOptions.focusFollowsMouse`, which is always `undefined` because nothing populates it yet. This task plumbs the user setting into the runtime options bag.
|
||||
|
||||
- [ ] **Step 1: Extend `resolvePaneStyleOptions` to accept and pass the boolean**
|
||||
|
||||
**Callers of `resolvePaneStyleOptions`** (verified via grep — there are two, both pass a full `GlobalSettings` so widening the `Pick` union is non-breaking):
|
||||
|
||||
1. `src/renderer/src/components/terminal-pane/terminal-appearance.ts:21` — the runtime call from `applyTerminalAppearance`. You WILL modify this file in Step 2.
|
||||
2. `src/renderer/src/components/settings/TerminalPane.tsx:68` — the settings-pane preview call that powers the theme preview. You will NOT need to modify this file in this task (it passes full `GlobalSettings`, which will contain `terminalFocusFollowsMouse` after Task 1). The new field will silently appear in the returned object and be ignored by the preview — that's fine.
|
||||
|
||||
Edit `src/renderer/src/lib/terminal-theme.ts`. Find the `resolvePaneStyleOptions` function (lines 103-118). The current full body is:
|
||||
|
||||
```ts
|
||||
export function resolvePaneStyleOptions(
|
||||
settings: Pick<
|
||||
GlobalSettings,
|
||||
| 'terminalInactivePaneOpacity'
|
||||
| 'terminalActivePaneOpacity'
|
||||
| 'terminalPaneOpacityTransitionMs'
|
||||
| 'terminalDividerThicknessPx'
|
||||
>
|
||||
) {
|
||||
return {
|
||||
inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1),
|
||||
activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1),
|
||||
opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000),
|
||||
dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
export function resolvePaneStyleOptions(
|
||||
settings: Pick<
|
||||
GlobalSettings,
|
||||
| 'terminalInactivePaneOpacity'
|
||||
| 'terminalActivePaneOpacity'
|
||||
| 'terminalPaneOpacityTransitionMs'
|
||||
| 'terminalDividerThicknessPx'
|
||||
| 'terminalFocusFollowsMouse'
|
||||
>
|
||||
) {
|
||||
return {
|
||||
inactivePaneOpacity: clampNumber(settings.terminalInactivePaneOpacity, 0, 1),
|
||||
activePaneOpacity: clampNumber(settings.terminalActivePaneOpacity, 0, 1),
|
||||
opacityTransitionMs: clampNumber(settings.terminalPaneOpacityTransitionMs, 0, 5000),
|
||||
dividerThicknessPx: clampNumber(settings.terminalDividerThicknessPx, 1, 32),
|
||||
// Why no clamping: boolean pass-through. Both true and false are valid.
|
||||
focusFollowsMouse: settings.terminalFocusFollowsMouse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass `focusFollowsMouse` to `setPaneStyleOptions` in `applyTerminalAppearance`**
|
||||
|
||||
Edit `src/renderer/src/components/terminal-pane/terminal-appearance.ts`. Find the `setPaneStyleOptions` call (around line 48-55):
|
||||
|
||||
```ts
|
||||
manager.setPaneStyleOptions({
|
||||
splitBackground: paneBackground,
|
||||
paneBackground,
|
||||
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
|
||||
activePaneOpacity: paneStyles.activePaneOpacity,
|
||||
opacityTransitionMs: paneStyles.opacityTransitionMs,
|
||||
dividerThicknessPx: paneStyles.dividerThicknessPx
|
||||
})
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
manager.setPaneStyleOptions({
|
||||
splitBackground: paneBackground,
|
||||
paneBackground,
|
||||
inactivePaneOpacity: paneStyles.inactivePaneOpacity,
|
||||
activePaneOpacity: paneStyles.activePaneOpacity,
|
||||
opacityTransitionMs: paneStyles.opacityTransitionMs,
|
||||
dividerThicknessPx: paneStyles.dividerThicknessPx,
|
||||
focusFollowsMouse: paneStyles.focusFollowsMouse
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run: `pnpm run tc:web`
|
||||
Expected: Pass. If it fails saying `paneStyles.focusFollowsMouse` is not assignable (e.g. `undefined` vs `boolean`), double-check that Step 1 added the field to `resolvePaneStyleOptions`'s returned object.
|
||||
|
||||
- [ ] **Step 4: Run the full test suite**
|
||||
|
||||
Run: `pnpm run test`
|
||||
Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/lib/terminal-theme.ts src/renderer/src/components/terminal-pane/terminal-appearance.ts
|
||||
git commit -m "feat: thread focusFollowsMouse through pane-style pipeline"
|
||||
```
|
||||
|
||||
**At this point the feature is functionally complete internally.** If a developer manually flipped `terminalFocusFollowsMouse: true` in the persisted settings file, it would work. Tasks 6 and 7 add the discoverable user-facing surface.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Add the settings toggle UI in TerminalPane.tsx
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/src/components/settings/TerminalPane.tsx` (disable comment at line 1; toggle insertion around line 290, the closing of the Pane Styling grid)
|
||||
|
||||
**⚠️ Line-count constraint:** `TerminalPane.tsx` is currently exactly 400 lines total (and oxlint passes, so effective non-blank/non-comment lines are ≤ 400). The project's `.oxlintrc.json` sets `max-lines: 400` for `.tsx` files (line 71). Adding the new toggle (~33 lines) would push the file over the limit and the commit in Step 3 would fail under the lint-staged `oxlint` pre-commit hook.
|
||||
|
||||
**The project's established workaround** is a file-scoped `eslint-disable` comment with justification — see `src/renderer/src/components/settings/GeneralPane.tsx:1-3`, which does the same for the same reason (484 lines, all owning general settings UI). Step 1 below applies this pattern to `TerminalPane.tsx` _before_ adding the toggle, so the file never goes over the limit.
|
||||
|
||||
- [ ] **Step 1: Add the `max-lines` eslint-disable at the top of TerminalPane.tsx**
|
||||
|
||||
Edit `src/renderer/src/components/settings/TerminalPane.tsx`. The file currently starts with:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
```
|
||||
|
||||
Prepend a file-level disable comment with an explicit justification (matching the `GeneralPane.tsx` precedent):
|
||||
|
||||
```tsx
|
||||
/* eslint-disable max-lines -- Why: TerminalPane is the single owner of all terminal settings UI;
|
||||
splitting individual settings into separate files would scatter related controls without a
|
||||
meaningful abstraction boundary. Mirrors the same decision made for GeneralPane.tsx. */
|
||||
import { useState } from 'react'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `SearchableSetting` toggle below the Pane Styling grid**
|
||||
|
||||
Edit `src/renderer/src/components/settings/TerminalPane.tsx`. Find the end of the Pane Styling grid (around line 290):
|
||||
|
||||
```tsx
|
||||
<SearchableSetting
|
||||
title="Divider Thickness"
|
||||
description="Thickness of the pane divider line."
|
||||
keywords={['pane', 'divider', 'thickness']}
|
||||
>
|
||||
<NumberField
|
||||
label="Divider Thickness"
|
||||
description="Thickness of the pane divider line."
|
||||
value={paneStyleOptions.dividerThicknessPx}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
max={32}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalDividerThicknessPx: clampNumber(value, 1, 32)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
Insert a new `SearchableSetting` toggle between the closing `</div>` of the grid and the closing `</section>`:
|
||||
|
||||
```tsx
|
||||
<SearchableSetting
|
||||
title="Divider Thickness"
|
||||
description="Thickness of the pane divider line."
|
||||
keywords={['pane', 'divider', 'thickness']}
|
||||
>
|
||||
<NumberField
|
||||
label="Divider Thickness"
|
||||
description="Thickness of the pane divider line."
|
||||
value={paneStyleOptions.dividerThicknessPx}
|
||||
defaultValue={1}
|
||||
min={1}
|
||||
max={32}
|
||||
step={1}
|
||||
suffix="px"
|
||||
onChange={(value) =>
|
||||
updateSettings({
|
||||
terminalDividerThicknessPx: clampNumber(value, 1, 32)
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</div>
|
||||
|
||||
<SearchableSetting
|
||||
title="Focus Follows Mouse"
|
||||
description="Hovering a terminal pane activates it without needing to click. Mirrors Ghostty's focus-follows-mouse setting."
|
||||
keywords={['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']}
|
||||
className="flex items-center justify-between gap-4 px-1 py-2"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<Label>Focus Follows Mouse</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hovering a terminal pane activates it without needing to click.
|
||||
Mirrors Ghostty's focus-follows-mouse setting. Selections and
|
||||
window switching stay safe.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settings.terminalFocusFollowsMouse}
|
||||
onClick={() =>
|
||||
updateSettings({
|
||||
terminalFocusFollowsMouse: !settings.terminalFocusFollowsMouse
|
||||
})
|
||||
}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
|
||||
settings.terminalFocusFollowsMouse ? 'bg-foreground' : 'bg-muted-foreground/30'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none block size-3.5 rounded-full bg-background shadow-sm transition-transform ${
|
||||
settings.terminalFocusFollowsMouse ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</SearchableSetting>
|
||||
</section>
|
||||
```
|
||||
|
||||
**Placement rationale:** The new toggle sits _below_ the existing 2-column `grid` (not inside it) because a full-width toggle row doesn't balance well next to numeric input fields in a 2-column layout.
|
||||
|
||||
- [ ] **Step 3: Run typecheck and lint**
|
||||
|
||||
Run: `pnpm run tc:web && pnpm exec oxlint src/renderer/src/components/settings/TerminalPane.tsx`
|
||||
Expected: Both pass with 0 errors. The explicit `oxlint` run is a safety net: if the file somehow still trips `max-lines` (e.g., the disable comment was formatted incorrectly), catch it here rather than in the pre-commit hook.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/settings/TerminalPane.tsx
|
||||
git commit -m "feat: add Focus Follows Mouse toggle to Terminal settings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Add the settings search entry
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/renderer/src/components/settings/terminal-search.ts` (around line 34-45, the `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` array)
|
||||
|
||||
- [ ] **Step 1: Add the new entry to `TERMINAL_PANE_STYLE_SEARCH_ENTRIES`**
|
||||
|
||||
Edit `src/renderer/src/components/settings/terminal-search.ts`. Find `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` (around line 34):
|
||||
|
||||
```ts
|
||||
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Inactive Pane Opacity',
|
||||
description: 'Opacity applied to panes that are not currently active.',
|
||||
keywords: ['pane', 'opacity', 'dimming']
|
||||
},
|
||||
{
|
||||
title: 'Divider Thickness',
|
||||
description: 'Thickness of the pane divider line.',
|
||||
keywords: ['pane', 'divider', 'thickness']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Replace it with:
|
||||
|
||||
```ts
|
||||
export const TERMINAL_PANE_STYLE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
|
||||
{
|
||||
title: 'Inactive Pane Opacity',
|
||||
description: 'Opacity applied to panes that are not currently active.',
|
||||
keywords: ['pane', 'opacity', 'dimming']
|
||||
},
|
||||
{
|
||||
title: 'Divider Thickness',
|
||||
description: 'Thickness of the pane divider line.',
|
||||
keywords: ['pane', 'divider', 'thickness']
|
||||
},
|
||||
{
|
||||
title: 'Focus Follows Mouse',
|
||||
description: 'Hovering a terminal pane activates it without needing to click.',
|
||||
keywords: ['focus', 'follows', 'mouse', 'hover', 'pane', 'ghostty', 'active']
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Note:** No changes needed to `TERMINAL_PANE_SEARCH_ENTRIES` (the aggregator at line 86) because it spreads `TERMINAL_PANE_STYLE_SEARCH_ENTRIES` automatically.
|
||||
|
||||
- [ ] **Step 2: Run typecheck and tests**
|
||||
|
||||
Run: `pnpm run tc:web && pnpm run test`
|
||||
Expected: Both pass.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/settings/terminal-search.ts
|
||||
git commit -m "feat: add Focus Follows Mouse to settings search index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Manual smoke test verification
|
||||
|
||||
This task does not produce a commit. It verifies the feature end-to-end in a running build and catches any issue that couldn't be caught by typecheck or unit tests (DOM event interactions, xterm.js behavior, Electron window focus, visual correctness).
|
||||
|
||||
- [ ] **Step 1: Launch the app in dev mode**
|
||||
|
||||
Run: `pnpm run dev`
|
||||
Expected: Orca opens. Wait for it to fully initialize (worktree list populates).
|
||||
|
||||
- [ ] **Step 2: Enable the setting**
|
||||
|
||||
Open Settings (gear icon or menu) → Terminal → Pane Styling. Find the "Focus Follows Mouse" toggle. Toggle it ON.
|
||||
|
||||
Expected: The toggle visibly flips to the on state (dark background, white dot on the right).
|
||||
|
||||
- [ ] **Step 3: Restart the app and verify persistence**
|
||||
|
||||
Close Orca. Run `pnpm run dev` again. Open Settings → Terminal → Pane Styling.
|
||||
|
||||
Expected: The toggle is still ON (the setting persisted to the JSON state file).
|
||||
|
||||
- [ ] **Step 4: Verify multi-split hover activation**
|
||||
|
||||
Open a worktree with a terminal. Create at least 2 splits (use the split-pane UI or keyboard shortcut). Click on pane A to make it active. Move the mouse over pane B without clicking.
|
||||
|
||||
Expected: Pane B's opacity updates to the active value, pane B's cursor starts blinking, and typing on the keyboard routes to pane B's shell.
|
||||
|
||||
- [ ] **Step 5: Verify text selection is not broken mid-drag**
|
||||
|
||||
In a single pane, click-drag to select text. Start the drag in pane A and continue dragging the selection into pane B.
|
||||
|
||||
Expected: The text selection extends normally. Focus does NOT switch from pane A to pane B during the drag. When you release the mouse button, the selection is finalized in pane A. (If focus switches mid-drag, the `mouseButtons !== 0` gate is broken.)
|
||||
|
||||
- [ ] **Step 6: Verify pane drag-to-reorder is not broken**
|
||||
|
||||
Hover a pane so the drag handle (the top strip) appears. Click-drag the pane by its drag handle to a drop zone.
|
||||
|
||||
Expected: The drag animation completes normally. No focus flicker during the drag.
|
||||
|
||||
- [ ] **Step 7: Verify window-focus gating with Cmd-Tab**
|
||||
|
||||
With 2+ splits and the setting enabled: click pane A to make it active. Cmd-Tab to another app. Move the mouse over pane B (still visible). Cmd-Tab back to Orca.
|
||||
|
||||
Expected: Pane A (the previously-active pane) is still active — focus did NOT switch to pane B just because the mouse was over it when Orca regained focus.
|
||||
|
||||
- [ ] **Step 8: Verify DevTools focus pauses the feature**
|
||||
|
||||
With 2+ splits, open Orca DevTools (View → Toggle Developer Tools, or Cmd-Opt-I). Click inside the DevTools panel to focus it. Move the mouse over an inactive pane.
|
||||
|
||||
Expected: Focus does NOT switch. `document.hasFocus()` returns false while DevTools is focused, so the gate blocks. Close DevTools, wiggle the mouse → focus-follows-mouse resumes on the next `mouseenter`.
|
||||
|
||||
- [ ] **Step 9: Verify modal overlay safety**
|
||||
|
||||
With 2+ splits, open the close-terminal confirmation dialog (Cmd-W or the close button on a pane). While the confirmation dialog is visible, move the mouse over a different pane.
|
||||
|
||||
Expected: Focus does NOT switch. The modal portal sits above the pane DOM, so `mouseenter` on the pane never fires while the modal is open.
|
||||
|
||||
- [ ] **Step 10: Verify URL hover tooltip still works**
|
||||
|
||||
With the setting ON, hover a URL in an inactive pane (run `echo https://example.com` in one pane, then hover that URL from another pane).
|
||||
|
||||
Expected: (1) The pane activates via focus-follows-mouse. (2) The `Cmd+click to open` URL tooltip appears in the bottom-left of the newly-activated pane. (If the tooltip fails to appear or flickers, there's an interaction issue between `setActivePane → terminal.focus()` and xterm.js's `WebLinksAddon` hover tracking — worth filing and debugging separately.)
|
||||
|
||||
- [ ] **Step 11: Verify disabling the setting stops the behavior**
|
||||
|
||||
Toggle the setting OFF in Settings. Return to the terminal view. Hover an inactive pane.
|
||||
|
||||
Expected: Focus does NOT switch on hover. Click on the pane — the click still activates it. (Disabling the feature must not regress click-to-focus.)
|
||||
|
||||
- [ ] **Step 12: Verify single-pane layout has no regressions**
|
||||
|
||||
Close all splits so only one pane remains. Toggle the setting ON. Hover the pane.
|
||||
|
||||
Expected: No crash, no console errors. The single pane stays focused. (The `activePaneId === hoveredPaneId` gate makes this a no-op.)
|
||||
|
||||
- [ ] **Step 13: Verify three-pane traversal flicker (informational)**
|
||||
|
||||
Create a horizontal layout of 3 panes: A | B | C. Click pane A. Move the mouse quickly from A straight to C.
|
||||
|
||||
Expected: A brief flicker of activation on B as the mouse traverses it (this is the accepted cost of Ghostty-parity immediate switching, documented in the spec). Confirm the flicker is tolerable. If it feels bad, the spec has a follow-up plan for adding a settle delay.
|
||||
|
||||
- [ ] **Step 14: Mark the plan complete**
|
||||
|
||||
If all 13 checks above passed, the feature is complete. The PR description should embed this smoke test checklist for the reviewer to verify.
|
||||
|
||||
---
|
||||
|
||||
## Verification Summary
|
||||
|
||||
After all 8 tasks are complete, the final state should be:
|
||||
|
||||
- 7 new commits on the branch (one per task, Task 8 is verify-only).
|
||||
- 9 new unit tests passing (`focus-follows-mouse.test.ts`).
|
||||
- Full typecheck passes (`pnpm run tc`).
|
||||
- Full test suite passes (`pnpm run test`).
|
||||
- Manual smoke test checklist (Task 8 Steps 1-13) passes in `pnpm run dev`.
|
||||
- The setting is discoverable via Settings → Terminal → Pane Styling AND via the settings search box (queries: "focus", "follows", "mouse", "hover", "ghostty").
|
||||
- The setting defaults to **off** for both new and upgrading users.
|
||||
@@ -0,0 +1,132 @@
|
||||
# GitHub Work Item Drawer: Fix Reopen Flash
|
||||
|
||||
## Symptom
|
||||
|
||||
After #1655, reopening a GH issue/PR drawer can still flash a loading/skeleton frame before cached content appears.
|
||||
|
||||
## Verified root cause
|
||||
|
||||
`GitHubItemDialog` currently paints from component state (`details`, `loading`, `error`) that is updated in an effect (`src/renderer/src/components/GitHubItemDialog.tsx:2480+`).
|
||||
|
||||
Current sequence on close/reopen of the same item:
|
||||
|
||||
1. Close path runs effect branch with no `workItem` and calls `setDetails(null)`.
|
||||
2. Reopen renders once with `details === null`.
|
||||
3. Effect reads `workItemDetailsCache` and then calls `setDetails(painted)`.
|
||||
4. Second render shows cached content.
|
||||
|
||||
With sheet animation, that first empty paint is visible as a flash.
|
||||
|
||||
## Refactor direction
|
||||
|
||||
Move drawer paint state to the module cache and subscribe with `useSyncExternalStore`. The drawer should read the cache entry synchronously during render.
|
||||
|
||||
This is not optional if we want reopen to paint cached content on first render.
|
||||
|
||||
## Required cache-store changes
|
||||
|
||||
Add a store subscription layer around `workItemDetailsCache`:
|
||||
|
||||
- `subscribeWorkItemDetailsCache(listener)`
|
||||
- `notifyWorkItemDetailsCache()`
|
||||
|
||||
Call `notifyWorkItemDetailsCache()` after every actual cache mutation:
|
||||
|
||||
- `touchWorkItemDetailsCache`
|
||||
- `invalidateWorkItemDetailsCacheForKey`
|
||||
- `invalidateWorkItemDetailsCacheByMatch`
|
||||
|
||||
Also increment `workItemDetailsCacheGeneration` on **all** invalidations, not only `invalidateWorkItemDetailsCacheByMatch`. Otherwise an in-flight fetch launched before exact-key invalidation can repopulate stale data.
|
||||
|
||||
## `useSyncExternalStore` contract details
|
||||
|
||||
Use:
|
||||
|
||||
```ts
|
||||
const cachedEntry = useSyncExternalStore(
|
||||
subscribeWorkItemDetailsCache,
|
||||
() => (detailsCacheKey ? workItemDetailsCache.get(detailsCacheKey) : undefined)
|
||||
)
|
||||
```
|
||||
|
||||
Important constraints:
|
||||
|
||||
- `getSnapshot` must return a referentially stable value when store data has not changed.
|
||||
- Returning the map entry object is valid **only if** writes replace entry identity (`delete+set` with a new entry object).
|
||||
- Do not build fresh wrapper objects in `getSnapshot` (that would violate stability and can cause render loops).
|
||||
|
||||
## Component state changes
|
||||
|
||||
Remove component-local `details/loading/error` state for work-item details.
|
||||
|
||||
Derive view state from `cachedEntry`:
|
||||
|
||||
- `details = cachedEntry?.details ?? null`
|
||||
- blocking `loading = !!cachedEntry?.pending && !cachedEntry?.details`
|
||||
- blocking `error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null`
|
||||
|
||||
Keep stale-on-error behavior by preserving `details` in cache while setting `error`.
|
||||
|
||||
## Effect responsibilities after refactor
|
||||
|
||||
The main effect should:
|
||||
|
||||
- handle item-change bookkeeping (`prevItemIdRef`, `optimisticCommentsRef`, `setTab`)
|
||||
- decide freshness and in-flight dedupe
|
||||
- launch fetch and write results/errors/pending back into cache
|
||||
|
||||
The effect should not call `setDetails`, `setLoading`, or `setError` for work-item details.
|
||||
|
||||
## Optimistic comment race and merge rules
|
||||
|
||||
`appendOptimisticComment` must write through cache only (no local `setDetails`).
|
||||
|
||||
Rules:
|
||||
|
||||
- push comment into `optimisticCommentsRef`
|
||||
- patch cache entry comments (if present), mark stale (`fetchedAt: 0`), notify
|
||||
- derive rendered comments from cached details plus optimistic refs
|
||||
|
||||
Dependency pitfall:
|
||||
|
||||
- do not depend on a freshly-created array from `optimisticCommentsRef.current` in `useMemo`
|
||||
- memo should key off stable signals (cache entry identity); cache notifications will re-render after optimistic writes anyway
|
||||
|
||||
This avoids unnecessary recomputation and avoids accidental infinite rerender patterns from unstable deps.
|
||||
|
||||
## Invalidation while drawer is open
|
||||
|
||||
When `invalidateWorkItemDetailsCacheByMatch` deletes the current key while drawer is mounted:
|
||||
|
||||
- subscription notifies immediately
|
||||
- render sees `cachedEntry === undefined`
|
||||
- UI falls back to loading/empty state and effect triggers refetch
|
||||
|
||||
This is expected and preferred over showing stale state.
|
||||
|
||||
## Fetch race constraints (must keep)
|
||||
|
||||
Keep current protections:
|
||||
|
||||
- per-key in-flight dedupe via `entry.pending`
|
||||
- generation guard to block stale writes after invalidation
|
||||
- stale-on-null handling (`result === null` must not wipe existing valid cached details)
|
||||
|
||||
Add one missing consistency rule:
|
||||
|
||||
- if fetch resolves after invalidation and key was dropped, do not recreate entry unless generation still matches launch generation.
|
||||
|
||||
## Scope of deletion
|
||||
|
||||
Delete only the work-item-detail local state and call sites:
|
||||
|
||||
- `useState<GitHubWorkItemDetails | null>` (`details`)
|
||||
- `useState<boolean>` (`loading`) for this data path
|
||||
- `useState<string | null>` (`error`) for this data path
|
||||
- corresponding `setDetails`/`setLoading`/`setError` calls in the details effect and optimistic comment append path
|
||||
|
||||
Do not touch unrelated loading/error state in other subviews.
|
||||
|
||||
## Expected result
|
||||
|
||||
Reopen of a cached item paints content on first render (no flash). Background refetch still runs when stale, and cross-window/local invalidations remain authoritative.
|
||||
@@ -0,0 +1,118 @@
|
||||
# GitHub Work Item Drawer: Cache & Latency
|
||||
|
||||
## Problem
|
||||
|
||||
`GitHubItemDialog` clears `details` to `null` on every open and waits for `gh:workItemDetails` before rendering. Reopening the same item therefore pays full IPC + `gh` process startup latency again.
|
||||
|
||||
The slow path is mostly process overhead and fan-out in `src/main/github/work-item-details.ts`, not GitHub API quota.
|
||||
|
||||
## What is true today
|
||||
|
||||
- `gh api --cache` reduces network/API pressure, but it does not remove local process startup, JSON parsing, or IPC serialization.
|
||||
- A 304 response does not consume primary REST rate-limit quota, but it is still a network round-trip. With `--cache`, many calls are served from local cache anyway.
|
||||
- The issue details path is currently multi-call:
|
||||
1. `getWorkItem` (`/issues/:n` or PR fallback).
|
||||
2. `getIssueBodyAndComments` (`/issues/:n` and `/issues/:n/comments`).
|
||||
3. `getWorkItemParticipants` (GraphQL participants).
|
||||
4. `getMentionParticipants` (GraphQL user hydration for visible authors).
|
||||
|
||||
So “3 spawns” understates the real hot path.
|
||||
|
||||
## Goals
|
||||
|
||||
- Reopen latency should be near-instant for recently viewed items.
|
||||
- Cold issue-open latency should drop by reducing `gh` command count.
|
||||
- Correctness must survive local mutations, repo/source switches, and multi-window usage.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No disk-persistent cache in this iteration.
|
||||
- No attempt to replace PR files/diff path with GraphQL.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Renderer SWR cache with paint-first reads
|
||||
|
||||
Add a module-level LRU cache in `GitHubItemDialog.tsx` keyed by:
|
||||
|
||||
`repoPath + issueSourcePreference + type + number`
|
||||
|
||||
Cache value:
|
||||
|
||||
- `details`
|
||||
- `fetchedAt`
|
||||
- `pending?: Promise`
|
||||
- `error?: string`
|
||||
|
||||
Behavior:
|
||||
|
||||
1. On open, render cached `details` immediately when present (do not clear to `null`).
|
||||
2. If entry age <= `FRESH_MS` (30s), skip fetch.
|
||||
3. If stale or missing, fetch in background and replace cache + UI when resolved.
|
||||
4. On fetch failure with cached data, keep stale data visible and show non-blocking error state.
|
||||
5. On fetch failure without cached data, show blocking error state.
|
||||
|
||||
### 2. In-flight dedupe in renderer
|
||||
|
||||
Store a single pending promise per key in the same cache entry. Concurrent opens/re-renders for the same key must await the same promise.
|
||||
|
||||
This dedupe must be keyed the same as the data cache key to avoid cross-repo or cross-source collisions.
|
||||
|
||||
### 3. Explicit invalidation rules
|
||||
|
||||
“Background refetch is authoritative” is necessary but not sufficient.
|
||||
|
||||
Invalidate (or patch + mark stale) on successful local mutations:
|
||||
|
||||
- issue state/labels/assignees/body edits
|
||||
- new comments/reactions
|
||||
- PR review comment create/resolve
|
||||
|
||||
Scope:
|
||||
|
||||
- per-item key in current window
|
||||
- broadcast to other windows via main-process event (`gh:workItemMutated`) so their caches invalidate too
|
||||
|
||||
Also invalidate on context switches:
|
||||
|
||||
- `repoPath` change
|
||||
- issue source preference change (`origin`/`upstream`/`auto`)
|
||||
- sign-out/account change
|
||||
|
||||
For out-of-band mutations (web UI/other tools), rely on TTL + manual refresh action in drawer header.
|
||||
|
||||
### 4. Main-process issue fetch collapse (GraphQL-first)
|
||||
|
||||
Do not claim “one call returns everything” unless we actually ship and verify it.
|
||||
|
||||
Feasible single GraphQL issue query fields:
|
||||
|
||||
- issue body
|
||||
- labels
|
||||
- assignees
|
||||
- participants
|
||||
- comments(first: N) with author login + avatarUrl + body + createdAt + url
|
||||
|
||||
Limits and required fallbacks:
|
||||
|
||||
- GraphQL pagination still applies (`first: 100`). More comments require paging.
|
||||
- Some comment authors can be null/ghost; renderer must keep existing fallback behavior.
|
||||
- If GraphQL fails (permissions, partial errors), fall back to current REST+GraphQL path.
|
||||
- Keep `getMentionParticipants` only if query omits non-participant visible authors; otherwise remove it.
|
||||
|
||||
PR path remains unchanged in this doc. PR file/diff/check behavior is intentionally out of scope.
|
||||
|
||||
## Edge cases this design must handle
|
||||
|
||||
- Reopen same item after optimistic comment: optimistic comment must survive stale cache reads until authoritative fetch includes it.
|
||||
- Switching between upstream/origin issue source with same issue number must never reuse the wrong cache entry.
|
||||
- Item-type collision (`issue #123` vs `pr #123`) must never reuse cache entry.
|
||||
- Drawer close/open races: stale request responses must still be dropped (`requestIdRef` guard stays).
|
||||
- Unauthorized/404 should not overwrite valid cached data with empty shells.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Implement renderer SWR + in-flight dedupe + stale-on-error behavior.
|
||||
2. Add mutation-driven invalidation and cross-window invalidation event.
|
||||
3. Implement GraphQL-first issue details with strict fallback.
|
||||
4. Keep telemetry: measure open-to-first-paint and open-to-fresh-data before/after.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Homebrew Cask
|
||||
|
||||
Orca is distributed on macOS as a Homebrew Cask. This doc covers how the cask
|
||||
is wired up, how it interacts with Orca's in-app updater, and what to do when
|
||||
something drifts.
|
||||
|
||||
## For users
|
||||
|
||||
```bash
|
||||
brew tap stablyai/orca
|
||||
brew install --cask orca
|
||||
```
|
||||
|
||||
Or in one command: `brew install --cask stablyai/orca/orca`. Both forms install
|
||||
`Orca.app` into `/Applications`. Subsequent updates are handled by Orca's
|
||||
in-app updater (electron-updater) — `brew upgrade` is a no-op because the
|
||||
cask is marked `auto_updates true`. Users who want brew to force-reinstall
|
||||
from the cask version can pass `--greedy`.
|
||||
|
||||
## How the pieces fit together
|
||||
|
||||
There are three moving parts:
|
||||
|
||||
1. **`Casks/orca.rb`** in this repo — source of truth for the cask file.
|
||||
Edited by automation on every stable release; can be edited manually if
|
||||
metadata (zap list, macOS floor, desc) needs to change.
|
||||
2. **`stablyai/homebrew-orca`** — the public tap users consume. Mirrors
|
||||
`Casks/orca.rb` from this repo via the bump workflow. Nothing else lives
|
||||
there; do not hand-edit.
|
||||
3. **`.github/workflows/homebrew-bump.yml`** — runs on `release.published`
|
||||
for stable tags (skips `-rc.*` and GitHub pre-releases). Downloads the
|
||||
two DMGs, rewrites `version`/`sha256` in `Casks/orca.rb`, pushes a PR to
|
||||
the tap, and auto-merges it.
|
||||
|
||||
### Why the cask uses `auto_updates true`
|
||||
|
||||
`electron-updater` (`src/main/updater.ts`) downloads each new release and
|
||||
swaps `Orca.app` in place. Homebrew-Cask's tracking of installed versions is
|
||||
based on the cask's `version:` field plus an install receipt — so when the
|
||||
app mutates itself, brew's metadata drifts. `auto_updates true` tells
|
||||
Homebrew this is expected: `brew outdated` and `brew upgrade` ignore the
|
||||
cask unless `--greedy` is passed. Uninstall still works normally.
|
||||
|
||||
The hidden requirement: Squirrel.Mac (what electron-updater uses) needs
|
||||
write access to `/Applications/Orca.app`. Cask installs into `/Applications`
|
||||
with user ownership by default, so this works out of the box. If a user ever
|
||||
`sudo`-installs or the bundle becomes root-owned, the in-app updater will
|
||||
fail silently; they'd need `brew reinstall --cask orca` or `brew upgrade
|
||||
--cask orca --greedy` to recover.
|
||||
|
||||
## One-time setup (already done, documented here for reference)
|
||||
|
||||
1. **Tap repo**: `stablyai/homebrew-orca` on GitHub. Must be named
|
||||
`homebrew-<anything>` so `brew tap stablyai/orca` resolves. Public.
|
||||
2. **Auto-merge** enabled in tap repo settings.
|
||||
3. **Seeded** with a copy of `Casks/orca.rb` for the initial version.
|
||||
|
||||
The workflow authenticates as the existing `buf0-bot` GitHub App
|
||||
(installed org-wide on stablyai), reusing the `BUFO_BOT_PRIVATE_KEY`
|
||||
secret that's already on `stablyai/orca` for `track-community-prs.yaml`.
|
||||
No PAT rotation, no new secret.
|
||||
|
||||
## Submitting to homebrew-cask (the main tap)
|
||||
|
||||
The `stablyai/homebrew-orca` tap ships first. Once Orca has stable user
|
||||
demand and has been on a release cadence for ~30+ days without the version
|
||||
string breaking conventions (no `-rc`, no date suffixes), we can submit to
|
||||
`Homebrew/homebrew-cask` so users can `brew install --cask orca` without a
|
||||
tap prefix. That submission is a one-time PR against
|
||||
https://github.com/Homebrew/homebrew-cask; subsequent bumps to the main tap
|
||||
are handled by their own [autobump infrastructure](https://docs.brew.sh/Autobump)
|
||||
as long as the release cadence matches their expectations. T3 Code's
|
||||
[cask](https://github.com/Homebrew/homebrew-cask/blob/main/Casks/t/t3-code.rb)
|
||||
is a close structural analogue.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"electron-updater says no update available, but brew says I'm out of
|
||||
date"** — expected if the user ran `brew upgrade --greedy` or installed
|
||||
the cask before the in-app updater picked up a newer release. The
|
||||
`auto_updates true` flag usually prevents this; if a user reports it,
|
||||
check that their cask file still has the marker.
|
||||
- **Bump workflow failed to PR the tap** — verify the `buf0-bot` app is
|
||||
still installed on `stablyai/homebrew-orca` (org-wide install, should
|
||||
auto-cover any new org repo). Re-run via `workflow_dispatch` with the
|
||||
tag name.
|
||||
- **Squirrel-mac fails during update** — almost always bundle permissions
|
||||
or a signing-identity mismatch. See the `Why: signing identity stability`
|
||||
comment in `config/electron-builder.config.cjs` and the updater logs in
|
||||
`~/Library/Application Support/Orca/logs/`.
|
||||
@@ -0,0 +1,651 @@
|
||||
# Mobile scrollback parity: hydrate headless emulator from renderer on first PTY data
|
||||
|
||||
## Problem
|
||||
|
||||
When a mobile companion client subscribes to a terminal, it sees substantially less scrollback than the desktop renderer for the same PTY. In the worst case observed (a `claude`-then-shell session in the `edit-issues` worktree), the desktop shows ~30 lines of post-exit agent summary while mobile shows a single bare prompt.
|
||||
|
||||
The mismatch is reproducible and not a transient race: it persists across resubscribes, app return-from-background, and PTY-clean shell prompts.
|
||||
|
||||
## Where data lives today
|
||||
|
||||
Two separate xterm.js instances exist for every connected PTY:
|
||||
|
||||
1. **Renderer xterm** — owned by `TerminalPane` in the renderer process. Created when the user mounts the pane. Listens to `pty:data` IPC for the lifetime of the pane. 50,000-row scrollback. Source of truth for the visible desktop terminal.
|
||||
|
||||
2. **Runtime headless emulator** — `HeadlessEmulator` in `src/main/runtime/orca-runtime.ts`. Created lazily on first `runtime.onPtyData(ptyId, …)` call. 5,000-row scrollback. Used to serve `terminal.read`, `terminal.subscribe`, `lastAgentStatus`, and TUI-idle detection. This is what mobile, the CLI, and any non-renderer consumer get.
|
||||
|
||||
| Consumer | Reads from | Lifetime |
|
||||
|---|---|---|
|
||||
| Desktop pane | Renderer xterm | Pane mount → unmount |
|
||||
| Mobile (`terminal.subscribe`) | Headless emulator (primary), renderer (fallback) | Runtime startup → PTY exit |
|
||||
| `terminal.read` (CLI / agent) | Headless emulator | Runtime startup → PTY exit |
|
||||
| `lastAgentStatus` | Headless emulator (via OSC detection on `onPtyData`) | Runtime startup → PTY exit |
|
||||
|
||||
The two xterms are fed by the same provider stream but are "born" at different moments:
|
||||
|
||||
- The renderer xterm gets hydrated on attach via cold-restore data (`result.coldRestore.scrollback`) or warm reattach (`result.snapshot`) in `pty-connection.ts`, plus all live bytes since.
|
||||
- The headless emulator only sees bytes that flow through `runtime.onPtyData` since *runtime* startup. After an Orca relaunch, the headless emulator starts empty and only catches up on new live data; it never replays the cold-restore payload.
|
||||
- Commit `8a5ea4b7` added `seedHeadlessTerminal(ptyId, data, size?)` which runs from `ipc/pty.ts` on `provider.spawn` and seeds the emulator from `result.snapshot` / `result.coldRestore.scrollback`. That helps daemon-restored sessions but **does not help when the daemon's persisted state is itself near-empty** — the `edit-issues` reproduction has `checkpoint.json` with `scrollbackAnsi: ""` because the daemon checkpoint only stores the visible screen, not real scrollback.
|
||||
|
||||
So the dominant residual case is: daemon checkpoint is near-empty, the user opens a desktop pane (which hydrates from the live PTY post-spawn and accumulates a rich 50k-row buffer), then the user opens mobile — and mobile reads from the headless emulator, which has only seen bytes since runtime startup with nothing to replay.
|
||||
|
||||
## Goal
|
||||
|
||||
Mobile, CLI, and `lastAgentStatus` should display the same scrollback as the desktop renderer for any PTY whenever the user has a desktop pane mounted for it. The fix must:
|
||||
|
||||
- Be a small, targeted change to the runtime (no daemon, IPC schema, or persistence-format changes).
|
||||
- Not regress the "no desktop pane mounted" cases (background terminals, headless agent runs, CLI on a freshly-restored daemon).
|
||||
- Not introduce reflow / SGR drift between desktop and mobile.
|
||||
- Not introduce a per-keystroke or per-subscribe renderer round-trip on the hot path.
|
||||
- Reinforce — not violate — the existing invariant that the main process is authoritative for terminal state served to mobile/CLI/`terminal.read`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Mobile-only-after-relaunch (Scenario A).** A user who relaunches Orca and opens mobile *without* ever opening the desktop pane will still see only what the daemon checkpoint stored (often the visible screen and nothing more). Solving this requires daemon-side persisted scrollback, which is a separate, larger effort with its own privacy, format-stability, retention, and IO-cost considerations. This design explicitly does not solve Scenario A.
|
||||
- Persisting full scrollback to disk (the "option 2" architecture).
|
||||
- Unifying the renderer and headless emulators into a single main-process service.
|
||||
- Changing mobile's xterm-side replay logic. Today it correctly handles either source.
|
||||
- Changing the read-priority order in `serializeTerminalBufferFromAvailableState`. Headless stays primary; renderer stays the no-headless fallback.
|
||||
|
||||
## Proposed change
|
||||
|
||||
When the runtime first sees `pty:data` for a `ptyId` that already has a renderer pane registered (detected via a new `ptyController.hasRendererSerializer(ptyId)` predicate, backed by the `serializersByPtyId` registry in `pty-buffer-serializer.ts`), perform a one-time IPC round-trip to the renderer, get its serialized buffer, and seed the headless emulator with it **before** writing the live byte that just arrived. Subsequent `pty:data` calls for the same `ptyId` skip hydration via a per-PTY guard. If no renderer serializer is registered yet on the first byte, the guard is left absent so a later byte can retry once the pane mounts.
|
||||
|
||||
This is structurally the same operation `seedHeadlessTerminal` already does on `provider.spawn` from daemon-restored state. The new path covers the gap the existing seeding doesn't: the renderer has a richer buffer than the daemon does, and we want that richer buffer mirrored into the headless emulator on first runtime touch.
|
||||
|
||||
After hydration, all consumers (`terminal.read`, `terminal.subscribe`, `lastAgentStatus`, mobile, CLI) get the same fuller scrollback through the existing read paths. No priority flip is required. No per-consumer fallback. No per-subscribe IPC.
|
||||
|
||||
### System context — before
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Renderer process │
|
||||
│ │
|
||||
│ TerminalPane → xterm.js (50k rows) │
|
||||
│ ▲ │
|
||||
│ │ replayIntoTerminal(coldRestore. │
|
||||
│ │ scrollback) on pane mount │
|
||||
│ │ + live pty:data writes │
|
||||
└─────┼───────────────────────────────────────┘
|
||||
│
|
||||
pty:data│IPC
|
||||
│
|
||||
┌─────┴───────────────────────────────────────┐
|
||||
│ Main process │
|
||||
│ │
|
||||
│ ipc/pty.ts (provider.spawn) │
|
||||
│ ├─→ seedHeadlessTerminal(snapshot) │
|
||||
│ │ ↓ │
|
||||
│ │ HeadlessEmulator (5k rows) ───┐ │
|
||||
│ │ ▲ │ │
|
||||
│ │ │ trackHeadlessTerminalData│ │
|
||||
│ │ │ on every onPtyData │ │
|
||||
│ └─→ runtime.onPtyData(...)────────┘ │
|
||||
│ ▼ │
|
||||
│ serializeHeadless │
|
||||
│ (primary read source) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ mobile / CLI / lastStatus │
|
||||
└─────────────────────────────────────────────┘
|
||||
▲
|
||||
pty:data│from daemon
|
||||
│
|
||||
┌─────┴───────────────────────────────────────┐
|
||||
│ Daemon │
|
||||
│ checkpoint.json (visible screen only) │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
Bug: when daemon checkpoint is near-empty but the renderer xterm has
|
||||
accumulated a rich live buffer, mobile reads from the headless emulator
|
||||
which only saw bytes since runtime startup — and so sees less than desktop.
|
||||
```
|
||||
|
||||
### System context — after
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Renderer process │
|
||||
│ │
|
||||
│ TerminalPane → xterm.js (50k rows) │
|
||||
│ │ ▲ │
|
||||
│ │ │ unchanged hydration path │
|
||||
│ │ └─── live pty:data writes │
|
||||
│ │ │
|
||||
│ │ ptyController.serializeBuffer(ptyId) │
|
||||
│ │ called ONCE per (ptyId, runtime) │
|
||||
│ │ on first onPtyData │
|
||||
│ ▼ │
|
||||
└─────┼───────────────────────────────────────┘
|
||||
│
|
||||
pty:data│IPC + one-time serialize round-trip
|
||||
│
|
||||
┌─────┴───────────────────────────────────────┐
|
||||
│ Main process │
|
||||
│ │
|
||||
│ onPtyData(ptyId, data) (first time) │
|
||||
│ ├─→ if ptyController.serializeBuffer │
|
||||
│ │ returns non-empty: │
|
||||
│ │ hydrateHeadlessFromRenderer → │
|
||||
│ │ seedHeadlessTerminal(rendered) │
|
||||
│ │ mark hydrated │
|
||||
│ ├─→ trackHeadlessTerminalData(data) │
|
||||
│ │ (chained via writeChain after seed) │
|
||||
│ │ │
|
||||
│ HeadlessEmulator (5k rows, now seeded │
|
||||
│ with renderer's view) │
|
||||
│ ↓ │
|
||||
│ serializeHeadless (UNCHANGED PRIMARY) │
|
||||
│ ↓ │
|
||||
│ mobile / CLI / lastAgentStatus / read │
|
||||
└─────────────────────────────────────────────┘
|
||||
|
||||
Read priorities are unchanged. Headless emulator is still the primary
|
||||
source of truth; renderer is consulted exactly once, at hydration time,
|
||||
not per consumer or per subscribe.
|
||||
```
|
||||
|
||||
### Ordering invariant (DO NOT REORDER)
|
||||
|
||||
In `onPtyData(ptyId, data, at)`, the call sequence is:
|
||||
|
||||
```ts
|
||||
this.agentDetector?.onData(ptyId, data, at)
|
||||
this.maybeHydrateHeadlessFromRenderer(ptyId) // ← MUST come BEFORE
|
||||
this.trackHeadlessTerminalData(ptyId, data) // ← this line
|
||||
```
|
||||
|
||||
**`maybeHydrateHeadlessFromRenderer` MUST be invoked before `trackHeadlessTerminalData`.** If reordered, `trackHeadlessTerminalData`'s lazy-create branch will populate `headlessTerminals` with a fresh empty emulator first, and `maybeHydrateHeadlessFromRenderer`'s `!headlessTerminals.has(ptyId)` precondition (the "live bytes already arrived" guard) will short-circuit — silently disabling hydration for the lifetime of the PTY. There is no test that catches this regression cheaply (the failure mode is "less scrollback than expected", which is the original bug); the guarantee lives in the call-site comment in `orca-runtime.ts`.
|
||||
|
||||
A `// DO NOT REORDER — see "Ordering invariant" in mobile-prefer-renderer-scrollback.md` comment is mandatory at this call site.
|
||||
|
||||
### Pseudocode
|
||||
|
||||
```ts
|
||||
// Per-PTY hydration tracker. Key absent = idle (next byte may attempt).
|
||||
// 'pending' = round-trip in flight; 'done' = succeeded or unrecoverably
|
||||
// finished; 'skipped' is no longer used — instead we leave the key absent
|
||||
// when the precondition fails so a later byte can retry.
|
||||
private headlessHydrationState = new Map<string, 'pending' | 'done'>()
|
||||
|
||||
onPtyData(ptyId: string, data: string, at: number): void {
|
||||
this.agentDetector?.onData(ptyId, data, at)
|
||||
this.maybeHydrateHeadlessFromRenderer(ptyId)
|
||||
this.trackHeadlessTerminalData(ptyId, data)
|
||||
// …existing OSC / tail / leaf bookkeeping unchanged
|
||||
}
|
||||
|
||||
private async maybeHydrateHeadlessFromRenderer(ptyId: string): Promise<void> {
|
||||
if (this.headlessHydrationState.has(ptyId)) return
|
||||
if (this.headlessTerminals.has(ptyId)) {
|
||||
// Live bytes already arrived for this ptyId — re-seeding now would
|
||||
// duplicate them. Mark done; never retry. (Daemon-snapshot seeding
|
||||
// is handled separately: see "Cooperation with seedHeadlessTerminal"
|
||||
// — that path is gated on `!hasRendererSerializer(ptyId)` so it does
|
||||
// not pre-empt this hydration when a renderer pane is mounted.)
|
||||
this.headlessHydrationState.set(ptyId, 'done')
|
||||
return
|
||||
}
|
||||
// Why: leave the state absent (not 'done') when there's no renderer
|
||||
// serializer or controller. The pane may mount one byte later; the
|
||||
// next onPtyData call gets another chance. This is the contract change
|
||||
// motivated by Open Question P2-2.
|
||||
if (!this.ptyController?.serializeBuffer) return
|
||||
if (!this.ptyController.hasRendererSerializer?.(ptyId)) return // not yet registered
|
||||
|
||||
this.headlessHydrationState.set(ptyId, 'pending')
|
||||
|
||||
// Why: eagerly create the headless state at PTY dims so concurrent live
|
||||
// writes from trackHeadlessTerminalData chain onto the same writeChain.
|
||||
// This matches seedHeadlessTerminal's pattern (orca-runtime.ts:780-803);
|
||||
// without it, a live byte arriving during the serializeBuffer await would
|
||||
// lazy-create a separate state that we'd later overwrite, dropping the
|
||||
// live byte.
|
||||
const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 }
|
||||
const state: RuntimeHeadlessTerminal = {
|
||||
emulator: new HeadlessEmulator({ cols: dims.cols, rows: dims.rows }),
|
||||
writeChain: Promise.resolve()
|
||||
}
|
||||
this.headlessTerminals.set(ptyId, state)
|
||||
|
||||
// Why: append the renderer-fetch + seed-write to the chain. Live writes
|
||||
// that arrive before the chain head resolves are queued behind it via
|
||||
// trackHeadlessTerminalData's existing `state.writeChain.then(...)` pattern.
|
||||
state.writeChain = state.writeChain.then(async () => {
|
||||
try {
|
||||
const rendered = await this.ptyController!.serializeBuffer!(ptyId, {
|
||||
scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS,
|
||||
altScreenForcesZeroRows: true
|
||||
})
|
||||
if (!rendered || rendered.data.length === 0) {
|
||||
this.headlessHydrationState.set(ptyId, 'done')
|
||||
return
|
||||
}
|
||||
// Why: resize to the renderer's reported dims before writing the seed
|
||||
// so the serialized layout reflows correctly. Then resize back to the
|
||||
// PTY's current dims after the seed lands so subsequent live writes
|
||||
// use the right cell grid.
|
||||
state.emulator.resize(rendered.cols, rendered.rows)
|
||||
await state.emulator.write(rendered.data)
|
||||
const ptyDims = this.getTerminalSize(ptyId)
|
||||
if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) {
|
||||
state.emulator.resize(ptyDims.cols, ptyDims.rows)
|
||||
}
|
||||
// Why: status parity. SerializeAddon does NOT round-trip OSC 0/1/2
|
||||
// title bytes, so extracting from rendered.data would always be null.
|
||||
// applySeededAgentStatus runs detectAgentStatusFromTitle and writes
|
||||
// leaf.lastAgentStatus only when the result is non-null; it MUST NOT
|
||||
// call resolveTuiIdleWaiters or deliverPendingMessages because seed-
|
||||
// derived status reflects historical state.
|
||||
if (rendered.lastTitle) {
|
||||
this.applySeededAgentStatus(ptyId, rendered.lastTitle)
|
||||
}
|
||||
} catch {
|
||||
// Hydration is best-effort. Live writes continue via the same
|
||||
// writeChain that this catch-arm leaves intact.
|
||||
} finally {
|
||||
this.headlessHydrationState.set(ptyId, 'done')
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Eager-state ordering invariant.** Even though the chain head blocks live writes until the seed completes, the LIVE BYTE ITSELF (the one that triggered `onPtyData`) is enqueued onto `state.writeChain` BY `trackHeadlessTerminalData` AFTER `maybeHydrateHeadlessFromRenderer` returns synchronously and registers its seed-write link. The live byte's chain link executes after the seed-write resolves. That is the ordering invariant the eager-state pattern preserves.
|
||||
|
||||
**Resize-during-hydration note.** `resizeHeadlessTerminal` (orca-runtime.ts:828) deliberately bypasses `writeChain` and calls `state.emulator.resize(...)` directly — that's existing behavior we preserve. If a `pty:resize` IPC arrives during the hydration window (between `headlessTerminals.set` and `seedPromise` resolution), the resize lands on the emulator while the seed-write is mid-flight; this can produce a one-frame visual artifact in the seed but cannot corrupt subsequent live writes (which the seed-then-resize-back invariant restores). The narrow window — typically <200ms — is bounded by the renderer-IPC round-trip duration.
|
||||
|
||||
The cap and alt-screen rule on the renderer side mirror `serializeHeadlessTerminalBuffer`: 1000-row limit on rehydration payload, force `scrollbackRows = 0` when the renderer xterm reports `buffer.active.type === 'alternate'`. This unifies semantics between hydration source and live-serialize source — the user sees the same depth regardless of which path served them, and an alt-screen TUI never bleeds normal-buffer scrollback into the seed.
|
||||
|
||||
### IPC contract changes
|
||||
|
||||
Hydration relies on the renderer-side serializer accepting an option bag. Today the chain is single-arg end to end, so changes must land in lockstep:
|
||||
|
||||
- `src/preload/index.ts` — the `pty:serializeBuffer:request` payload extends to `{ requestId, ptyId, opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean } }`. Renderer-side type for the listener callback widens accordingly.
|
||||
- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts the opts object and forwards it on the IPC `webContents.send` payload. Snapshot-shape validation widens to accept an optional `lastTitle: string`. Existing 750 ms timeout unchanged.
|
||||
- `src/main/runtime/orca-runtime.ts` — the `RuntimePtyController.serializeBuffer?` signature widens to `(ptyId, opts?) => Promise<{ data: string, cols: number, rows: number, lastTitle?: string } | null>`. A new optional `hasRendererSerializer?(ptyId): boolean` lets the runtime check pane mount status before paying the IPC cost (implementation: a getter against `serializersByPtyId` in `pty-buffer-serializer.ts`).
|
||||
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — the registered `SerializeFn` widens to `(opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`. The pane's serializer reads the opts: if `altScreenForcesZeroRows && pane.terminal.buffer.active.type === 'alternate'`, call `pane.serializeAddon.serialize({ scrollback: 0 })`; otherwise `pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows })`. The response payload includes `lastTitle: lastTitleByPtyId.get(ptyId)?.title` (omitted when no title has been observed for this PTY). Default behavior (no opts) preserves today's full-buffer serialize for any caller that hasn't migrated.
|
||||
- `serializeTerminalBufferFromAvailableState` (the renderer-fallback read path in `orca-runtime.ts:843`) forwards `scrollbackRows` so the fallback's depth matches the primary path. **It does NOT pass `altScreenForcesZeroRows: true`** — see "alt-screen flag scoping" below.
|
||||
|
||||
**alt-screen flag scoping (only on hydration path).** `altScreenForcesZeroRows: true` is passed ONLY by `maybeHydrateHeadlessFromRenderer` (the hydration path). The read-fallback path (`serializeTerminalBufferFromAvailableState`) passes `altScreenForcesZeroRows: false` (or omits the flag, default false). Reasoning:
|
||||
|
||||
- **Hydration:** wants the *normal* buffer's scrollback. xterm discards alt-screen content when alt-screen exits anyway, so suppressing scrollback during alt-screen prevents transient TUI bytes from bleeding into the seed that the headless emulator will hold for the rest of the session.
|
||||
- **Read fallback:** is invoked by `terminal.read` / `terminal.subscribe` for an actively-running TUI when the headless emulator returned null. The caller wants whatever is currently visible — including alt-screen content — so suppressing it would produce a worse result than today.
|
||||
|
||||
Both call sites pass explicit opts in the pseudocode (no implicit defaults relied on). The read-fallback shape:
|
||||
|
||||
```ts
|
||||
// In serializeTerminalBufferFromAvailableState (read-fallback):
|
||||
rendererSnapshot = await (this.ptyController?.serializeBuffer?.(ptyId, {
|
||||
scrollbackRows: opts.scrollbackRows,
|
||||
altScreenForcesZeroRows: false // read-fallback wants visible alt-screen content
|
||||
}) ?? Promise.resolve(null))
|
||||
```
|
||||
|
||||
### Renderer-side prerequisite
|
||||
|
||||
Today, `registerPtySerializer(ptyId, …)` is only called inside `handleReattachResult` (`pty-connection.ts:571`). Fresh spawns — the dominant case — never register, so `hasRendererSerializer(ptyId)` would always return false and hydration would no-op. The renderer change required:
|
||||
|
||||
- Move (or duplicate) the `registerPtySerializer` call so it fires for **both** reattach and fresh spawn paths. Cleanest shape: extract the body (closure capturing `pane`, `deps.pendingWritesRef`, `deps.replayingPanesRef`) into a `registerPaneSerializerFor(ptyId)` helper and call it from `handleReattachResult` AND from `startFreshSpawn`'s `.then(spawnedPtyId => …)` once a `ptyId` is known.
|
||||
- The helper accepts the new `opts` arg and threads it into `pane.serializeAddon.serialize({ scrollback: … })` per the IPC contract above.
|
||||
|
||||
**Cleanup contract & StrictMode safety.** Two requirements, both motivated by React 18 StrictMode double-mount and async pane disposal:
|
||||
|
||||
1. **`if (disposed) return` guard at the top of every post-spawn registration call** (both reattach and fresh-spawn paths). Rationale: StrictMode mounts the pane twice; the first mount is disposed before the second runs, but its `pty:spawn` IPC may have already resolved by the time `disposed` flips to true. Without the guard, the disposed first mount would call `registerPaneSerializerFor` after teardown — replacing the live registration from the second mount or registering a closure over a torn-down `pane.terminal`.
|
||||
|
||||
2. **Unregister via ownership token, not unconditional delete.** `serializersByPtyId` becomes `Map<ptyId, { fn: SerializeFn, owner: symbol }>`. Each call to `registerPtySerializer` mints a fresh `owner` symbol and returns an unregister closure that **only deletes the entry when the current entry's `owner === thisOwner`**. If a later registration has overwritten the entry (StrictMode second mount, fast pane swap), the unregister is a no-op so the live registration is preserved. The body of the unregister closure also clears any module-level state keyed off the same owner (lastTitle bookkeeping introduced below) AND disposes the xterm `onTitleChange` IDisposable that was installed at register time.
|
||||
|
||||
```ts
|
||||
export function registerPtySerializer(ptyId, fn): () => void {
|
||||
const owner = Symbol(ptyId)
|
||||
serializersByPtyId.set(ptyId, { fn, owner })
|
||||
ensureSerializerListener()
|
||||
return () => {
|
||||
const current = serializersByPtyId.get(ptyId)
|
||||
if (current?.owner === owner) {
|
||||
serializersByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Spawn-rejection cleanup is mandatory.** The renderer MUST call `pty:clearPendingPaneSerializer(paneKey, gen)` from the catch-arm of `pty:spawn` AND from pane unmount AFTER pre-signal but BEFORE settle. Without this, a failed-spawn paneKey leaks a pending intent that suppresses the next legitimate daemon-snapshot seed for that paneKey.
|
||||
|
||||
4. **Pre-signal applies to BOTH fresh-spawn AND reattach paths.** `declarePendingPaneSerializer(paneKey)` is mandatory on both — mirroring the `registerPaneSerializerFor(ptyId)` requirement. Reattach paths today already register the serializer in `handleReattachResult`, but the pre-signal must also fire on the reattach path so the cooperation gate in `pty:spawn` behaves consistently regardless of whether the renderer is taking over a pre-existing PTY or a fresh one.
|
||||
|
||||
The `unregisterSerializer()` call is wired into `onDataDisposable.dispose` so the registration is torn down on pane unmount, regardless of which path registered it.
|
||||
|
||||
### Renderer pre-signal handshake
|
||||
|
||||
The cooperation gate (`ipc/pty.ts` skipping `seedHeadlessTerminal` when a renderer serializer is registered) has a fundamental timing problem on **fresh spawns**: the renderer cannot have registered the serializer for the new ptyId yet, because it doesn't know the ptyId until `pty:spawn` resolves. So at the moment the cooperation gate runs (immediately after `provider.spawn` returns inside `pty:spawn`'s handler), `runtime.hasRendererSerializerFor(ptyId) === false` for every fresh spawn — and the daemon-snapshot seed always wins, defeating the gate for the dominant case.
|
||||
|
||||
The renderer does, however, know its **paneKey** before spawn: every pane has a stable `ORCA_PANE_KEY` env var that is part of `args.env` on `pty:spawn`. We use the paneKey as a pre-signal, on a channel that doesn't depend on knowing the ptyId yet:
|
||||
|
||||
1. **Pre-signal IPC with generation token.** Renderer calls `pty:declarePendingPaneSerializer(paneKey)` BEFORE invoking `pty:spawn`. Main process maintains `pendingByPaneKey: Map<paneKey, generation: number>` keyed by paneKey. On each pre-signal, main mints a fresh monotonic `gen` value (incrementing a module-level `genSeq` counter) and overwrites the entry, then returns `gen` to the renderer. The renderer awaits this IPC (capturing `gen`), then awaits the spawn IPC — guaranteeing the main process has the pending intent recorded by the time `pty:spawn` runs.
|
||||
|
||||
```ts
|
||||
let genSeq = 0
|
||||
const pendingByPaneKey = new Map<string, number>() // paneKey -> generation
|
||||
|
||||
function declarePending(paneKey: string): number {
|
||||
const gen = ++genSeq
|
||||
pendingByPaneKey.set(paneKey, gen)
|
||||
return gen // returned to renderer, echoed back on settle/clear
|
||||
}
|
||||
|
||||
function settlePending(paneKey: string, gen: number): void {
|
||||
if (pendingByPaneKey.get(paneKey) === gen) pendingByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))
|
||||
```
|
||||
|
||||
Why a generation counter: today's design hooks `pendingByPaneKey.delete(paneKey)` into `clearProviderPtyState` via `paneKeyTeardownListeners`. If the OLD PTY's `clearProviderPtyState` fires AFTER mount-2's pre-signal but BEFORE mount-2's spawn, an unconditional teardown deletes mount-2's pending entry — defeating the gate. The generation counter solves this: the `paneKeyTeardownListeners` wiring captures the generation at the time the old PTY was REGISTERED (via the existing `registerPty` call site) and the teardown only fires `settlePending` with that generation. If mount-2 has already replaced the entry with a new gen, `pendingByPaneKey.get(K) !== oldGen` and the teardown is a no-op.
|
||||
|
||||
2. **Cooperation gate consults the pre-signal.** Inside `pty:spawn`, after `provider.spawn` returns, the gate becomes:
|
||||
```ts
|
||||
const paneKey = args.env?.ORCA_PANE_KEY
|
||||
const rendererPreSignaled =
|
||||
typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256
|
||||
&& pendingByPaneKey.has(paneKey)
|
||||
const rendererAlreadyRegistered =
|
||||
runtime?.hasRendererSerializerFor?.(result.id) ?? false
|
||||
if (!rendererPreSignaled && !rendererAlreadyRegistered) {
|
||||
// No renderer is or will be authoritative for this PTY — daemon-snapshot
|
||||
// seed runs as today.
|
||||
runtime?.seedHeadlessTerminal(result.id, /* …snapshot/coldRestore… */)
|
||||
}
|
||||
```
|
||||
The `rendererAlreadyRegistered` branch covers reattach, where the pane mounted before spawn returned and registration completed in the meantime. The `rendererPreSignaled` branch covers fresh spawn, where registration has not happened yet but is imminent. Note: the gate matches when `pendingByPaneKey.has(paneKey)` is true *regardless of generation*; the generation is only used by cleanup paths (teardown listeners, settle, clear) to prevent cross-generation deletion. **Invariant:** fresh-spawn always relies on the pre-signal branch (`hasRendererSerializerFor(ptyId)` is necessarily false at gate-time since the renderer hasn't received `ptyId` yet); the registered branch only fires for reattach where the pane mounted before spawn returned.
|
||||
|
||||
3. **Settle / clear (echo gen back).** After the spawn IPC resolves and the renderer calls `registerPtySerializer(ptyId, …)`, it calls `pty:settlePaneSerializer(paneKey, gen)` to remove the pending intent (echoing the `gen` value it captured from declarePending). The handler runs `settlePending(paneKey, gen)` which only deletes if the stored gen still matches. (The simpler "clear on first onPtyData" or "clear on receipt of `pty:registerPaneSerializer`" alternatives work too — the design picks the explicit `settle` IPC because it makes the contract auditable from the renderer side and avoids coupling main-process state to onPtyData flow.) On pane unmount or registration failure, the renderer also calls `pty:clearPendingPaneSerializer(paneKey, gen)` (sibling handler with the same `settlePending` semantics) to drop the pending intent.
|
||||
|
||||
**Sequence diagram (fresh spawn, happy path):**
|
||||
|
||||
```
|
||||
Renderer Main / ipc-pty.ts Runtime
|
||||
│ │ │
|
||||
│ pty:declarePendingPaneSerializer │ │
|
||||
│ (paneKey) ───────────────────────────▶│ pendingByPaneKey.set(K, gen) │
|
||||
│ ◀──────────────────────── ack(gen) ──│ │
|
||||
│ │ │
|
||||
│ pty:spawn(args incl. ORCA_PANE_KEY) ─▶│ │
|
||||
│ │ provider.spawn(...) → result │
|
||||
│ │ pendingByPaneKey.has(paneKey)│
|
||||
│ │ === true → SKIP │
|
||||
│ │ seedHeadlessTerminal │
|
||||
│ │ │
|
||||
│ ◀──────────────────────────── ptyId ──│ │
|
||||
│ │ │
|
||||
│ registerPtySerializer(ptyId, fn) │ │
|
||||
│ pty:settlePaneSerializer(paneKey, │ │
|
||||
│ gen) ──────────────────────────────▶│ settlePending(paneKey, gen) │
|
||||
│ │ if gen matches → delete │
|
||||
│ │ │
|
||||
│ │ first onPtyData(ptyId,data)──▶│
|
||||
│ │ │ maybeHydrateFromRenderer
|
||||
│ │ │ (headlessTerminals empty,
|
||||
│ │ │ hasRendererSerializer true
|
||||
│ │ │ → IPC round-trip → seed)
|
||||
```
|
||||
|
||||
**Renderer-side call shape.** The renderer threads `gen` through every call site so cleanup (settle/clear) can be matched against the generation it captured at declare time:
|
||||
|
||||
```ts
|
||||
// In pty-connection.ts startFreshSpawn / handleReattachResult:
|
||||
const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)
|
||||
try {
|
||||
const ptyId = await window.api.pty.spawn(args)
|
||||
registerPaneSerializerFor(ptyId)
|
||||
await window.api.pty.settlePaneSerializer(paneKey, gen)
|
||||
} catch (err) {
|
||||
await window.api.pty.clearPendingPaneSerializer(paneKey, gen)
|
||||
throw err
|
||||
}
|
||||
```
|
||||
|
||||
**Correctness properties:**
|
||||
|
||||
1. **Pre-signal arrives before `pty:spawn`.** The renderer awaits the pre-signal IPC, then awaits the spawn IPC. Electron `ipcRenderer.invoke`/`send` ordering on a single renderer is preserved on the main side, so the main process processes the pre-signal before `pty:spawn`'s handler runs.
|
||||
2. **Missing paneKey degrades to today's behavior.** If `args.env?.ORCA_PANE_KEY` is absent (e.g., a pane that isn't yet wired to set it, an old renderer build, or a non-pane spawn path), the pre-signal lookup returns false, `rendererAlreadyRegistered` is also false on a fresh spawn, and the daemon-snapshot seed runs as today. No regression for callers that don't participate in the handshake.
|
||||
3. **Pre-signal without settle (renderer crashes between spawn and registerPtySerializer).** The pending intent stays in the map, but it has no observable effect: the cooperation gate already ran, so the daemon snapshot was suppressed; the headless emulator stays empty; the first `onPtyData` falls through `hasRendererSerializer(ptyId) === false` and hydration's precondition leaves the state map absent. `trackHeadlessTerminalData` then lazy-creates the emulator and live writes populate it. We lose the daemon snapshot for this PTY in that narrow window — accepted limitation, since the renderer was clearly going to be authoritative but didn't make it. (To bound staleness, the entry in `pendingByPaneKey` is cleared whenever a paneKey teardown event fires via the existing `paneKeyTeardownListeners` registry, gated by the generation token.)
|
||||
|
||||
**Files-touched implications:** see "IPC contract changes" and "Files touched" — both `pty:declarePendingPaneSerializer` and `pty:settlePaneSerializer` need preload bindings AND main handlers.
|
||||
|
||||
### Status parity
|
||||
|
||||
Seeding the emulator bypasses `onPtyData`'s OSC-extraction path, so without intervention `leaf.lastAgentStatus` stays whatever it was (typically `null` for daemon-restored leaves) until the next live byte happens to carry an OSC title. Mobile would then see a stale or missing agent badge despite a rich seed.
|
||||
|
||||
A previous draft of this design called `extractLastOscTitle(rendered.data)` on the seed payload to recover the title. **That doesn't work**: xterm's `SerializeAddon.serialize()` writes out the visible buffer as a stream of cursor moves, SGR resets, and printable characters — it does NOT round-trip OSC 0/1/2 (window/icon title) escape sequences. The renderer xterm consumes the OSC and sets `terminal.options.title`, but the bytes never appear in `serialize()`'s output. So `extractLastOscTitle` on a seed payload is dead code — it always returns null even when the renderer's pane currently shows a perfectly good title. We need the title via a different channel.
|
||||
|
||||
**Approach: ship now via IPC widening, document as best-effort.** The renderer pane already wires `pane.terminal.onTitleChange` (in `pty-connection.ts`) to keep the tab title in sync. We capture the latest observed title in module state alongside the serializer registration, and widen the serialize-buffer IPC payload to carry it back to main:
|
||||
|
||||
1. **Renderer side** (`pty-buffer-serializer.ts`):
|
||||
- Module-level `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` keyed by the same ownership-token symbol as `serializersByPtyId` (see "Renderer-side prerequisite"). Populated by a wrapper around `pane.terminal.onTitleChange` installed at the same time as `registerPtySerializer`. The entry stores BOTH the IDisposable returned from `pane.terminal.onTitleChange(...)` AND the owner symbol alongside the latest title.
|
||||
- When the pane unregisters, the `unregisterSerializer` closure disposes the IDisposable AND deletes the map entry, both gated by the owner-token match — same ownership semantics as the serializer map, so a StrictMode disposed first-mount cannot wipe the live second-mount's title or leak a dangling listener.
|
||||
- The IPC response payload widens from `{ data, cols, rows }` to `{ data, cols, rows, lastTitle?: string }`. Default omitted when no title was observed for this PTY (e.g., fresh shell pre-prompt).
|
||||
|
||||
2. **Main side** (`ipc/pty.ts`, `orca-runtime.ts`):
|
||||
- `requestSerializedBuffer` snapshot-shape validation accepts `lastTitle?: string`.
|
||||
- `RuntimePtyController.serializeBuffer(ptyId, opts?)` returns `Promise<{ data, cols, rows, lastTitle?: string } | null>`.
|
||||
- `maybeHydrateHeadlessFromRenderer` calls `applySeededAgentStatus(ptyId, rendered.lastTitle)` only when `rendered.lastTitle` is a non-empty string (the helper itself also short-circuits on null/empty for safety). The helper internally runs `detectAgentStatusFromTitle` and only writes `leaf.lastAgentStatus` when the result is one of `'idle' | 'working' | 'permission'`.
|
||||
|
||||
3. **`applySeededAgentStatus(ptyId, title)`** (new helper in `orca-runtime.ts`). Behavior:
|
||||
- If `title == null` or empty, no-op.
|
||||
- Look up the leaf for `ptyId` via the existing pty→leaf mapping. Run `detectAgentStatusFromTitle(rendered.lastTitle)` (the helper from `src/shared/agent-detection.ts`). If the result is non-null, set `leaf.lastAgentStatus = result`. If null (title is not a recognizable agent title), leave `lastAgentStatus` untouched — the next live OSC byte will populate it through the normal onPtyData path.
|
||||
- **MUST NOT** call `resolveTuiIdleWaiters(leaf)` or `deliverPendingMessages(leaf)`. The seeded status reflects the renderer's last-observed historical title, not a live transition; resolving idle waiters or delivering pending messages on stale data could mistakenly unblock orchestration callers that should only react to a fresh prompt-return.
|
||||
- **Why** (mandatory comment in the helper definition): `// seed-derived status reflects historical state; orchestration waiters must only react to live transitions to avoid resolving on stale data.`
|
||||
- **Rationale for routing through `detectAgentStatusFromTitle`:** we mirror the live path (`onPtyData` already uses `detectAgentStatusFromTitle`) so seeded and live paths produce the same union value, and downstream `leaf.lastAgentStatus === 'idle'` consumers (orchestration `tui-idle` waiters, pending-message gating) keep working. `leaf.lastAgentStatus` is typed `AgentStatus | null` where `AgentStatus = 'working' | 'idle' | 'permission'`; assigning the raw title string would be type-incorrect.
|
||||
|
||||
This delivers status parity for the dominant case via the `lastTitle` field (any pane that has rendered a prompt or had a TUI emit OSC titles will have populated `lastTitleByPtyId` long before mobile subscribes). Edge cases where the renderer never observed a title — fresh shell pre-prompt, pane that mounted but never received bytes — leave `lastAgentStatus` as `null`; that's acceptable since `lastAgentStatus` is documented best-effort and the next live OSC title will populate it through the normal `onPtyData` path.
|
||||
|
||||
**Status parity / accepted limitations.** Side effect of seeding `lastAgentStatus`: if the seed sets status to `'idle'`, the next live OSC byte that re-emits the same `'idle'` title will see `prevStatus === 'idle'` and the existing transition gate (`agentStatus === 'idle' && prevStatus !== 'idle'`) skips. Orchestration `tui-idle` waiters registered between seeding and the next idle transition will hang until either (a) a non-idle status arrives followed by another idle transition, or (b) the waiter times out. This is an accepted limitation. If it becomes a real problem, future work could split into `lastSeededAgentStatus` (informational, not gating) vs `lastAgentStatus` (gates transitions).
|
||||
|
||||
### Files touched
|
||||
|
||||
- `src/main/runtime/orca-runtime.ts` — add `headlessHydrationState`, `maybeHydrateHeadlessFromRenderer`, wire into `onPtyData` (with mandatory `// DO NOT REORDER` comment per "Ordering invariant"). Widen `RuntimePtyController.serializeBuffer?` to `(ptyId, opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null>` and add `hasRendererSerializer?(ptyId)`. Add `applySeededAgentStatus(ptyId, title)` helper that writes `leaf.lastAgentStatus` only — must NOT call `resolveTuiIdleWaiters` or `deliverPendingMessages` (mandatory "Why" comment in the helper definition). Add `hasRendererSerializerFor(ptyId)` runtime-service-level method that delegates to `ptyController.hasRendererSerializer` (consumed by `ipc/pty.ts`'s cooperation gate). Forward `scrollbackRows` from `serializeTerminalBufferFromAvailableState` into the renderer-fallback call (do NOT pass `altScreenForcesZeroRows: true` on this path — see "alt-screen flag scoping").
|
||||
- `src/main/ipc/pty.ts` — `requestSerializedBuffer(ptyId, opts?)` accepts opts and forwards them on the `pty:serializeBuffer:request` IPC payload. Wire `hasRendererSerializer` through a renderer-registry getter. **Cooperate with hydration:** when a renderer serializer is registered for the spawning PTY *or* the renderer pre-signaled intent for `args.env.ORCA_PANE_KEY`, skip seeding from `result.snapshot` / `result.coldRestore.scrollback` so the daemon's bare-prompt state cannot pre-empt the richer renderer hydration that follows on first byte. **Add three new IPC handlers** with consistent paneKey validation (`typeof paneKey === 'string' && paneKey.length > 0 && paneKey.length <= 256` — invalid input is rejected before mutating state): `pty:declarePendingPaneSerializer(paneKey): Promise<number>` (renderer → main, before `pty:spawn`) mints a fresh generation `gen`, stores `pendingByPaneKey.set(paneKey, gen)` in a module-level `pendingByPaneKey: Map<string, number>`, and returns `gen` to the renderer; `pty:settlePaneSerializer(paneKey, gen)` (renderer → main, after `registerPtySerializer`) runs `settlePending(paneKey, gen)` which deletes only if the stored gen still matches; `pty:clearPendingPaneSerializer(paneKey, gen)` (renderer → main, on spawn-rejection / unmount-before-settle) has the same `settlePending` semantics. Hook `paneKeyTeardownListeners.add((paneKey, gen) => settlePending(paneKey, gen))` so a torn-down PTY's listener captures the generation it was registered under and only clears the map when that generation still matches — ensuring a teardown for the OLD PTY cannot delete a NEW PTY's pending intent if mount-2's pre-signal landed in between. (See "Renderer pre-signal handshake" and "Cooperation with seedHeadlessTerminal" in Open questions.)
|
||||
- `src/preload/index.ts` — extend the `pty:serializeBuffer:request` listener payload type to include `opts?: { scrollbackRows?: number; altScreenForcesZeroRows?: boolean }`. Add three new bindings under `pty`: `declarePendingPaneSerializer(paneKey: string): Promise<number>` (returns the `gen` token), `settlePaneSerializer(paneKey: string, gen: number): Promise<void>`, and `clearPendingPaneSerializer(paneKey: string, gen: number): Promise<void>`, all forwarded as `ipcRenderer.invoke('pty:declarePendingPaneSerializer', …)` / `'pty:settlePaneSerializer'` / `'pty:clearPendingPaneSerializer'` so the renderer can `await` them and rely on main-side ordering before issuing `pty:spawn` / after `registerPtySerializer`. The renderer captures `gen` from declare and threads it through every settle/clear call.
|
||||
- `src/renderer/src/components/terminal-pane/pty-buffer-serializer.ts` — widen `SerializeFn` to `(opts?) => Promise<{ data, cols, rows, lastTitle?: string } | null> | …`; default to today's full-buffer behavior when opts is absent. Convert `serializersByPtyId: Map<ptyId, SerializeFn>` to `Map<ptyId, { fn: SerializeFn, owner: symbol }>` and have `registerPtySerializer` mint a fresh owner symbol per call, returning an unregister closure that deletes only when `current?.owner === thisOwner` (StrictMode safety — see "Renderer-side prerequisite"). Add a parallel `lastTitleByPtyId: Map<ptyId, { title: string, owner: symbol, disposable: IDisposable }>` populated by a wrapper around `pane.terminal.onTitleChange`, with the unregister closure both disposing the IDisposable AND deleting the entry under the same owner-token check. The serialize listener reads from `lastTitleByPtyId` to populate `lastTitle` in the IPC response.
|
||||
- `src/renderer/src/components/terminal-pane/pty-connection.ts` — register the serializer for **fresh spawns** as well as reattach. Extract the registration body into a shared `registerPaneSerializerFor(ptyId)` helper called from both `handleReattachResult` AND the post-spawn callback in `startFreshSpawn`. Thread opts (scrollback cap, alt-screen flag) into `pane.serializeAddon.serialize`. Add `if (disposed) return` guard at the top of every post-spawn registration call (StrictMode safety — see "Renderer-side prerequisite"). Track the latest `pane.terminal.onTitleChange` value in module state alongside the serializer registration so the serializer's IPC response can include `lastTitle` (see "Status parity"). Call `const gen = await window.api.pty.declarePendingPaneSerializer(paneKey)` BEFORE `pty:spawn` on every fresh-spawn path (capturing the returned generation token), and `settlePaneSerializer(paneKey, gen)` AFTER `registerPaneSerializerFor(ptyId)` succeeds. On spawn-rejection (catch-arm) AND on pane unmount AFTER pre-signal but BEFORE settle, call `clearPendingPaneSerializer(paneKey, gen)` — this is mandatory (see "Renderer-side prerequisite" item 3). Unregister on pane unmount via the existing `onDataDisposable.dispose` hook (now using the ownership-token contract from "Renderer-side prerequisite").
|
||||
- `src/main/runtime/scrollback-limits.ts` (new) — main-process module exporting `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS = 1000`. Imported by both `src/main/runtime/rpc/methods/terminal.ts` and `src/main/runtime/orca-runtime.ts`. (The constant is main-process-only; promoting it to `src/shared/` would expose a runtime cap to the renderer for no benefit.)
|
||||
- `src/main/runtime/orca-runtime.test.ts` — add hydration unit tests (see "Test plan").
|
||||
- `src/main/runtime/mobile-subscribe-integration.test.ts` — extend with cross-process hydration scenario.
|
||||
|
||||
No changes to:
|
||||
- `pty:data` batching, daemon adapter.
|
||||
- `serializeTerminalBufferFromAvailableState` priority order (headless still primary; renderer still no-headless fallback).
|
||||
- Mobile RPC method signatures (`terminal.subscribe`, `terminal.resizeForClient`).
|
||||
- Mobile WebView / `TerminalWebView.tsx`.
|
||||
- Persistence (`HistoryManager`, `checkpoint.json` shape).
|
||||
|
||||
## Why this is structurally sound
|
||||
|
||||
The renderer xterm is, in practice, the fuller and more authoritative state for any mounted pane:
|
||||
|
||||
1. **It hydrates on attach.** `pty-connection.ts` calls `replayIntoTerminal(pane, …, coldRestore.scrollback)` and writes `snapshot` immediately after pane mount. The headless emulator never replays either. So even on a session that started before the current Orca run, the renderer xterm contains the full history; the headless emulator does not.
|
||||
|
||||
2. **It has a bigger buffer.** 50,000 rows vs 5,000.
|
||||
|
||||
3. **It is updated on the same IPC tick as the visible UI.** No staleness vs what the user sees.
|
||||
|
||||
Hydrating the headless emulator from the renderer on first runtime touch is the same shape of operation as the existing `seedHeadlessTerminal(provider.spawn snapshot)` path. We are not introducing a new architectural pattern; we are extending an existing one to the case where the daemon snapshot is empty but the renderer is rich. The runtime remains authoritative for everything mobile, CLI, `terminal.read`, and `lastAgentStatus` see — the renderer is consulted exactly once per `(ptyId, runtime)` pair.
|
||||
|
||||
This is a structural-adjacent improvement that fixes the dominant case (user has both desktop and mobile open) while consciously deferring Scenario A (mobile-only after relaunch) to a separate daemon-persistence design. It is honest about what it does and does not solve.
|
||||
|
||||
## Data flow paths
|
||||
|
||||
### Happy path — renderer mounted, hydration succeeds
|
||||
|
||||
```
|
||||
T0 user opens desktop pane.
|
||||
- Fresh spawn: pty-connection.ts calls
|
||||
const gen = await pty:declarePendingPaneSerializer(paneKey)
|
||||
BEFORE pty:spawn, then awaits both. Main mints a fresh gen and
|
||||
records the pending intent under it. After spawn resolves with
|
||||
ptyId, registerPaneSerializerFor(ptyId) +
|
||||
pty:settlePaneSerializer(paneKey, gen) clear the intent (only if
|
||||
the stored gen still matches). On spawn-rejection or unmount-
|
||||
before-settle, pty:clearPendingPaneSerializer(paneKey, gen) runs
|
||||
instead (mandatory — see "Renderer-side prerequisite" item 3).
|
||||
- Reattach: registerPaneSerializerFor(ptyId) runs in
|
||||
handleReattachResult once the ptyId is known.
|
||||
Either way, ipc/pty.ts's cooperation gate sees that the renderer
|
||||
is or will be authoritative for this PTY (via either
|
||||
hasRendererSerializerFor(ptyId) === true OR a pre-signaled paneKey)
|
||||
and SKIPS the daemon-snapshot seedHeadlessTerminal call.
|
||||
T1 PTY emits live byte → ipc/pty.ts forwards to renderer (xterm grows)
|
||||
→ ipc/pty.ts forwards to runtime.onPtyData
|
||||
T2 onPtyData: maybeHydrateHeadlessFromRenderer
|
||||
- hasRendererSerializer(ptyId) === true → state: absent → pending
|
||||
- serialize round-trip with opts {scrollbackRows, altScreenForcesZeroRows}
|
||||
- on resolve: HeadlessEmulator built at rendered.cols/rows,
|
||||
applySeededAgentStatus(ptyId, rendered.lastTitle), write(rendered.data)
|
||||
- resize emulator to PTY dims if they diverge from rendered dims
|
||||
T3 trackHeadlessTerminalData(data) chains AFTER the seed via writeChain,
|
||||
so the live byte is appended on top of the seeded buffer — order
|
||||
preserved.
|
||||
T4 hydration state → done. Subsequent onPtyData calls skip the guard.
|
||||
T5 user opens mobile → terminal.subscribe → serializeHeadless returns
|
||||
seeded buffer + everything since. Mobile == desktop, and
|
||||
leaf.lastAgentStatus is populated from rendered.lastTitle
|
||||
(sourced from pane.terminal.onTitleChange, NOT from the seed payload —
|
||||
SerializeAddon does not round-trip OSC titles).
|
||||
```
|
||||
|
||||
### Nil path — Scenario A: no renderer ever mounts
|
||||
|
||||
```
|
||||
T0 user relaunches Orca, opens mobile only.
|
||||
T1 ipc/pty.ts: provider.spawn returns coldRestore.scrollback = ""
|
||||
(daemon checkpoint had scrollbackAnsi: "" — visible screen only)
|
||||
or a thin visible-screen snapshot. hasRendererSerializerFor(ptyId)
|
||||
is false (no pane mounted), so the cooperation gate does NOT skip:
|
||||
seedHeadlessTerminal runs as today.
|
||||
T2 PTY emits live byte → onPtyData → maybeHydrateHeadlessFromRenderer
|
||||
- hasRendererSerializer(ptyId) is false → state map left absent.
|
||||
(Pane could still mount later in this runtime; we'd retry then.)
|
||||
T3 trackHeadlessTerminalData populates from live bytes; emulator
|
||||
already contains the daemon-snapshot seed.
|
||||
T4 Mobile sees daemon's visible-screen snapshot + live bytes since.
|
||||
KNOWN LIMITATION; out of scope for this design.
|
||||
```
|
||||
|
||||
### Error path — renderer throws or unmounts mid-hydration
|
||||
|
||||
```
|
||||
T0–T1 same as happy path through `pending` state.
|
||||
T2 ptyController.serializeBuffer rejects (renderer disposed,
|
||||
channel closed, IPC timeout, etc.).
|
||||
- catch swallows the error.
|
||||
- hydration state → done (we do not loop on every byte).
|
||||
T3 Live bytes continue to populate the emulator via
|
||||
trackHeadlessTerminalData (which lazily creates the headless
|
||||
state at PTY dims). We lose the seed for this runtime but do
|
||||
not block live data flow.
|
||||
```
|
||||
|
||||
The "advance to done on serialize failure" rule is intentional. Re-trying on every subsequent byte would create a renderer round-trip on every byte until success — too costly for a best-effort path. The "leave state absent when the renderer isn't ready yet" rule (the precondition checks before `pending`) is the inverse trade-off: those checks are cheap (Map lookups), so retrying them is fine.
|
||||
|
||||
### SSH note
|
||||
|
||||
SSH-backed PTYs flow through the same `runtime.onPtyData` entry point as local and daemon-backed PTYs (see `ipc/pty.ts` provider listener — all providers call `runtime.onPtyData(id, data, at)` uniformly). Hydration applies to SSH PTYs without special-casing: when a desktop pane is mounted on an SSH-backed terminal, its `registerPtySerializer` registration is identical to the local case, and the runtime sees `hasRendererSerializer(ptyId) === true` regardless of provider. The mobile + SSH + relaunch case (rare in practice) inherits Scenario A's known-limitation behavior.
|
||||
|
||||
### Concurrent path — live data arrives during hydration
|
||||
|
||||
The renderer round-trip is asynchronous; live `pty:data` will continue to arrive before `serializeBuffer` resolves. The pattern from `seedHeadlessTerminal` already handles this: every emulator write is appended to `state.writeChain`, so:
|
||||
|
||||
```
|
||||
writeChain: Promise.resolve()
|
||||
→ seed write ← scheduled by maybeHydrateHeadlessFromRenderer
|
||||
→ live byte 1 ← scheduled by trackHeadlessTerminalData
|
||||
→ live byte 2
|
||||
→ live byte N
|
||||
```
|
||||
|
||||
`emulator.write` is invoked in chain order, preserving the byte sequence exactly as the wire delivered it. The seed always lands before the live bytes that triggered hydration, because the trigger logic schedules the seed write *before* it returns control to `onPtyData`, which then schedules the live write.
|
||||
|
||||
## Performance
|
||||
|
||||
`ptyController.serializeBuffer(ptyId)` does an IPC round-trip to the renderer where xterm's SerializeAddon runs synchronously over the buffer. Cost is roughly proportional to scrollback size: empirically ~50–200 ms for a full 50k-row buffer.
|
||||
|
||||
The cost profile is materially different from the rejected priority-flip approach:
|
||||
|
||||
| Approach | Renderer round-trip frequency |
|
||||
|---|---|
|
||||
| Priority flip (rejected) | Once per `terminal.subscribe` AND `terminal.resizeForClient` |
|
||||
| Hydrate-on-first-touch (this) | Once per `(ptyId, runtime)` pair, ever |
|
||||
|
||||
For a typical session — Orca starts, user opens desktop pane, agent runs for hours, user opens mobile periodically — this approach pays the IPC cost exactly once *for hydration* per `(ptyId, runtime)` pair. The existing renderer-fallback inside `serializeTerminalBufferFromAvailableState` is preserved — it can still hit the renderer on `terminal.read` / `terminal.subscribe` when the headless emulator returns null — but that path is the read-time fallback, not the hydration path. The priority-flip approach paid the IPC cost on every mobile re-subscribe (foreground/background, navigation between worktrees, phone-fit toggle). The "first byte after pane mount" trigger ties the hydration cost to a moment that is already doing IO work, so the latency is hidden inside the existing flow.
|
||||
|
||||
There is no per-keystroke cost; `trackHeadlessTerminalData` is unchanged.
|
||||
|
||||
## Reflow asymmetry
|
||||
|
||||
Hydration happens at whatever cols the renderer is currently at — typically the desktop's natural size (e.g., 105 cols). When mobile later subscribes and triggers a 49-col phone-fit resize, the headless emulator (now seeded with 105-col data) reflows to 49 cols just like the renderer would. Both buffers reflow from the same starting content, so they stay in sync.
|
||||
|
||||
This is a real improvement over the priority-flip proposal, which was sensitive to the order in which renderer and headless reflow completed across the IPC tick boundary. With hydrate-on-mount the seed has already landed before any phone-fit resize is even requested, so the reflow asymmetry concerns largely go away: at subscribe time both buffers contain the same content and respond to the same resize event in the same `handleMobileSubscribe` synchronous block.
|
||||
|
||||
## Risks
|
||||
|
||||
1. **Live data arrives during pending hydration.** Mitigated by chaining through `state.writeChain`, exactly as `seedHeadlessTerminal` already does. Order is preserved.
|
||||
|
||||
2. **Renderer pane unmount mid-round-trip.** Caught by `try/catch`; the guard advances to `done` so we don't loop on every subsequent byte, and live writes continue via lazy emulator creation in `trackHeadlessTerminalData`. We lose the seed for that PTY, which is acceptable given how rare unmount-during-IPC is.
|
||||
|
||||
3. **Wire-payload size on hydration.** Capped at 1000 rows from day one via the shared `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` constant. Alt-screen forces 0 rows. This matches the existing serialize path's behavior, so users see identical depth from both sources. The headless emulator's own 5,000-row `DEFAULT_SCROLLBACK` (in `HeadlessEmulator`) is comfortably above the 1k hydration cap; if a future change raises the hydration cap, `HeadlessEmulator`'s default must be bumped in lockstep or the seed will be silently truncated as it lands in the emulator.
|
||||
|
||||
4. **Renderer xterm is *behind* the runtime.** Theoretically possible if `pty:data` is delivered to the runtime before the renderer (depends on dispatch order in `ipc/pty.ts`). The renderer's xterm uses an asynchronous `write(callback)`, so even when bytes are delivered the buffer may not yet contain them. In practice this is sub-millisecond and the seed will be at most one frame behind the live byte that triggered it — better than today's headless-only path which has none of those bytes.
|
||||
|
||||
5. **Hydration races registerPty.** If `onPtyData` fires before `runtime.registerPty` has been called for this PTY, `getTerminalSize` may return null and we default to 80×24 for the post-seed PTY dims. The seed itself is constructed at the renderer's reported dims (see Pseudocode), so xterm reflow only happens on the post-seed `resize` call — at most one reflow, and only when PTY and renderer dims actually diverge.
|
||||
|
||||
6. **PTY-size vs renderer-dim divergence (resize-after-seed reflow).** Under mobile-fit overrides, or in the brief window before a `pty:resize` lands on the main process, the renderer's xterm dims and the runtime's `ptySizes` entry can disagree. Constructing the headless emulator at PTY-size and then writing a seed captured at renderer-dim would force xterm to wrap/reflow the seed in the wrong column count — visible as broken line breaks or doubled prompts. Mitigation: build the emulator at `rendered.cols / rendered.rows`, then resize to PTY-size after the seed lands. See Pseudocode. **At extreme size deltas (e.g., 105→32), the seed-then-reflow path can produce minor visible divergence vs the renderer's own reflow** (xterm reflow is not strictly invertible across very different widths, and the headless emulator runs the reflow on already-laid-out cells rather than the original byte stream). This is an accepted limitation, not a correctness issue: the reflow result is still valid xterm output, just potentially line-broken slightly differently than what the desktop pane shows for the same scrollback. Mobile users have always seen a phone-fit reflow distinct from desktop's; this design does not change that.
|
||||
|
||||
7. **Note: writes are flushed before serialize.** The renderer-side serializer (`pty-buffer-serializer.ts:registerPtySerializer`) flushes `pendingWritesRef` via `replayIntoTerminalAsync` before calling `pane.serializeAddon.serialize()`, so the seed faithfully includes hidden-pane buffered writes. No additional sequencing logic is needed in the runtime.
|
||||
|
||||
8. **`pendingWritesRef` double-write window (severity-adjusted P2).** When a pane is hidden at the moment bytes arrive, the renderer-side `pendingWritesRef` buffers them while `runtime.onPtyData` simultaneously routes the same bytes into the headless emulator via `trackHeadlessTerminalData`. The renderer's serializer flushes `pendingWritesRef` before serializing, so the seed payload includes those bytes. If hydration runs after `trackHeadlessTerminalData` has already written the same bytes, the headless emulator ends up with a duplicated stretch.
|
||||
|
||||
**Mitigation: the P0-A2 pre-signal handshake makes this rare in practice.** With pre-signal, hydration is the FIRST writer for a fresh-spawn-with-pre-signal — `trackHeadlessTerminalData`'s lazy-create check sees the emulator already exists (created at hydration time) and just appends, so live bytes between seed-fetch and seed-resolve are queued behind the seed via `state.writeChain`. The double-write only manifests in the narrow combo of: (a) reattach path, (b) hidden pane buffering live bytes into `pendingWritesRef`, AND (c) hydration runs after `trackHeadlessTerminalData` has already populated the headless emulator. The impact is bounded: at most one ~8 ms PTY-batch's worth of bytes can be duplicated, the duplicate is visible only in mobile/CLI scrollback (desktop renders from its own xterm which never duplicates), and a future shell prompt overwrites the area anyway. Document as a known limitation; not a correctness-blocking issue. If it becomes user-visible, the fix is to track a per-pty "live bytes seen since last serialize request" cursor on the renderer and trim the seed payload to bytes the renderer observed before hydration started — out of scope for this design.
|
||||
|
||||
## Test plan
|
||||
|
||||
### Unit (`orca-runtime.test.ts`)
|
||||
|
||||
- Hydration runs on the *first* `onPtyData` for a `ptyId` when `hasRendererSerializer` is true and `serializeBuffer` returns non-empty data. Assert the emulator's serialized output contains both the seeded prefix and the live byte.
|
||||
- Subsequent `onPtyData` calls do **not** re-trigger hydration after a successful run. Mock `serializeBuffer` and assert it was called exactly once across N `onPtyData` invocations for the same ptyId.
|
||||
- "Exactly once" claim is qualified: the renderer round-trip happens once per `(ptyId, runtime)` for hydration. The existing `serializeTerminalBufferFromAvailableState` renderer-fallback (consulted only when the headless emulator returns null) is preserved and may still hit the renderer on `terminal.read` / `terminal.subscribe` paths.
|
||||
- When `ptyController` is null (CLI / headless test runs), hydration leaves the state map untouched, headless emulator is created lazily as today, no IPC attempt.
|
||||
- When `hasRendererSerializer(ptyId)` returns false on the first byte but true on a later byte, hydration **does** run on the later byte (state is left absent on idle, not consumed). Confirms the contract change for Open Question P2-2.
|
||||
- When `ptyController.serializeBuffer` rejects, the guard advances to `done` (no infinite retry), the emulator is created lazily by `trackHeadlessTerminalData`, live bytes still write through.
|
||||
- Live data arriving during pending hydration is queued behind the seed write. Schedule the seed-write as a long pending promise, fire several `onPtyData` calls, await `writeChain`, assert the final emulator content has the seed prefix followed by the live bytes in correct order.
|
||||
- The 1000-row cap is applied to the renderer-sourced hydration: mock `serializeBuffer` to return a payload representing >1000 rows, assert the requested option bag includes `scrollbackRows: MOBILE_SUBSCRIBE_SCROLLBACK_ROWS`.
|
||||
- Alt-screen flag is forwarded: assert the request includes `altScreenForcesZeroRows: true`. (The alt-screen branch is exercised in renderer-side serializer tests where it has access to a real xterm `buffer.active.type`.)
|
||||
- **Status parity (lastTitle field):** mock `serializeBuffer` to return `{ data, cols, rows, lastTitle }` where `lastTitle` is a recognizable agent title. Fire `onPtyData`. Assert `leaf.lastAgentStatus` is one of `'idle' | 'working' | 'permission'` (whatever `detectAgentStatusFromTitle` returns for that title) after hydration and before any live OSC title bytes are observed. Conversely, when `lastTitle` is absent / undefined OR `detectAgentStatusFromTitle(lastTitle)` returns `null` (unrecognizable title), assert `leaf.lastAgentStatus` is left unchanged from its prior value (no overwrite).
|
||||
- **Multi-PTY concurrent hydration:** fire `onPtyData` for ptyA and ptyB before either `serializeBuffer` resolves. Assert each PTY's hydration completes with its own payload (no cross-talk through the IPC response listener pool — each request has a unique `requestId`).
|
||||
- **Re-entrant `onPtyData`:** when `agentDetector.onData` synchronously triggers another `onPtyData` for the same ptyId (or a different ptyId), confirm the hydration state machine still holds — no double-pending, no skipped seed, no out-of-order emulator writes.
|
||||
- **Cooperation with `seedHeadlessTerminal`:** when a renderer serializer is registered for `ptyId`, the spawn-time call from `ipc/pty.ts` skips seeding (no daemon-snapshot bytes written to the emulator). When no serializer is registered, the daemon-snapshot path runs as today.
|
||||
- **Fresh-spawn timing race (P0-A2 pre-signal handshake):** simulate the renderer pre-signaling intent for paneKey via `const gen = await pty:declarePendingPaneSerializer(paneKey)`, then call the `pty:spawn` handler with `args.env.ORCA_PANE_KEY === paneKey`. Assert `seedHeadlessTerminal` is **NOT** invoked even though `hasRendererSerializerFor(result.id)` is false at that moment (the renderer hasn't received the ptyId yet). The renderer mock then `registerPtySerializer(ptyId, …)` and `pty:settlePaneSerializer(paneKey, gen)`. Fire `onPtyData(ptyId, liveByte)`. Assert `maybeHydrateHeadlessFromRenderer` runs and the headless emulator is populated from the renderer's serialize payload, with the live byte appended after the seed via `writeChain`.
|
||||
- **Pre-signal without settle (renderer crash):** simulate `pty:declarePendingPaneSerializer(paneKey)` followed by `pty:spawn` (gate skips daemon seed) but the renderer never calls `registerPtySerializer` or `settlePaneSerializer`. Fire `onPtyData`. Assert hydration's preconditions leave the state map absent, `trackHeadlessTerminalData` lazy-creates the emulator, and live bytes populate it. The pending-paneKey entry is dropped on the next paneKey teardown event.
|
||||
- **Pre-signal preserved across paneKey reuse during teardown:** simulate (a) old PTY for paneKey K is exiting, its `paneKeyTeardownListener` is queued (captured at registration time with `gen1`). (b) Mount-2 calls `pty:declarePendingPaneSerializer(K)` and stores generation `gen2` (`gen2 > gen1`). (c) The queued teardown for the old PTY runs; the listener invokes `settlePending(K, gen1)` and SKIPS the delete because `pendingByPaneKey.get(K) === gen2`. (d) Mount-2 calls `pty:spawn`; the cooperation gate matches (`pendingByPaneKey.has(K) === true`) and seed is suppressed. Assert daemon-snapshot seed did NOT run.
|
||||
- **Test imports for `scrollback-limits.ts`:** tests in `orca-runtime.test.ts` and `mobile-subscribe-integration.test.ts` import `MOBILE_SUBSCRIBE_SCROLLBACK_ROWS` from `../scrollback-limits` so cap-assertion lines match the production constant (no hardcoded `1000`).
|
||||
- **Status parity (real `SerializeAddon` round-trip):** in a renderer-side unit test (using a real `xterm` `Terminal` + `SerializeAddon`), write OSC 0/1/2 title bytes (e.g., `\x1b]0;some-title\x07`) into the terminal, await the `onTitleChange` listener, then call `serializeAddon.serialize()`. **Assert the serialize output does NOT contain the OSC title bytes** — this codifies the limitation that motivated the `lastTitle` field. Then, in a runtime-side unit test, mock the renderer-IPC response to include `{ data, cols, rows, lastTitle }` where `lastTitle` is a known agent title, fire `onPtyData`, and assert `applySeededAgentStatus` was called with that title and `leaf.lastAgentStatus` is the corresponding `'idle' | 'working' | 'permission'` value returned by `detectAgentStatusFromTitle` afterward.
|
||||
- **`applySeededAgentStatus` does not resolve waiters:** with a leaf that has pending TUI-idle waiters and pending orchestration messages registered, call `applySeededAgentStatus(ptyId, title)` where `title` is a recognizable agent title. Assert `leaf.lastAgentStatus` matches `detectAgentStatusFromTitle(title)` (one of `'idle' | 'working' | 'permission'`), AND assert `resolveTuiIdleWaiters` and `deliverPendingMessages` were **NOT** invoked. (Spy on the methods or assert the waiter / pending-message queues are unchanged.) This guards against future drift where a refactor lifts shared logic between the live and seeded paths.
|
||||
|
||||
### Integration (`mobile-subscribe-integration.test.ts`)
|
||||
|
||||
- Cross-process scenario with a mocked renderer that registers a richer scrollback than the headless adapter would produce. After the runtime sees its first live byte, mobile subscribes; assert the `scrollback` frame's `serialized` field includes the seeded prefix.
|
||||
- Scenario A regression guard: no renderer registered, mobile subscribes, assert mobile receives only what the daemon snapshot + live bytes provide, hydration was never invoked. (Documents the known limitation.)
|
||||
|
||||
### Manual
|
||||
|
||||
On a real device, reproduce the original `edit-issues` post-claude-exit scenario, confirm the mobile WebView now shows the same agent summary the desktop shows.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Hydration trigger point.** Three plausible options: (a) first `onPtyData`, (b) `runtime.registerPty`, (c) an explicit renderer-pane-mount notification from the renderer. (a) is the chosen approach: cheap, ties cost to a moment already paying IO, and **retryable within a runtime**. The state map is left absent (not `done` or `skipped`) when the renderer serializer isn't yet registered, so a later byte can attempt hydration once the pane mounts. The only consume-on-idle case is `headlessTerminals.has(ptyId)` (live bytes already arrived), where re-seeding would duplicate. (b) fires before the renderer has any data to serialize. (c) needs a new wire from `pty-connection.ts → ipc/pty.ts → runtime`.
|
||||
|
||||
- **Cooperation with `seedHeadlessTerminal`.** Today `seedHeadlessTerminal` writes to the headless emulator on `provider.spawn` from `result.snapshot` / `result.coldRestore.scrollback`. The daemon's `result.snapshot` is the visible-screen-only ANSI from `checkpoint.json` — almost always non-empty (it includes the bare prompt) but typically much thinner than what the renderer pane has. If both fire for the same PTY, the daemon path lands first and `headlessTerminals.has(ptyId)` becomes true; the hydration guard would then mark itself `done` and the richer renderer payload is lost. **Decision: gate `seedHeadlessTerminal` on (a) an already-registered renderer serializer OR (b) a renderer pre-signal for the spawning PTY's paneKey.** In `ipc/pty.ts`, before calling `runtime.seedHeadlessTerminal(...)`, check both `runtime.hasRendererSerializerFor(result.id)` AND `pendingByPaneKey.has(args.env?.ORCA_PANE_KEY)` (after validating the paneKey shape — see P1-B2 in the cooperation gate snippet). If either is true, skip the daemon-snapshot seed entirely and let renderer hydration be canonical. (a) covers reattach, where the pane mounted before spawn returned. (b) covers fresh spawn, where the renderer pre-signaled intent before `pty:spawn` but cannot have registered yet because it doesn't know the ptyId. If neither is true (Scenario A — mobile-only, no desktop pane), the daemon-snapshot path runs unchanged. See "Renderer pre-signal handshake" for the full sequence and correctness properties. This is cleaner than a length-comparison "richness heuristic" and lets the renderer be authoritative whenever it's mounted *or* about to mount.
|
||||
|
||||
## Rollout
|
||||
|
||||
Single change touching the runtime and the renderer-side serialize controller. No feature flag — the hydration is best-effort and degrades to today's behavior when the renderer cannot answer or has nothing useful to share.
|
||||
|
||||
Ready for implementation review. Scenario A remains a known limitation tracked for a future daemon-persistence design.
|
||||
@@ -0,0 +1,690 @@
|
||||
# Mobile Presence Lock for Desktop Terminal
|
||||
|
||||
Design doc for repurposing the existing "phone-fit" banner into a general
|
||||
presence-based interaction lock between the desktop renderer and mobile
|
||||
clients sharing a single PTY.
|
||||
|
||||
## Problem
|
||||
|
||||
The desktop and mobile clients share full read/write access to the same
|
||||
underlying PTY. There is no synchronization between desktop's xterm `onData`
|
||||
(which calls `provider.write(ptyId, bytes)` via the `pty:write` IPC) and
|
||||
mobile's `terminal.send` RPC (which also calls `provider.write` through the
|
||||
runtime). When both parties type at the same time, bytes interleave at the
|
||||
TTY level — `ls<enter>` and `pwd<enter>` typed concurrently can produce
|
||||
`lpws<enter>d<enter>` and execute unintended commands.
|
||||
|
||||
The same is true for resize: desktop `pty:resize` and mobile
|
||||
`terminal.resizeForClient` both reach `provider.resize(ptyId, ...)` without
|
||||
coordination. The renderer-side `safeFit` has a partial guard, but the
|
||||
`pty:resize` IPC handler does not check the override server-side.
|
||||
|
||||
A teammate using the original mobile build reported "odd behavior using
|
||||
laptop + mobile at the same time." The most visible symptom is the desktop
|
||||
terminal jumping to phone dimensions; the more dangerous symptom is silent
|
||||
input interleaving.
|
||||
|
||||
## Today's banner
|
||||
|
||||
The existing banner in `TerminalPane.tsx` is keyed on
|
||||
`getFitOverrideForPty(ptyId)`:
|
||||
|
||||
- It only appears when mobile is in `auto`/`phone` display mode AND a phone
|
||||
resize actually happened.
|
||||
- It says "Terminal resized for phone (W×H)" with a "Restore" button that
|
||||
calls `runtime:restoreTerminalFit(ptyId)`.
|
||||
- That IPC handler sets the display mode to `desktop` and applies the
|
||||
display mode, which resizes the PTY back to desktop dims and clears the
|
||||
override. The banner unmounts.
|
||||
|
||||
In `desktop` display mode (mobile is subscribed but viewing at desktop dims)
|
||||
there is no override and no banner — the desktop user has no idea mobile is
|
||||
watching.
|
||||
|
||||
## Goal
|
||||
|
||||
Repurpose the banner into a general presence-based lock with this single
|
||||
rule:
|
||||
|
||||
> At any moment, each PTY has exactly one *driver* — desktop, a specific
|
||||
> mobile client, or nobody. While mobile drives, desktop input and resize
|
||||
> are dropped; the desktop banner explains why and offers **Take back** to
|
||||
> hand the floor over. While desktop drives, mobile keystrokes silently
|
||||
> reclaim the floor (no mobile-side banner — see "Asymmetric UX" below).
|
||||
|
||||
The lock exists at two layers:
|
||||
|
||||
- **Renderer guard** (primary UX): `pty-connection.ts` drops xterm `onData`
|
||||
and `onResize` while a mobile client is the driver. Banner explains why.
|
||||
- **Server-side defense** (defense-in-depth): the `pty:write` and
|
||||
`pty:resize` IPC handlers consult the runtime's driver state and drop
|
||||
desktop-side calls while a mobile client is the driver.
|
||||
|
||||
## Behavioral model
|
||||
|
||||
### Driver state machine
|
||||
|
||||
Instead of a binary lock, each PTY carries a tagged driver state. There is
|
||||
exactly one driver per PTY at any moment, and transitions are atomic
|
||||
(updated and emitted together from the runtime).
|
||||
|
||||
```ts
|
||||
type DriverState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'desktop' }
|
||||
| { kind: 'mobile'; clientId: string }
|
||||
```
|
||||
|
||||
- `idle` — no mobile subscribers; desktop input and resize flow through
|
||||
normally. (We don't bother distinguishing "no one is here" from "only
|
||||
desktop is here" since they behave identically; `idle` is the catch-all
|
||||
for "nothing is currently locking the desktop out.")
|
||||
- `desktop` — at least one mobile client is subscribed but the desktop has
|
||||
reclaimed the floor; desktop input and resize flow through.
|
||||
- `mobile{clientId}` — `clientId` is the mobile actor that most recently
|
||||
drove this PTY. The banner is mounted on desktop; desktop input and
|
||||
resize are dropped.
|
||||
|
||||
Invariants:
|
||||
|
||||
- `currentDriver(ptyId)` is always exactly one of the three kinds.
|
||||
- On the main process, a transition mutates `currentDriver(ptyId)` and
|
||||
emits `terminalDriverChanged(ptyId, driver)` in the same critical
|
||||
section. The renderer's mirror in `mobile-driver-state.ts` is updated
|
||||
only when the IPC arrives — there is a brief IPC-hop window during
|
||||
which the renderer's `getDriverForPty(ptyId)` returns a stale value.
|
||||
This is why the server-side `pty:write` / `pty:resize` defenses are
|
||||
**load-bearing, not redundant**: a desktop keystroke fired during
|
||||
that window passes the renderer guard and is dropped by the server
|
||||
guard.
|
||||
- Only one driver per PTY at any moment. Multiple mobile clients can
|
||||
*subscribe* simultaneously (see "Multi-mobile subscriber model"), but
|
||||
only the most recent mobile actor is the driver.
|
||||
|
||||
### Transitions
|
||||
|
||||
| Current driver | Trigger | Next driver | Side effect |
|
||||
|---|---|---|---|
|
||||
| `idle` | mobile subscribes with `displayMode='auto'` (first client for this ptyId) | `mobile{clientId}` | banner mounts on desktop; PTY resizes to phone dims |
|
||||
| `idle` | mobile subscribes with `displayMode='desktop'` (first client for this ptyId) | `desktop` | inner subscriber map populated; **no** banner; PTY stays at desktop dims |
|
||||
| `idle` | desktop input or first PTY data after no subscribers | `idle` (no transition) | — |
|
||||
| `mobile{A}` | desktop clicks **Take back** | `desktop` | banner unmounts; PTY snaps to desktop dims if at phone dims |
|
||||
| `mobile{A}` | mobile A sends input/resize/setDisplayMode | `mobile{A}` (no transition) | — |
|
||||
| `mobile{A}` | mobile B sends input | `mobile{B}` | (no banner change; both are "mobile") |
|
||||
| `mobile{A}` | last mobile client unsubscribes | `idle` | banner unmounts |
|
||||
| `desktop` | any mobile client sends input/resize | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims if that client's mode is auto |
|
||||
| `desktop` | mobile sets `displayMode` to `auto` or `phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims (deliberate "I want to drive" gesture) |
|
||||
| `desktop` | mobile sets `displayMode` to `desktop` | `desktop` (no transition) | — (already desktop-mode watching) |
|
||||
| `desktop` | mobile subscribes-fresh with `auto`/`phone` | `mobile{thatClient}` | banner mounts; PTY snaps to phone dims |
|
||||
| `desktop` | mobile subscribes-fresh with `desktop` | `desktop` (no transition) | inner map updated; no banner |
|
||||
| `desktop` | last mobile client unsubscribes | `idle` | (banner already unmounted) |
|
||||
|
||||
**Subscribe-in-desktop-mode rule.** A mobile client subscribing in
|
||||
`displayMode='desktop'` is treated as a passive watch, not a take-floor
|
||||
gesture. The driver stays at `idle`/`desktop`, so the desktop user is not
|
||||
interrupted. The instant that client (or any peer) sends input, sets the
|
||||
display mode to `auto`/`phone`, or sends a resize, the runtime transitions
|
||||
to `mobile{thatClient}` and the banner appears. This matches the rest of
|
||||
the design: the lock engages on *interaction*, not on *presence*.
|
||||
|
||||
The protocol is "first-mover wins until the other party acts." Desktop
|
||||
clicks Take back, mobile types, desktop types again — banner ping-pongs as
|
||||
each side acts. The `clientId` carried in `mobile{clientId}` is updated
|
||||
each time a mobile actor takes the floor; this is the wire channel by
|
||||
which the runtime knows *which* phone last drove (useful for the
|
||||
forward-path coordinator described at the bottom of this doc, and for the
|
||||
multi-mobile semantics described below).
|
||||
|
||||
### Take back and PTY dimensions
|
||||
|
||||
Take back has two sub-cases:
|
||||
|
||||
1. **Mobile was in `phone`/`auto` mode (PTY at phone dims)**. Take back
|
||||
resizes PTY back to desktop dims (existing behavior of
|
||||
`applyMobileDisplayMode('desktop')` when `wasResizedToPhone` is true).
|
||||
2. **Mobile was in `desktop` mode (PTY already at desktop dims)**. Take
|
||||
back is a pure lock-flag flip; no resize. Today's
|
||||
`applyMobileDisplayMode('desktop')` already short-circuits the resize in
|
||||
this case.
|
||||
|
||||
In the symmetric direction, mobile reclaim:
|
||||
|
||||
1. **Mobile is in `phone`/`auto` mode but PTY is at desktop dims** (because
|
||||
desktop just clicked Take back): mobile reclaim re-applies the phone
|
||||
resize via `applyMobileDisplayMode(currentMode)`.
|
||||
2. **Mobile is in `desktop` mode**: pure lock-flag flip; no resize.
|
||||
|
||||
## End-user UX
|
||||
|
||||
| Driver | Banner (desktop) | Desktop input | Desktop resize |
|
||||
|---|---|---|---|
|
||||
| `idle` | hidden | allowed | allowed |
|
||||
| `mobile{*}` | "🔒 Mobile is driving this terminal — your input is paused. Click **Take back** to resume." | blocked at xterm.onData (silent drop) | blocked in renderer + dropped in `pty:resize` IPC server-side |
|
||||
| `desktop` | hidden | allowed | allowed |
|
||||
|
||||
Walkthroughs:
|
||||
|
||||
**Phone connects while you're typing.** Banner pops in. PTY may resize to
|
||||
phone dims (existing). Your next keystroke is dropped. Click Take back to
|
||||
unlock and (if needed) restore desktop dims.
|
||||
|
||||
**You click Take back.** Banner gone. You type freely. Mobile stays
|
||||
connected; mobile sees the desktop-sized terminal because PTY snapped back
|
||||
(or stayed at desktop dims if mobile was in desktop mode).
|
||||
|
||||
**Mobile types something while you're reclaimed.** Banner reappears. Your
|
||||
next keystroke is blocked. PTY may snap back to phone dims (if mobile is in
|
||||
auto mode).
|
||||
|
||||
**Mobile disconnects.** Banner gone permanently. Driver returns to `idle`.
|
||||
|
||||
**Multiple panes.** Driver state is per-pane. Phone on pane A doesn't
|
||||
affect pane B.
|
||||
|
||||
**Text selection / scrollback / copy.** Always allowed. The lock is
|
||||
keystroke and resize only.
|
||||
|
||||
**Output continues to render.** While the banner is mounted, terminal
|
||||
output streams to xterm normally — it is the mobile actor's bytes you
|
||||
are seeing. Only desktop-side keystrokes and resize are dropped. This
|
||||
matters because "your input is paused" can read ambiguously; output
|
||||
flow is unaffected.
|
||||
|
||||
## Asymmetric UX (accepted tradeoff)
|
||||
|
||||
Mobile sees no banner in this PR. The mobile UI is visually unchanged: no
|
||||
"Desktop is driving" indicator, no analog to the Take back affordance.
|
||||
This is asymmetric and we are accepting it deliberately:
|
||||
|
||||
- **Smaller mobile UI surface.** Mobile already has limited screen real
|
||||
estate and a constrained component set; introducing a presence banner
|
||||
there is a non-trivial design + i18n + dismissibility task that we want
|
||||
to defer until the lock model has settled in production.
|
||||
- **Faster ship.** Driver state machine + multi-mobile subscriber fix +
|
||||
desktop banner is the smallest change that fixes the dangerous collision
|
||||
(silent input interleaving). Adding a mobile-side banner doubles the UI
|
||||
scope.
|
||||
- **Mobile reclaim is naturally signaled.** When a mobile user types
|
||||
while desktop drives, the runtime flips the driver to
|
||||
`mobile{thatClient}`, the desktop banner remounts, and (if mobile is in
|
||||
auto mode) the PTY snaps back to phone dims. The mobile user sees
|
||||
the pane reflow and their keystrokes appear in the output stream. There
|
||||
is no silent black-hole condition on mobile that a banner would
|
||||
resolve.
|
||||
- **Mobile keystrokes always reclaim implicitly.** The protocol on mobile
|
||||
is "just type to take the floor." There is no mobile-side button to
|
||||
surface; a banner with no actionable control is closer to noise than
|
||||
signal.
|
||||
|
||||
The flip side: a mobile user typing into a stale view *won't* be told
|
||||
"hold on, desktop is driving" before their keystroke lands. They just take
|
||||
the floor. We consider that acceptable because (a) they're explicitly
|
||||
acting, and (b) the desktop user gets the warning side of the tradeoff
|
||||
where the risk of unintended action is much higher.
|
||||
|
||||
If usage data later shows mobile users frequently being surprised by
|
||||
desktop activity, a mobile banner can be added without changing the wire
|
||||
format — `terminalDriverChanged(ptyId, driver)` already carries everything
|
||||
needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Where the truth lives
|
||||
|
||||
The `mobileSubscribers` map (re-keyed; see "Multi-mobile subscriber model"
|
||||
below) plus a new `currentDriver: Map<ptyId, DriverState>` on the runtime
|
||||
are the source of truth. The renderer learns the driver state via a new
|
||||
IPC event `terminal-driver-changed(ptyId, driver: DriverState)` emitted
|
||||
from the runtime through the existing notifier path. The structured
|
||||
payload is intentional: a binary `locked: boolean` would lose the
|
||||
`clientId` we need for multi-mobile semantics today and for the unified
|
||||
write coordinator on the forward path.
|
||||
|
||||
The runtime exposes `getDriver(ptyId): DriverState`, replacing the old
|
||||
`isLocked(ptyId): boolean`. Server-side `pty:write` / `pty:resize`
|
||||
defenses check `runtime.getDriver(ptyId).kind === 'mobile'` to drop
|
||||
desktop-side calls.
|
||||
|
||||
A new renderer module `mobile-driver-state.ts` mirrors
|
||||
`mobile-fit-overrides.ts`: keyed by ptyId, supports
|
||||
`getDriverForPty(ptyId): DriverState` and a subscribe-style change
|
||||
listener. The `TerminalPane` banner mounts when
|
||||
`getDriverForPty(ptyId).kind === 'mobile'`, and `pty-connection`'s
|
||||
`onData`/`onResize` guards drop input/resize under the same predicate.
|
||||
|
||||
### Why not reuse `getFitOverrideForPty`?
|
||||
|
||||
The fit override only fires when the PTY was actually resized
|
||||
(`mode='auto'` and `wasResizedToPhone=true`). It misses the
|
||||
desktop-mode case where mobile is subscribed but no resize happened. The
|
||||
driver state is broader than the fit override.
|
||||
|
||||
### Why a new IPC event vs extending `terminal-fit-override-changed`?
|
||||
|
||||
Cleaner separation. The fit override is about dimensions; the driver
|
||||
state is about ownership. They happen to overlap in the auto/phone
|
||||
subcase, but conflating them long-term ties future work on either to the
|
||||
other.
|
||||
|
||||
### Why a structured payload vs `locked: boolean`?
|
||||
|
||||
The runtime needs to know *which* mobile client most recently drove (for
|
||||
multi-mobile semantics today and for the future write coordinator). A
|
||||
structured `DriverState` payload makes that information first-class on
|
||||
the wire instead of smuggling it through a side channel. The cost is ~70
|
||||
LoC over a binary lock; the payoff is a symmetric UX foundation and a
|
||||
future-proof wire format.
|
||||
|
||||
## Multi-mobile subscriber model
|
||||
|
||||
The `mobileSubscribers` map is today shaped `Map<ptyId, Subscriber>` and
|
||||
each `terminal.subscribe` call overwrites the previous subscriber for that
|
||||
ptyId. That overwrite is fine for a binary `has(ptyId)` presence check but
|
||||
breaks the moment we treat presence as a real set: phone B subscribing
|
||||
silently evicts phone A, so phone A unsubscribing or its WS dropping looks
|
||||
like "no mobile clients" even while phone B is still on the line. The
|
||||
driver state machine sits directly on top of presence, so soundness here
|
||||
is a prerequisite, not a polish.
|
||||
|
||||
We rekey to `Map<ptyId, Map<clientId, Subscriber>>` and update each
|
||||
callsite:
|
||||
|
||||
- **`handleMobileSubscribe(ptyId, clientId, subscriber)`** — get-or-create
|
||||
the inner map, then `inner.set(clientId, subscriber)`. Do not overwrite
|
||||
peer clients. If the inner map was empty before insert, this is the
|
||||
first subscriber for the PTY; emit the `idle → mobile{clientId}` driver
|
||||
transition.
|
||||
- **`handleMobileUnsubscribe(ptyId, clientId)`** — `inner.delete(clientId)`,
|
||||
then if the inner map is now empty, delete the outer entry, run the
|
||||
existing restore-resize logic, and emit the `* → idle` driver
|
||||
transition. If the inner map is non-empty, do **not** fire restore /
|
||||
driver-change; peers still have the floor.
|
||||
- **`applyMobileDisplayMode(ptyId)`** — iterate the inner map. The
|
||||
restore-resize semantics need a single representative subscriber (the
|
||||
desktop dims to restore *to* are stored on the subscriber record). Pick
|
||||
the earliest by subscribe time so the restore target is stable as
|
||||
later phones come and go. The display mode itself is per-PTY runtime
|
||||
state, not per-client.
|
||||
|
||||
**Active phone-fit dim selection (multi-mobile).** When two or more
|
||||
mobile clients subscribe with different viewports (e.g., iPhone +
|
||||
iPad), the PTY can only be at one phone-fit size at a time. Rule:
|
||||
**the most recent mobile actor's viewport wins**. This matches the
|
||||
driver state machine — whoever last took the floor (`mobile{X}`)
|
||||
also dictates the active phone-fit dims. When that client
|
||||
unsubscribes, the next-most-recent surviving subscriber's viewport
|
||||
wins; on the last client leaving, the inner map empties and we run
|
||||
the existing restore back to the earliest-recorded desktop dims. We
|
||||
do not use `min(cols, rows)` across subscribers because that
|
||||
produces no clear principal — multiple TUIs are forced to render
|
||||
for an aggregate viewport that nobody actually has, which is worse
|
||||
than picking a single owner. Most-recent-actor is consistent with
|
||||
the rest of the protocol (whoever acted last has the floor) and is
|
||||
cheap to reason about.
|
||||
|
||||
**Subscriber record extension.** "Most-recent actor" and
|
||||
"earliest-by-subscribe-time" both require timestamps that today's
|
||||
Subscriber record does not carry. Extend it with two fields:
|
||||
|
||||
```ts
|
||||
interface Subscriber {
|
||||
clientId: string
|
||||
viewport: { cols: number; rows: number }
|
||||
wasResizedToPhone: boolean
|
||||
previousCols: number | null
|
||||
previousRows: number | null
|
||||
subscribedAt: number // ms since epoch, set on insert
|
||||
lastActedAt: number // ms since epoch, init = subscribedAt;
|
||||
// updated on every mobileTookFloor for this client
|
||||
}
|
||||
```
|
||||
|
||||
- `applyMobileDisplayMode(ptyId)` for active phone-fit dims:
|
||||
iterate the inner map, pick `argmax(lastActedAt)`, use that
|
||||
subscriber's `viewport`.
|
||||
- For restore-to-desktop semantics on last-client-leaves: pick
|
||||
`argmin(subscribedAt)` **among subscribers with non-null
|
||||
`previousCols`/`previousRows`** and use that subscriber's
|
||||
`previousCols/Rows` as the restore target. Desktop-mode
|
||||
subscribers carry `previousCols/Rows = null` by design (the
|
||||
existing `handleMobileSubscribe` short-circuits dim capture for
|
||||
desktop-mode joins to avoid capturing a stale full-width size),
|
||||
so they are not viable restore targets. If no surviving
|
||||
subscriber has captured dims, fall back to
|
||||
`lastRendererSizes.get(ptyId)` (the desktop renderer's most
|
||||
recent reported size) — same fallback the existing first-insert
|
||||
capture path uses.
|
||||
|
||||
Both operations are O(n) over the inner map, which is bounded by
|
||||
the number of concurrently subscribed mobile clients (typically 1,
|
||||
rarely 2-3). No indexing needed.
|
||||
- **`isMobileSubscriberActive(ptyId)`** — returns `true` iff the inner map
|
||||
is non-empty.
|
||||
- **Driver state for the mobile side** stores the `clientId` of the most
|
||||
recent mobile actor. Whenever any mobile client sends
|
||||
input/resize/setDisplayMode/subscribes-fresh, that client becomes the
|
||||
`currentDriver` (`mobile{thatClient}`). Phone B taking over from phone A
|
||||
is an internal mobile→mobile transition: no banner change, but the
|
||||
recorded `clientId` updates so the runtime always knows who is the
|
||||
authoritative mobile actor.
|
||||
|
||||
The semantic upshot is that the desktop banner is governed by "is the
|
||||
inner map non-empty AND has the desktop not reclaimed?" rather than the
|
||||
old single-slot heuristic, and Take back / mobile-reclaim correctly
|
||||
ping-pong even when two phones are on the same PTY.
|
||||
|
||||
## Implementation
|
||||
|
||||
Files touched:
|
||||
|
||||
1. `src/main/runtime/orca-runtime.ts` — rekey `mobileSubscribers` to
|
||||
`Map<ptyId, Map<clientId, Subscriber>>`, add `currentDriver` map,
|
||||
`getDriver(ptyId)` getter, transition methods, notifier hook. Update
|
||||
`handleMobileSubscribe` / `handleMobileUnsubscribe` /
|
||||
`applyMobileDisplayMode` / `isMobileSubscriberActive` per the
|
||||
multi-mobile model above. **Extend `onPtyExit` cleanup** to
|
||||
`currentDriver.delete(ptyId)` and emit `terminalDriverChanged(ptyId,
|
||||
{ kind: 'idle' })` so any banner mounted on the dead pane unmounts.
|
||||
Without this, a dead PTY's last driver state lingers and the
|
||||
renderer banner could persist on a dead pane until tab teardown.
|
||||
2. `src/main/runtime/orca-runtime.ts` interface `RuntimeNotifier` — add
|
||||
`terminalDriverChanged(ptyId, driver: DriverState)`.
|
||||
3. `src/main/window-manager.ts` (or wherever `RuntimeNotifier` is wired) —
|
||||
forward to renderer via `webContents.send('terminal-driver-changed', …)`.
|
||||
4. `src/preload/index.ts` + `src/preload/api-types.ts` — expose the new
|
||||
event with the structured `DriverState` payload.
|
||||
5. `src/main/ipc/runtime.ts` — add `runtime:reclaimTerminal` IPC (or
|
||||
extend `runtime:restoreTerminalFit`) which drives the
|
||||
`mobile{*} → desktop` transition.
|
||||
6. `src/main/runtime/rpc/methods/terminal.ts` — drive the
|
||||
`* → mobile{clientId}` transition in `terminal.send`,
|
||||
`terminal.resizeForClient`, `terminal.setDisplayMode`, and the
|
||||
subscribe handler (with the subscribe-in-desktop-mode exception
|
||||
above).
|
||||
|
||||
**Wire-format changes required.** The driver state machine tags
|
||||
the active mobile actor with `clientId`, so every mobile RPC method
|
||||
that can take the floor must carry the caller's identity. Today
|
||||
only `terminal.subscribe` and `terminal.resizeForClient` do; we
|
||||
extend the others.
|
||||
|
||||
**Canonical identity shape.** Use the existing
|
||||
`terminal.subscribe` shape: `client: { id: string; type: 'mobile'
|
||||
\| 'desktop' }` (with `type` optional). `TerminalResizeForClient`
|
||||
keeps its grandfathered top-level `clientId: string` field for
|
||||
backward compatibility — implementers may add an aliased nested
|
||||
`client.id` setter for consistency at the call site, but the wire
|
||||
shape is preserved.
|
||||
|
||||
New fields on existing schemas:
|
||||
|
||||
- `TerminalSend` gains `client: { id: string; type?: 'mobile' \|
|
||||
'desktop' }` (optional for backward compatibility — falls back
|
||||
to "the most recent mobile actor" when absent).
|
||||
- `TerminalSetDisplayMode` gains the same `client` field.
|
||||
- `TerminalUnsubscribe` gains `client: { id: string }` so the
|
||||
server can derive the composite cleanup key (see below); the
|
||||
existing `subscriptionId` field is kept and remains the primary
|
||||
lookup, with `client.id` used only when the caller passed a
|
||||
bare-handle `subscriptionId`.
|
||||
|
||||
**Subscribe-side composite key.** Change `subscriptionId` from
|
||||
`params.terminal` to `${params.terminal}:${params.client.id}` so
|
||||
two phones subscribing to the same terminal handle do not evict
|
||||
each other via `registerSubscriptionCleanup`. Without this fix,
|
||||
the multi-mobile rekey to `Map<ptyId, Map<clientId, Subscriber>>`
|
||||
would be silently defeated at the RPC subscription layer (phone
|
||||
B's subscribe runs phone A's cleanup → A's data listener tears
|
||||
down → A's `handleMobileUnsubscribe` fires).
|
||||
|
||||
**Unsubscribe-side composite key.** The mobile RPC client today
|
||||
emits `terminal.unsubscribe` with `params: { subscriptionId:
|
||||
stream.params.terminal }` — the bare handle. With the subscribe
|
||||
side now keying by composite, a bare-handle unsubscribe will miss
|
||||
in `subscriptionCleanups.get(bareHandle)` and silently no-op,
|
||||
leaking the data listener and leaving `mobileSubscribers`
|
||||
populated forever (banner stuck, driver never returns to `idle`).
|
||||
Fix: the mobile RPC client emits `{ subscriptionId:
|
||||
${terminal}:${clientId}, client: { id: clientId } }`, and the
|
||||
server's `cleanupSubscription` uses whichever the caller sent —
|
||||
if both `subscriptionId` and `client.id` are present and the
|
||||
caller passed only `terminal` in `subscriptionId`, the server
|
||||
reconstructs the composite key from `client.id`. Belt-and-braces
|
||||
so a stale older mobile build still cleans up correctly.
|
||||
|
||||
This is a coordinated client/server wire-format change. The
|
||||
mobile app must ship in lockstep; if a stale mobile client emits
|
||||
bare-handle unsubscribe to a new server, the server's
|
||||
reconstruction path catches it. If a new mobile client emits
|
||||
composite-handle unsubscribe to a stale server, the stale server
|
||||
ignores `client.id` and the composite key fails to match — but
|
||||
that direction is moot because the stale server has not adopted
|
||||
composite keys on subscribe either. No client newer than server,
|
||||
so this is safe.
|
||||
7. `src/main/ipc/pty.ts` — defense in depth: drop `pty:write` and
|
||||
`pty:resize` calls when
|
||||
`runtime.getDriver(id).kind === 'mobile'`. **Preserve the existing
|
||||
`runtime.isResizeSuppressed()` short-circuit at the top of the
|
||||
`pty:resize` handler** — the new driver-state guard is *in
|
||||
addition to*, not in place of, the suppression window. The two
|
||||
guards have different purposes: `isResizeSuppressed()` blocks the
|
||||
safeFit cascade after a take-back transition (preventing
|
||||
collateral resize corruption of background panes), while the
|
||||
driver-state check blocks desktop-side resizes whenever mobile is
|
||||
driving. Both must apply.
|
||||
8. `src/renderer/src/lib/pane-manager/mobile-driver-state.ts` — new
|
||||
renderer store mirroring `mobile-fit-overrides.ts`. Exposes
|
||||
`getDriverForPty(ptyId): DriverState` and a change listener.
|
||||
9. `src/renderer/src/components/terminal-pane/pty-connection.ts` — guard
|
||||
`onData` and `onResize` on
|
||||
`getDriverForPty(ptyId).kind === 'mobile'`.
|
||||
10. `src/renderer/src/components/terminal-pane/TerminalPane.tsx` — switch
|
||||
banner to consume driver state, update copy, rename Restore → Take
|
||||
back.
|
||||
|
||||
### Server-side detail
|
||||
|
||||
```ts
|
||||
// orca-runtime.ts
|
||||
private currentDriver = new Map<string, DriverState>()
|
||||
|
||||
getDriver(ptyId: string): DriverState {
|
||||
return this.currentDriver.get(ptyId) ?? { kind: 'idle' }
|
||||
}
|
||||
|
||||
private setDriver(ptyId: string, next: DriverState): void {
|
||||
this.currentDriver.set(ptyId, next)
|
||||
this.notifier?.terminalDriverChanged(ptyId, next)
|
||||
}
|
||||
|
||||
reclaimTerminalForDesktop(ptyId: string): void {
|
||||
if (!this.isMobileSubscriberActive(ptyId)) return
|
||||
// Snap PTY back to desktop dims if currently at phone dims.
|
||||
// applyMobileDisplayMode is a no-op resize when already at desktop dims.
|
||||
this.setMobileDisplayMode(ptyId, 'desktop')
|
||||
this.applyMobileDisplayMode(ptyId)
|
||||
this.setDriver(ptyId, { kind: 'desktop' })
|
||||
}
|
||||
|
||||
private mobileTookFloor(ptyId: string, clientId: string): void {
|
||||
// If mobile is in phone/auto mode, re-apply phone resize on the first
|
||||
// hand-off back from desktop. Mobile-to-mobile hand-offs are no-ops
|
||||
// for resize.
|
||||
const prev = this.getDriver(ptyId)
|
||||
if (prev.kind === 'desktop') this.applyMobileDisplayMode(ptyId)
|
||||
this.setDriver(ptyId, { kind: 'mobile', clientId })
|
||||
}
|
||||
|
||||
handleMobileUnsubscribe(ptyId: string, clientId: string) {
|
||||
const inner = this.mobileSubscribers.get(ptyId)
|
||||
if (!inner) return
|
||||
inner.delete(clientId)
|
||||
if (inner.size > 0) return // peer mobile clients still drive
|
||||
this.mobileSubscribers.delete(ptyId)
|
||||
// Drive transition fires synchronously so the desktop banner
|
||||
// unmounts immediately. The PTY restore runs inside the existing
|
||||
// 300ms pendingRestoreTimers debounce — see "Restore-debounce
|
||||
// window" under Edge cases.
|
||||
this.setDriver(ptyId, { kind: 'idle' })
|
||||
// Existing restore-resize logic (300ms debounced timer) runs here.
|
||||
}
|
||||
```
|
||||
|
||||
The mobile-took-floor path is invoked from the RPC method handlers (not
|
||||
from `sendTerminal` directly) so that the driver flip is bound to
|
||||
deliberate mobile actions, not to internal runtime calls.
|
||||
|
||||
### Renderer guards
|
||||
|
||||
```ts
|
||||
// pty-connection.ts onData
|
||||
if (currentPtyId && getDriverForPty(currentPtyId).kind === 'mobile') {
|
||||
return // Mobile is driving this PTY; banner explains.
|
||||
}
|
||||
// existing transport.sendInput(data)
|
||||
|
||||
// pty-connection.ts onResize
|
||||
// Why: keep both predicates. getFitOverrideForPty fires synchronously
|
||||
// in the renderer the moment safeFit runs; getDriverForPty arrives via
|
||||
// IPC and lags by one round-trip. Removing the fit-override predicate
|
||||
// would re-introduce the resize-over-mobile-fit bug mobile-fit-overrides
|
||||
// was added to prevent.
|
||||
if (
|
||||
currentPtyId &&
|
||||
(getFitOverrideForPty(currentPtyId) ||
|
||||
getDriverForPty(currentPtyId).kind === 'mobile')
|
||||
) {
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
### Banner copy
|
||||
|
||||
```
|
||||
🔒 Mobile is driving this terminal — your input is paused. [ Take back ]
|
||||
```
|
||||
|
||||
When PTY is at phone dims, append the dim suffix:
|
||||
```
|
||||
🔒 Mobile is driving this terminal (80×24) — your input is paused. [ Take back ]
|
||||
```
|
||||
|
||||
The phrasing makes Take back the discoverable resolution. There is no
|
||||
auto-unlock-on-WS-silence and no liveness probing; the banner stays up
|
||||
until the desktop user takes back, until mobile actor sends input (which
|
||||
keeps mobile as driver), or until the last mobile subscriber disconnects.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Mobile drops network**. WS doesn't immediately know. The inner
|
||||
subscriber map retains the entry for ~30s until ping timeout. Banner
|
||||
stays. Take back still works during this window — it transitions to
|
||||
`desktop` immediately. When mobile eventually reconnects with
|
||||
`terminal.subscribe`, that client takes the floor again
|
||||
(`desktop → mobile{thatClient}`).
|
||||
- **Two phones on same PTY**. The inner map carries both subscribers.
|
||||
Driver state is `mobile{whicheverActedLast}`. Phone A unsubscribing
|
||||
while phone B is still on does *not* drop the banner; only the last
|
||||
client leaving the inner map transitions to `idle`.
|
||||
- **Pre-locked input in flight**. `transport.sendInput` is
|
||||
fire-and-forget; there is no in-flight queue to drain. The first
|
||||
dropped keystroke is on the renderer side, which is good enough.
|
||||
- **Take back while phone is offline**. Works (transitions to `desktop`).
|
||||
When phone reconnects with a fresh subscribe, driver flips to
|
||||
`mobile{thatClient}`.
|
||||
- **Display mode toggle from mobile**. `terminal.setDisplayMode` is
|
||||
treated as mobile interaction → that client takes the floor. Aligns
|
||||
with "any deliberate mobile action takes the floor."
|
||||
- **Subscribe from a fresh client (reconnect with new clientId)**. The
|
||||
subscribe path runs the `desktop → mobile{thatClient}` (or
|
||||
`idle → mobile{thatClient}`) transition. A mobile reconnect takes back
|
||||
the floor from desktop — correct semantic since the user actively
|
||||
reopened the mobile view.
|
||||
- **Restore-debounce window on last-subscriber-leaves**. When the
|
||||
last mobile subscriber unsubscribes, the driver transitions to
|
||||
`idle` synchronously (banner unmounts immediately on desktop), but
|
||||
the PTY-dim restore runs inside the existing 300ms
|
||||
`pendingRestoreTimers` debounce. For up to 300ms after the banner
|
||||
unmounts, the PTY remains at phone dims while desktop input flows
|
||||
through. Desktop typing into a still-squished terminal is a brief
|
||||
visual mismatch, not a correctness bug — the existing debounce is
|
||||
there to absorb rapid tab switches without thrashing PTY size, and
|
||||
emitting `idle` synchronously preserves that behavior. Do not
|
||||
reorder these (driver-emit-first, then debounced restore) without
|
||||
re-introducing thrash.
|
||||
|
||||
## Tests
|
||||
|
||||
- `orca-runtime.test.ts` (driver state machine):
|
||||
- Every transition row in the table above has a unit test asserting
|
||||
`(prev, trigger) → (next, side effect, emitted event payload)`.
|
||||
- `getDriver` defaults to `{ kind: 'idle' }` for unknown ptyIds.
|
||||
- `terminalDriverChanged` is emitted exactly once per state change and
|
||||
carries the structured `DriverState` payload (not a boolean).
|
||||
- `orca-runtime.test.ts` (multi-mobile subscriber sequencing):
|
||||
- Phone A subscribes (`auto`) → driver is `mobile{A}`, banner emit
|
||||
fires.
|
||||
- Phone A subscribes (`desktop`) from `idle` → driver stays `desktop`
|
||||
(subscribe-in-desktop-mode does not take the floor); inner map
|
||||
has A.
|
||||
- Phone B subscribes (`auto`) while A is still on (`auto`) → driver
|
||||
flips to `mobile{B}` (subscribe-fresh-with-auto/phone counts as
|
||||
take-floor); inner map has both subscribers; active phone-fit
|
||||
dims switch to B's viewport.
|
||||
- Phone B unsubscribes while A is still on → driver remains mobile;
|
||||
active phone-fit dims revert to A's viewport (next-most-recent
|
||||
actor); banner stays up; no `idle` transition.
|
||||
- Phone A unsubscribes (last client leaves) → driver transitions to
|
||||
`idle`, banner unmounts, PTY restores to earliest-recorded
|
||||
desktop dims.
|
||||
- `applyMobileDisplayMode` picks the **most-recent-actor's**
|
||||
viewport for active phone-fit dims and the
|
||||
**earliest-by-subscribe-time** desktop dims for restore.
|
||||
- Mobile sets `displayMode` to `desktop` from `mobile{*}` does *not*
|
||||
transition to `desktop` automatically (existing setDisplayMode
|
||||
semantics stand) — but the runtime's *driver* transition rule for
|
||||
`desktop → desktop` (mode change to desktop while already in
|
||||
desktop driver) is a no-op.
|
||||
- `terminal.test.ts` (subscriptionId per-client keying):
|
||||
- Phone A subscribes to terminal handle `T`, phone B subscribes to
|
||||
the same handle `T` → A's data listener still receives bytes
|
||||
after B's subscribe (subscriptionId is `${T}:${clientId}` so
|
||||
`registerSubscriptionCleanup` does not collide).
|
||||
- Regression: prior to the fix, the second subscribe ran the first
|
||||
subscribe's cleanup and tore down phone A's stream.
|
||||
- `pty-connection.test.ts`:
|
||||
- `onData` is dropped while `getDriverForPty(id).kind === 'mobile'`.
|
||||
- `onData` is delivered when driver flips to `desktop` or `idle`.
|
||||
- `onResize` is dropped while driver is `mobile`.
|
||||
- `pty.test.ts` (or new `pty-driver-state.test.ts`):
|
||||
- `pty:write` IPC is dropped when `runtime.getDriver(id).kind ===
|
||||
'mobile'`.
|
||||
- `pty:resize` IPC is dropped under the same predicate.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Mobile yanking desktop tab focus via `terminal.focus`.
|
||||
- Cold-restore ack from mobile-only attach.
|
||||
- Mobile-side "Desktop is driving" banner (see "Asymmetric UX" above).
|
||||
|
||||
These are independent collisions tracked separately.
|
||||
|
||||
## Forward path
|
||||
|
||||
The natural next step (not in this PR) is unifying both writers — the
|
||||
desktop renderer's `pty:write` IPC and the mobile RPC's `terminal.send`
|
||||
— into a single coordinator that queues bytes through a runtime-owned
|
||||
write path. The driver state machine cleanly supports this: the
|
||||
coordinator can use `currentDriver(ptyId)` as the admission predicate
|
||||
(only the current driver's bytes are dequeued), and the
|
||||
`mobile{clientId}` payload tells it which queue head to drain when a
|
||||
specific phone is the active actor. Today's PR keeps two independent
|
||||
write paths and uses driver state purely as a drop-filter; the same
|
||||
state machine becomes the scheduling input on the forward path without
|
||||
a wire-format change.
|
||||
|
||||
## Rollout
|
||||
|
||||
Single PR. No feature flag needed; behavior is strictly additive (locks
|
||||
an existing collision surface) and the existing banner UX continues to
|
||||
work under the new driver-state model with sharper copy.
|
||||
@@ -0,0 +1,406 @@
|
||||
# Single Shared RPC Client per Host (Mobile)
|
||||
|
||||
Design doc for collapsing the per-screen WebSocket connection model into a
|
||||
single shared `RpcClient` per paired host, owned by a React context that
|
||||
sits above the route tree.
|
||||
|
||||
## Problem
|
||||
|
||||
The mobile app today opens **one WebSocket per screen per host**:
|
||||
|
||||
| Screen | Connections per host |
|
||||
|---|---|
|
||||
| Home (`app/index.tsx`) | 1 (with persistent `accounts.subscribe` stream) |
|
||||
| Host detail (`app/h/[hostId]/index.tsx`) | +1 |
|
||||
| Worktree session (`app/h/[hostId]/session/[worktreeId].tsx`) | +1 |
|
||||
| Accounts (`app/h/[hostId]/accounts.tsx`) | +1 |
|
||||
| Pair confirm (briefly) | +1 |
|
||||
|
||||
A user actively browsing one host typically holds **3–4 simultaneous
|
||||
sockets** to the desktop runtime. Each call to `connect()` runs its own
|
||||
E2EE handshake, allocates an ephemeral keypair, and runs an independent
|
||||
reconnect loop with exponential backoff.
|
||||
|
||||
This causes three observable problems:
|
||||
|
||||
1. **Stuck-connecting / reconnecting for minutes.** The desktop's
|
||||
`MAX_WS_CONNECTIONS = 32` is shared across all clients. A user who
|
||||
navigates rapidly accumulates stale sockets faster than they can be
|
||||
reaped by TCP keepalive (which can take 60–300s on default systems
|
||||
for half-open connections from a phone leaving Wi-Fi range or
|
||||
backgrounding). Once the cap is hit, new sockets are rejected with WS
|
||||
close code `1013 Maximum connections reached`. The mobile reconnect
|
||||
loop does not recognize 1013 as terminal — it retries with backoff,
|
||||
each retry also dropped, until enough stale sockets are reaped.
|
||||
Result: a screen that should connect in <1s is stuck for 1–5 minutes.
|
||||
2. **Tab create/delete hangs.** `client.sendRequest('terminal.create')`
|
||||
awaits `waitForConnected()`. If the session-screen client is in
|
||||
`connecting`/`reconnecting` state because its socket lost the cap
|
||||
race, the await blocks until the 30s `REQUEST_TIMEOUT_MS` fires.
|
||||
The user sees nothing happen.
|
||||
3. **Triple cost on every cold-start.** Three E2EE handshakes,
|
||||
three Curve25519 keypair generations, three subscription
|
||||
re-registrations on every app launch. On low-end Android, this
|
||||
visibly delays first paint by hundreds of milliseconds.
|
||||
|
||||
The architecture also wastes server resources: each socket carries its
|
||||
own E2EE channel, its own subscription set, its own driver-state-machine
|
||||
client identity (cf. `docs/mobile-presence-lock.md`). The server already
|
||||
has logic to reconcile multi-socket-per-token tear-down
|
||||
(`hasOtherConnections` in `ws-transport.ts`) — that logic exists *because*
|
||||
this design forced the question; with a single client per host, it becomes
|
||||
unnecessary.
|
||||
|
||||
## Today's transport ownership
|
||||
|
||||
Five files independently call `connect(endpoint, deviceToken, publicKeyB64)`:
|
||||
|
||||
```
|
||||
mobile/app/pair-confirm.tsx # one-shot during pairing
|
||||
mobile/app/pair-scan.tsx # one-shot during pairing
|
||||
mobile/app/index.tsx # N (one per paired host)
|
||||
mobile/app/h/[hostId]/index.tsx # 1 (host detail)
|
||||
mobile/app/h/[hostId]/session/[worktreeId].tsx # 1 (session)
|
||||
mobile/app/h/[hostId]/accounts.tsx # 1 (accounts)
|
||||
```
|
||||
|
||||
Each owns a `useRef<RpcClient | null>` and calls `client.close()` from a
|
||||
cleanup function. The home screen additionally maintains a
|
||||
`clientsRef: Array<{ hostId, client }>` so its own usage of the per-host
|
||||
client survives across navigation events.
|
||||
|
||||
This pattern works for *correctness* — every cleanup eventually closes
|
||||
its socket — but it breaks under three real-world conditions:
|
||||
|
||||
1. **Rapid navigation.** Mounts spawn before unmounts complete; cleanup
|
||||
`client.close()` runs after a new screen has already opened a fresh
|
||||
socket to the same host. Two sockets briefly coexist for the same
|
||||
token, multiplied across screens.
|
||||
2. **Network drops.** A backgrounded/locked phone on a flaky network
|
||||
leaves sockets half-open. The server doesn't get a FIN; cleanup
|
||||
relies on TCP keepalive timing. Meanwhile the foreground app, on
|
||||
resume, opens fresh sockets. The half-open ones eat the cap until
|
||||
reaped.
|
||||
3. **Hot reload during dev.** Each Metro hot reload fires a new render
|
||||
tree without unmounting the old, so connections leak.
|
||||
|
||||
## Goal
|
||||
|
||||
> A paired host has at most **one active WebSocket** at any time, owned
|
||||
> by a context provider above the route tree. All screens for that host
|
||||
> share that client. The pair flows are the only places that create
|
||||
> short-lived clients (and they explicitly close those after pairing
|
||||
> completes).
|
||||
|
||||
This is the architectural fix to the symptoms above. Combined with the
|
||||
two recently-shipped hotfixes (token cache + stable `useEffect`
|
||||
dependency on home screen), this completes the connection-lifecycle
|
||||
work.
|
||||
|
||||
## Design
|
||||
|
||||
### Layered ownership
|
||||
|
||||
```
|
||||
RootLayout (<RpcClientProvider>)
|
||||
└── routes
|
||||
└── <HostScopedClientGate hostId={...}> // mounts when route has hostId
|
||||
├── h/[hostId]/ // host detail
|
||||
├── h/[hostId]/session/[worktreeId]/ // session
|
||||
└── h/[hostId]/accounts/ // accounts
|
||||
```
|
||||
|
||||
Two providers, layered:
|
||||
|
||||
1. **`RpcClientProvider` (root)** — owns one `RpcClient` per host,
|
||||
keyed by `hostId`. Lifecycle: opens on first request for that
|
||||
host's client, holds open until app shutdown OR until the host is
|
||||
removed (`removeHost(hostId)` triggers explicit close). Reuses
|
||||
existing `loadHosts()` cache from the recently-merged
|
||||
`host-store.ts` work.
|
||||
2. **`HostScopedClientGate` (per host)** — a thin route-layout
|
||||
component placed at `app/h/_layout.tsx`. Reads `hostId` from
|
||||
route params, requests the client for that host from the root
|
||||
provider, exposes it via context to descendants, and renders a
|
||||
loading state until the client reaches `connected`. Guarantees
|
||||
every descendant screen sees the same client instance for that
|
||||
host — no per-screen `connect()` calls.
|
||||
|
||||
The home screen (`app/index.tsx`) lives outside `HostScopedClientGate`
|
||||
since it spans all hosts; it consumes the root provider directly via
|
||||
a multi-host hook (see API below).
|
||||
|
||||
### API
|
||||
|
||||
```ts
|
||||
// New file: mobile/src/transport/client-context.tsx
|
||||
type RpcClientContext = {
|
||||
// Get-or-open. Returns the singleton client for hostId; opens it
|
||||
// lazily on first call, reuses it for all subsequent callers. Never
|
||||
// returns null (returns a placeholder client in 'connecting' state
|
||||
// if open hasn't completed).
|
||||
getClient: (hostId: string) => RpcClient
|
||||
// Connection state for a given host (driven by client.onStateChange).
|
||||
useHostState: (hostId: string) => ConnectionState
|
||||
// Useful for the home screen which renders all hosts at once.
|
||||
useAllClients: () => Array<{ hostId: string; client: RpcClient }>
|
||||
}
|
||||
|
||||
export const RpcClientProvider: React.FC<{ children: React.ReactNode }>
|
||||
|
||||
export const useHostClient: (hostId: string) => {
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
}
|
||||
```
|
||||
|
||||
Internal store (single `useRef` in the provider):
|
||||
|
||||
```ts
|
||||
type StoreEntry = {
|
||||
client: RpcClient
|
||||
state: ConnectionState
|
||||
refCount: number // number of active screens holding this client
|
||||
closeTimer: NodeJS.Timeout | null
|
||||
}
|
||||
const store = useRef(new Map<string, StoreEntry>())
|
||||
```
|
||||
|
||||
### Lifecycle rules
|
||||
|
||||
1. **Open on first read.** First `getClient(hostId)` call for a host
|
||||
reads the host record (uses cached `loadHosts()`), then calls
|
||||
`connect()` and stores the client. Subsequent calls return the
|
||||
cached entry.
|
||||
2. **Idle close timer.** When `refCount` drops to 0 (all screens for
|
||||
that host unmounted), schedule a 30-second close timer. If a screen
|
||||
for the same host mounts within 30s, cancel the timer. Otherwise,
|
||||
close the client and remove from the store.
|
||||
- Why 30s: covers fast tab-switching and back-navigation without
|
||||
keeping idle sockets forever. Tunable based on observed behavior.
|
||||
3. **Forced close on host removal.** `removeHost(hostId)` from
|
||||
`host-store.ts` calls into the provider to close the client
|
||||
immediately and delete the store entry.
|
||||
4. **App backgrounded.** No special action — let TCP keepalive and
|
||||
server-side reaping handle it. Reconnect happens on foreground.
|
||||
5. **App foregrounded.** Trigger a `getState()` poll on every
|
||||
non-closed entry; if any are in `disconnected` (TCP died while
|
||||
backgrounded), the existing reconnect loop handles it. No new
|
||||
client allocations.
|
||||
|
||||
### Public surface for screens
|
||||
|
||||
Each screen replaces:
|
||||
|
||||
```ts
|
||||
// Before
|
||||
const [client, setClient] = useState<RpcClient | null>(null)
|
||||
const [connState, setConnState] = useState<ConnectionState>('disconnected')
|
||||
|
||||
useEffect(() => {
|
||||
let rpcClient: RpcClient | null = null
|
||||
void (async () => {
|
||||
const hosts = await loadHosts()
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) return
|
||||
rpcClient = connect(host.endpoint, host.deviceToken, host.publicKeyB64, setConnState)
|
||||
setClient(rpcClient)
|
||||
})()
|
||||
return () => {
|
||||
rpcClient?.close()
|
||||
}
|
||||
}, [hostId])
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```ts
|
||||
// After
|
||||
const { client, state } = useHostClient(hostId)
|
||||
```
|
||||
|
||||
Total LoC reduction across screens: ~150 lines.
|
||||
|
||||
### Pair flow exception
|
||||
|
||||
`app/pair-confirm.tsx` and `app/pair-scan.tsx` continue to call `connect()`
|
||||
directly with **explicit `client.close()`** after the test request returns.
|
||||
Reason: the host record doesn't yet exist in `loadHosts()` during pairing,
|
||||
so the provider has nothing to look up. The pair flow's client is a
|
||||
short-lived transient that delivers `getStatus()` once and then dies.
|
||||
|
||||
After `saveHost()` succeeds, the user is navigated away; the next time
|
||||
they enter `/h/[hostId]/...`, the provider opens a fresh client through
|
||||
the normal path.
|
||||
|
||||
### Streaming subscription handling
|
||||
|
||||
The home screen's `accounts.subscribe` stream and the session screen's
|
||||
terminal subscriptions remain owned by their respective screens — the
|
||||
provider doesn't manage subscriptions, only the underlying transport.
|
||||
Each screen's effect calls `client.subscribe(...)` and stores the
|
||||
returned unsubscribe function. On unmount, the screen unsubscribes
|
||||
(returns to the existing per-screen pattern, just over a shared
|
||||
transport). The transport's `subscribe()` already correctly multiplexes
|
||||
multiple listeners on one WebSocket via the `id` field.
|
||||
|
||||
### State propagation
|
||||
|
||||
`useHostState(hostId)` returns the live `ConnectionState`. The provider
|
||||
maintains a per-host `useState` keyed by hostId; the `client.onStateChange`
|
||||
listener is wired once at client creation and updates the corresponding
|
||||
state slot. `useHostState` reads from this state via `useSyncExternalStore`
|
||||
or a context selector — the choice is mostly preference; in this
|
||||
codebase, given the small state shape, a simple `useContext + useMemo`
|
||||
of the matching slot is fine.
|
||||
|
||||
## Migration
|
||||
|
||||
Step-by-step, each step independently shippable:
|
||||
|
||||
1. **Add `RpcClientProvider` and `useHostClient`.** No callers yet.
|
||||
Wire into `app/_layout.tsx`. Existing screens unchanged.
|
||||
2. **Migrate session screen** (highest-risk, most-used). Replace
|
||||
per-screen `connect()` with `useHostClient`. Test connection
|
||||
behavior, terminal create/delete, scrollback hydration.
|
||||
3. **Migrate host detail and accounts screens.** Same pattern.
|
||||
4. **Migrate home screen.** Replace `clientsRef` with
|
||||
`useAllClients()`. The home screen's per-host streaming
|
||||
subscriptions move into a hook that runs per-host.
|
||||
5. **Add `HostScopedClientGate`** at `app/h/_layout.tsx` to centralize
|
||||
the gate and remove duplicated loading-state logic.
|
||||
6. **Delete legacy code.** Remove the dead `connect()` import paths
|
||||
from each screen. Codepoint reduction.
|
||||
7. **Remove server-side `hasOtherConnections` complexity** in a
|
||||
follow-up: with one socket per token, the multi-socket reconciliation
|
||||
in `runtime-rpc.ts` `wsTransport.onConnectionClose` simplifies.
|
||||
This is a desktop-side cleanup PR done after mobile rolls out.
|
||||
|
||||
Each step is tested in isolation; rollback per step is trivial.
|
||||
|
||||
## Risks
|
||||
|
||||
### R1: Connection loss while screens are mounted
|
||||
|
||||
**Risk.** Today, when a screen unmounts due to network loss, its
|
||||
client closes and reopens on remount. Under the new design, a
|
||||
network-loss-during-screen-mounted means the client lives but is in
|
||||
`reconnecting` state.
|
||||
|
||||
**Mitigation.** The existing `RpcClient` already handles this — its
|
||||
internal reconnect loop runs invisibly. Screens already render based
|
||||
on `connState === 'connected'`, so they stay in their `connecting`
|
||||
UI until the loop succeeds. No regression.
|
||||
|
||||
### R2: One bad host poisons the singleton
|
||||
|
||||
**Risk.** If the client for one host is wedged in `reconnecting` due
|
||||
to a desktop-side issue, all screens for that host inherit the wedged
|
||||
state. Under the per-screen design, navigating to a different screen
|
||||
gave a fresh client with a chance to connect cleanly.
|
||||
|
||||
**Mitigation.** "Force reconnect" affordance: a button on the host
|
||||
detail "Connection issues" UI calls
|
||||
`provider.forceReconnect(hostId)` — close + reopen the client. Users
|
||||
who hit a stuck state get a one-tap recovery without uninstalling.
|
||||
Implemented as part of step 2.
|
||||
|
||||
### R3: Idle close timer races
|
||||
|
||||
**Risk.** A user navigates from session → home → back to session
|
||||
within 35 seconds. The 30s idle timer fires between hops and closes
|
||||
the client; the back-navigation has to wait for a fresh handshake.
|
||||
|
||||
**Mitigation.** Cancel the timer at `getClient(hostId)` time, not at
|
||||
mount time. As long as the consumer holds a reference to the client,
|
||||
the timer is paused. Standard refcount pattern.
|
||||
|
||||
### R4: Memory / state leak on rapid host removal
|
||||
|
||||
**Risk.** User removes a host while a screen for it is mounted.
|
||||
The screen's reference is now dangling.
|
||||
|
||||
**Mitigation.** `removeHost(hostId)` triggers an explicit close +
|
||||
delete from the store. Screens already handle `auth-failed` /
|
||||
`disconnected` states (the client transitions to one of them on
|
||||
forced close). Add a navigation-bounce in those states so the screen
|
||||
returns to the host list.
|
||||
|
||||
### R5: Pair-flow socket leaks through provider
|
||||
|
||||
**Risk.** If pair-confirm crashes mid-handshake before its explicit
|
||||
`close()`, the socket leaks.
|
||||
|
||||
**Mitigation.** Independent of the provider — same risk exists today.
|
||||
Add a try/finally + cleanup useEffect in pair-confirm.
|
||||
|
||||
### R6: Provider re-initialization on hot reload (dev only)
|
||||
|
||||
**Risk.** Metro hot reload re-runs `RpcClientProvider`, possibly
|
||||
spawning new clients while old ones are still in the store.
|
||||
|
||||
**Mitigation.** On provider mount, scan the store for entries whose
|
||||
clients report `closed` state and prune. Acceptable dev-only friction.
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Unit / hook tests
|
||||
|
||||
- `useHostClient` returns the same client instance across multiple
|
||||
consumers for the same hostId.
|
||||
- `removeHost(hostId)` immediately closes the client (assert via
|
||||
`client.getState()`).
|
||||
- Idle close timer: zero refcount → 30s wait → client closed.
|
||||
- Idle close timer cancellation: zero refcount → 15s wait →
|
||||
consumer subscribes → no close.
|
||||
|
||||
### Integration / manual
|
||||
|
||||
- Create one host; navigate home → host detail → session → back ×10
|
||||
rapidly. Single socket on desktop (verify via desktop debug log).
|
||||
- Background app for 5 min; foreground; verify reconnect uses same
|
||||
client instance, no leak.
|
||||
- Remove host while session screen mounted; screen bounces back to
|
||||
home; client closed.
|
||||
- Force-reconnect button on host detail; client closes and reopens
|
||||
cleanly.
|
||||
- Hot reload during development; no socket leak (verify desktop
|
||||
active-connection count).
|
||||
|
||||
### Regression
|
||||
|
||||
- Terminal create/delete works during normal browse (Bug A from
|
||||
initial reports — should never recur once cap pressure is removed).
|
||||
- Scrollback hydration unchanged.
|
||||
- Phone-fit / driver-lock state machine unchanged
|
||||
(`docs/mobile-presence-lock.md` invariants hold).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Desktop-side connection LRU eviction.** Useful as a defense-in-depth
|
||||
but not needed once mobile self-limits to one socket per host.
|
||||
- **Application-level ping/pong.** Worth adding but separate concern;
|
||||
helps server reap dead sockets faster regardless of how many a single
|
||||
client opens.
|
||||
- **iOS share extension or system-wide deep-link integration.** Future
|
||||
product work.
|
||||
|
||||
## Effort estimate
|
||||
|
||||
3–4 hours including tests and incremental migration. Each step
|
||||
independently mergeable.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/mobile-presence-lock.md` — driver-state-machine that depends
|
||||
on per-client identity. Single-client-per-host simplifies but
|
||||
doesn't break this contract.
|
||||
- `docs/mobile-prefer-renderer-scrollback.md` — scrollback hydration
|
||||
flow. Subscriptions remain per-screen; transport changes are
|
||||
transparent.
|
||||
- `mobile/src/transport/rpc-client.ts` — existing `connect()`
|
||||
implementation; reconnect loop, E2EE handshake, subscription
|
||||
multiplexing all preserved as-is.
|
||||
- `src/main/runtime/rpc/ws-transport.ts` — server-side
|
||||
`MAX_WS_CONNECTIONS = 32`, `hasOtherConnections` reconciliation
|
||||
that becomes simpler post-migration.
|
||||
@@ -1,81 +0,0 @@
|
||||
# Orca Performance Audit
|
||||
|
||||
Audit date: 2026-04-13
|
||||
Branch: `Jinwoo-H/performance-improvement`
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — High Impact, Low Complexity
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 1 | Renderer | `App.tsx` has 53 separate Zustand subscriptions — nearly every state change re-renders the root and cascades through the entire tree | `src/renderer/src/App.tsx:40-180` | DONE (53→22 subs, memo barriers on 4 children) |
|
||||
| 2 | Renderer | `Terminal.tsx` has unscoped `useAppStore.subscribe()` — fires on every store mutation, does O(worktrees×tabs) scan each time | `src/renderer/src/components/Terminal.tsx:645-659` | DONE |
|
||||
| 3 | Terminal | No IPC batching — every PTY data chunk is a separate `webContents.send` call, hundreds/sec under load | `src/main/ipc/pty.ts:173-177` | DONE |
|
||||
| 4 | Terminal | Divider drag calls `fitAddon.fit()` on every `pointermove` pixel — xterm reflow can take 500ms+ with large scrollback | `src/renderer/src/lib/pane-manager/pane-divider.ts:90-119` | DONE |
|
||||
| 5 | Main | Startup blocks: `openCodeHookService.start()` and `runtimeRpc.start()` awaited sequentially before window opens | `src/main/index.ts:131-168` | DONE |
|
||||
| 6 | Main | `persistence.ts` uses `readFileSync`/`writeFileSync`/`renameSync` on the main thread — blocks during startup and every 300ms save | `src/main/persistence.ts:59-125` | DONE |
|
||||
| 7 | Worktree | `worktrees:listAll` iterates repos sequentially with `await` — total time = sum of all repos instead of max | `src/main/ipc/worktrees.ts:53-84` | DONE |
|
||||
| 8 | Browser | Reverse Map scan O(N) on every mouse/load/permission event in `BrowserManager` | `src/main/browser/browser-manager.ts:91-96` | DONE |
|
||||
| 9 | Browser | `before-mouse-event` listener fires for ALL mouse events on ALL guests, even background ones | `src/main/browser/browser-guest-ui.ts:78-93` | DONE |
|
||||
| 10 | Worktree | `refreshGitHubForWorktree` bypasses 5-min cache TTL on every worktree switch — fires GitHub API calls on rapid tab switching | `src/renderer/src/store/slices/worktrees.ts:467-489` | DONE |
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — Medium Impact
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 11 | Main | `git/repo.ts`: `execSync('gh api user ...')` and chains of 5 sync git processes block main thread | `src/main/git/repo.ts:87-138` | TODO |
|
||||
| 12 | Main | `hooks.ts`: `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` in IPC handlers | `src/main/hooks.ts:113-416` | TODO |
|
||||
| 13 | Renderer | `useSettings` returns entire `GlobalSettings` (~30+ fields) — any setting change re-renders all consumers | `src/renderer/src/store/selectors.ts` | TODO |
|
||||
| 14 | Renderer | Tab title changes bump `sortEpoch` for ALL worktrees → triggers WorktreeList re-sort on every PTY title event | `src/renderer/src/store/slices/terminals.ts:325` | TODO |
|
||||
| 15 | Renderer | `CacheTimer`: one `setInterval` per mounted card (20 cards = 20 intervals/sec), each with O(n) selector work | `src/renderer/src/components/sidebar/CacheTimer.tsx:28-48` | TODO |
|
||||
| 16 | Renderer | Three simultaneous 3-second polling intervals per active worktree (git status, worktrees, stale conflict) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts` | TODO |
|
||||
| 17 | Renderer | 6 components missing `React.memo`: `SourceControl`, `RightSidebar`, `EditorPanel`, `ChecksPanel`, `FileExplorer`, `TabBar` | Various files in `src/renderer/src/components/` | DONE (8 components wrapped) |
|
||||
| 18 | Worktree | Git polling fires immediately on every worktree switch (burst of `git status` + `git worktree list`) | `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts:69-103` | REVERTED (150ms debounce caused visible flash on switch) |
|
||||
| 19 | Worktree | `FileExplorer` `dirCache` discarded on every worktree switch — re-fetches entire tree from scratch | `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts:27,81-96` | TODO |
|
||||
| 20 | Terminal | `pty:resize` uses `ipcRenderer.invoke` (round-trip) instead of fire-and-forget `send` | `src/preload/index.ts:203-205` | DONE |
|
||||
| 21 | Terminal | Flow control watermarks defined but never enforced for local PTYs — unbounded output floods renderer | `src/main/providers/local-pty-provider.ts:330-332` | TODO |
|
||||
| 22 | Browser | `BrowserPane` subscribes to entire `browserPagesByWorkspace` map — any tab's navigation re-renders all panes | `src/renderer/src/components/browser-pane/BrowserPane.tsx:312` | TODO |
|
||||
| 23 | Browser | `findPage`/`findWorkspace` do O(N) `Object.values().flat().find()` scans on every navigation event | `src/renderer/src/store/slices/browser.ts:221-240` | TODO |
|
||||
| 24 | Browser | Download progress IPC fires at full Chromium frequency (many/sec) | `src/main/browser/browser-manager.ts:421-430` | TODO |
|
||||
| 25 | Main | `detectConflictOperation` runs 4 `existsSync` + `readFile` on every 3s poll before git status | `src/main/git/status.ts:236-265` | DONE |
|
||||
| 26 | Main | `getBranchCompare`: `loadBranchChanges` and `countAheadCommits` run sequentially | `src/main/git/status.ts:374-375` | DONE |
|
||||
| 27 | Main | `addWorktree` calls 4-5 synchronous git processes from IPC handler | `src/main/git/worktree.ts:116-157` | DONE |
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — Lower Impact / Architectural
|
||||
|
||||
| # | Area | Issue | File(s) | Status |
|
||||
|---|------|-------|---------|--------|
|
||||
| 28 | Terminal | SSH relay `FrameDecoder` uses `Buffer.concat` on every chunk (quadratic copy) | `src/relay/protocol.ts:84` | TODO |
|
||||
| 29 | Terminal | SSH relay replay buffer uses string concatenation (quadratic allocation) | `src/relay/pty-handler.ts:68-72` | TODO |
|
||||
| 30 | Terminal | `extractLastOscTitle` regex runs on every PTY chunk with no fast-path bail | `src/shared/agent-detection.ts:23-39` | TODO |
|
||||
| 31 | Terminal | Binary search calls `serialize()` up to 16× at shutdown | `src/renderer/src/components/terminal-pane/TerminalPane.tsx:579-596` | TODO |
|
||||
| 32 | Browser | `capturePage()` captures full viewport then crops — should pass rect directly | `src/main/browser/browser-grab-screenshot.ts:36` | TODO |
|
||||
| 33 | Browser | Parked webviews retain full 100vw×100vh compositor surfaces | `src/renderer/src/components/browser-pane/BrowserPane.tsx:94-107` | TODO |
|
||||
| 34 | Browser | `onBeforeSendHeaders` intercepts every HTTPS request even when UA override unused | `src/main/browser/browser-session-registry.ts:126-137` | TODO |
|
||||
| 35 | Main | `setBackgroundThrottling(false)` wastes CPU when window is minimized | `src/main/window/createMainWindow.ts:97` | TODO |
|
||||
| 36 | Main | `warmSystemFontFamilies()` competes with startup I/O | `src/main/system-fonts.ts:30-32` | TODO |
|
||||
| 37 | Renderer | Session persistence effect has 15 deps — fires on every tab title change | `src/renderer/src/App.tsx:239-283` | TODO |
|
||||
| 38 | Renderer | Per-card `fetchPRForBranch` on mount — 30 worktrees = 30 simultaneous IPC calls | `src/renderer/src/components/sidebar/WorktreeCard.tsx:128-132` | TODO |
|
||||
| 39 | Renderer | Synchronous full `monaco-editor` import blocks EditorPanel chunk evaluation | `src/renderer/src/components/editor/EditorPanel.tsx:7` | TODO |
|
||||
| 40 | Worktree | `removeWorktree` runs `git worktree list` twice (pre and post removal) | `src/main/git/worktree.ts:163-208` | TODO |
|
||||
| 41 | Worktree | `worktrees:list` can trigger duplicate `git worktree list` when cache is dirty | `src/main/ipc/worktrees.ts:86-116` | TODO |
|
||||
| 42 | Renderer | SSH targets initialized sequentially in a `for...await` loop | `src/renderer/src/hooks/useIpcEvents.ts:232-252` | TODO |
|
||||
|
||||
---
|
||||
|
||||
## Key Themes
|
||||
|
||||
1. **Zustand subscription granularity** — `App.tsx` subscribes to 53 slices, `Terminal.tsx` subscribes to everything, `useSettings` returns the full object. Almost any state change cascades through the entire tree.
|
||||
|
||||
2. **Synchronous I/O on the main thread** — `persistence.ts`, `hooks.ts`, `git/repo.ts`, and `git/worktree.ts` use `readFileSync`/`writeFileSync`/`execSync` in startup and IPC handler paths.
|
||||
|
||||
3. **Unthrottled high-frequency events** — PTY data (no IPC batching), divider drag (fit on every pixel), download progress (no throttle), `before-mouse-event` (all mouse events on all guests), CacheTimer (20 intervals/sec).
|
||||
|
||||
4. **Sequential operations that could be parallel** — Startup server binds, repo iteration in `listAll`, git ref probing, `loadBranchChanges`/`countAheadCommits`, SSH target init.
|
||||
|
||||
5. **Aggressive polling** — Three 3-second intervals per worktree, per-card 1-second cache timers, per-card 5-minute issue polling, 250ms error-page detection.
|
||||
@@ -1,179 +0,0 @@
|
||||
# Performance Improvement — Implementation Plan
|
||||
|
||||
Branch: `Jinwoo-H/performance-improvement`
|
||||
|
||||
---
|
||||
|
||||
## Completed (Tier 1)
|
||||
|
||||
### Fix 3 — PTY IPC Data Batching
|
||||
**Files:** `src/main/ipc/pty.ts`
|
||||
**Change:** Added 8ms flush-window batching for PTY data. Instead of calling `webContents.send('pty:data', ...)` on every `node-pty` onData event, data is accumulated per-PTY in a `Map<string, string>` and flushed once per 8ms interval. Reduces IPC round-trips from hundreds/sec to ~120/sec under high throughput. Interactive latency stays below one frame (16ms).
|
||||
|
||||
### Fix 4 — Divider Drag Throttled to rAF
|
||||
**Files:** `src/renderer/src/lib/pane-manager/pane-divider.ts`
|
||||
**Change:** Wrapped `refitPanesUnder` calls in `requestAnimationFrame` guard during `onPointerMove`. Previously `fitAddon.fit()` ran on every pointer event (~250Hz), each triggering a full xterm.js reflow (500ms+ with large scrollback). Now capped at 60fps. Cleanup on `onPointerUp` cancels pending rAF and runs one final refit.
|
||||
|
||||
### Fix 5 — Startup Parallelization
|
||||
**Files:** `src/main/index.ts`
|
||||
**Change:** `openCodeHookService.start()`, `runtimeRpc.start()`, and `openMainWindow()` now run concurrently via `Promise.all` instead of three sequential `await`s. Window creation no longer blocked by server bind operations.
|
||||
|
||||
### Fix 6 — Async Persistence Writes
|
||||
**Files:** `src/main/persistence.ts`, `src/main/persistence.test.ts`
|
||||
**Change:** Debounced `scheduleSave()` now calls `writeToDiskAsync()` using `fs/promises` (writeFile, rename, mkdir) instead of `writeFileSync`/`renameSync`. Synchronous `writeToDiskSync()` retained only for `flush()` at shutdown. Added `waitForPendingWrite()` for test await support.
|
||||
|
||||
### Fix 7 — Parallel Worktree Listing
|
||||
**Files:** `src/main/ipc/worktrees.ts`
|
||||
**Change:** `worktrees:listAll` handler now uses `Promise.all(repos.map(...))` instead of sequential `for...of` loop. Total time = slowest repo, not sum of all repos. Each repo's `listRepoWorktrees` spawns `git worktree list` subprocess independently.
|
||||
|
||||
### Fix 8 — BrowserManager Reverse Map
|
||||
**Files:** `src/main/browser/browser-manager.ts`
|
||||
**Change:** Added `tabIdByWebContentsId` reverse Map maintained in sync with `webContentsIdByTabId`. Replaced two O(N) `[...entries()].find()` scans with O(1) `.get()` lookups. Updated `registerGuest`, `unregisterGuest`, and `unregisterAll` to keep both maps in sync.
|
||||
|
||||
### Fix 9 — Context Menu Listener Scoping
|
||||
**Files:** `src/main/browser/browser-guest-ui.ts`
|
||||
**Change:** `before-mouse-event` listener is now installed only when a context menu is open (on `context-menu` event) and removed on first `mouseDown` (dismiss). Previously fired for every mouse event on every guest surface.
|
||||
|
||||
### Fix 10 — GitHub Cache TTL on Worktree Switch
|
||||
**Files:** `src/renderer/src/store/slices/github.ts`, `src/renderer/src/store/slices/worktrees.ts`, `src/renderer/src/store/slices/store-cascades.test.ts`
|
||||
**Change:** Added `refreshGitHubForWorktreeIfStale()` that checks cache age before fetching. `setActiveWorktree` now calls this instead of `refreshGitHubForWorktree` (which always force-refreshes). Eliminates unnecessary GitHub API calls on rapid worktree switching. Force-refresh still available via explicit user action.
|
||||
|
||||
### Fix 20 (Tier 2 bonus) — PTY Resize Fire-and-Forget
|
||||
**Files:** `src/main/ipc/pty.ts`, `src/preload/index.ts`
|
||||
**Change:** `pty:resize` changed from `ipcMain.handle`/`ipcRenderer.invoke` (round-trip) to `ipcMain.on`/`ipcRenderer.send` (fire-and-forget). Halves IPC traffic for terminal resize events since the renderer never awaited the response anyway.
|
||||
|
||||
---
|
||||
|
||||
## Completed (Tier 1 — continued)
|
||||
|
||||
### Fix 1b — Session Persistence Extracted from React
|
||||
**Files:** `src/renderer/src/App.tsx`
|
||||
**Change:** Replaced the session-persistence `useEffect` (which had ~15 Zustand subscriptions as deps) with a single `useAppStore.subscribe()` call that runs outside React's render cycle. The subscriber debounces writes to disk via `window.setTimeout(150ms)`. This removed 12 `useAppStore` subscriptions from App's render cycle (`activeRepoId`, `terminalLayoutsByTabId`, `openFiles`, `activeFileIdByWorktree`, `activeTabTypeByWorktree`, `activeTabIdByWorktree`, `browserTabsByWorktree`, `browserPagesByWorkspace`, `activeBrowserTabIdByWorktree`, `unifiedTabsByWorktree`, `groupsByWorktree`, `activeGroupIdByWorktree`) — none of which ever drove JSX.
|
||||
|
||||
### Fix 1a — Consolidated Action Subscriptions
|
||||
**Files:** `src/renderer/src/App.tsx`
|
||||
**Change:** Consolidated 19 stable action-ref subscriptions (`toggleSidebar`, `fetchRepos`, `openModal`, `setRightSidebarTab`, etc.) into a single `useShallow` selector returning an `actions` object. Since Zustand actions are referentially stable, the shallow equality check always passes and this subscription never triggers a re-render. All call sites updated to `actions.fetchRepos()`, `actions.toggleSidebar()`, etc.
|
||||
|
||||
### Fix 1c — React.memo Barriers for Children
|
||||
**Files:** `src/renderer/src/components/sidebar/index.tsx`, `src/renderer/src/components/Terminal.tsx`, `src/renderer/src/components/right-sidebar/index.tsx`, `src/renderer/src/components/status-bar/StatusBar.tsx`
|
||||
**Change:** Wrapped `Sidebar`, `Terminal`, `RightSidebar`, and `StatusBar` in `React.memo`. These components accept no props from App — they read state from the store directly. The memo barrier prevents App's remaining re-renders (from layout state like `sidebarWidth`, `activeView`) from cascading into the full component tree.
|
||||
|
||||
### Fix 2 — Terminal.tsx Scoped Subscribe
|
||||
**Files:** `src/renderer/src/components/Terminal.tsx`
|
||||
**Change:** The `useAppStore.subscribe()` that destroys orphaned browser webviews now short-circuits with a reference equality check (`state.browserTabsByWorktree === prevBrowserTabs`). Previously fired on every store mutation; now only runs the O(tabs) scan when `browserTabsByWorktree` actually changes.
|
||||
|
||||
**Total App.tsx subscription reduction: 53 → 22 (58% fewer)**
|
||||
|
||||
---
|
||||
|
||||
## Planned — Tier 2
|
||||
|
||||
### Fix 11 — Async Git Username/Login
|
||||
**Files:** `src/main/git/repo.ts`
|
||||
**Change:** Replace `execSync('gh api user ...')` and `gitExecFileSync` chains in `getGhLogin`, `getGitUsername`, and `getDefaultBaseRef` with their async equivalents. `getDefaultBaseRefAsync` already exists — remove the sync variant and migrate all callers. The `Store.hydrateRepo` call in `getRepos()` is synchronous and uses `getGitUsername` — convert to a lazy-populate pattern where the `gitUsername` field is initially empty and filled by an async hydration pass after construction.
|
||||
|
||||
### Fix 12 — Async Hooks File I/O
|
||||
**Files:** `src/main/hooks.ts`
|
||||
**Change:** Convert `loadHooks`, `hasHooksFile`, `hasUnrecognizedOrcaYamlKeys`, `readIssueCommand`, `writeIssueCommand`, and `createWorktreeRunnerScript` from `readFileSync`/`writeFileSync`/`mkdirSync`/`gitExecFileSync` to `fs/promises` + `gitExecFileAsync`. Update all callers in `src/main/ipc/worktrees.ts` to await the new async versions.
|
||||
|
||||
### Fix 13 — Narrow `useSettings` Selector
|
||||
**Files:** `src/renderer/src/store/selectors.ts`, all 10 consumers
|
||||
**Change:** The current `useSettings = () => useAppStore((s) => s.settings)` returns the entire GlobalSettings object (~30 fields). Any setting change re-renders every consumer. Replace with field-specific selectors or use `useShallow` at each call site to select only the fields used by that component:
|
||||
- `App.tsx` only uses `settings.theme` → `useAppStore((s) => s.settings?.theme)`
|
||||
- `MonacoEditor.tsx` uses font/tab/theme → `useShallow` for those 3 fields
|
||||
- `AddWorktreeDialog.tsx` uses one field → direct selector
|
||||
|
||||
### Fix 14 — Narrow sortEpoch Bumping
|
||||
**Files:** `src/renderer/src/store/slices/terminals.ts`
|
||||
**Change:** `updateTabTitle` currently bumps `sortEpoch` on every title string change for background worktrees (line 354). Title strings change frequently during agent runs (shell prompts, command names). Only bump `sortEpoch` when the agent working/idle status boundary is actually crossed. This requires comparing the old and new title against the agent-status detection logic before deciding to increment.
|
||||
|
||||
### Fix 15 — Shared CacheTimer Interval
|
||||
**Files:** `src/renderer/src/components/sidebar/CacheTimer.tsx`
|
||||
**Change:** Each `CacheTimer` instance creates its own 1-second `setInterval`. With 20 visible cards this means 20 intervals firing per second, each running a Zustand selector that iterates `Object.keys(s.cacheTimerByKey)`. Replace with a single shared interval at the module level (or in the store slice) that updates a `remainingByWorktreeId` map in one `set()` call. Components subscribe to only their specific worktree's entry.
|
||||
|
||||
### Fix 16 — Consolidate Git Status Polling
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
|
||||
**Change:** Three `setInterval(fn, 3000)` calls run simultaneously: git status, fetchWorktrees, and stale conflict poll. The worktree list poll (every 3s) is aggressive — branch changes inside terminals are low-frequency. Consolidate into a single interval:
|
||||
- Git status poll: keep at 3s (drives diff gutter, status badge)
|
||||
- Worktree list poll: increase to 15s (only needed when user runs `git checkout` in terminal)
|
||||
- Stale conflict poll: keep at 3s but only when stale worktrees exist (already gated)
|
||||
|
||||
### Fix 17 — React.memo on Heavy Components
|
||||
**Files:** `SourceControl.tsx`, `EditorPanel.tsx`, `FileExplorer.tsx`, `TabBar.tsx`
|
||||
**Change:** Wrapped all four in `React.memo`. Combined with the Tier 1 memo barriers on Sidebar/Terminal/RightSidebar/StatusBar, a total of 8 heavy components now prevent parent re-render cascades.
|
||||
|
||||
### Fix 18 — Debounce Git Polling on Worktree Switch
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useGitStatusPolling.ts`
|
||||
**Change:** Replaced the immediate `void fetchStatus()` and `void fetchWorktrees(activeRepoId)` calls with 150ms `setTimeout` debounces. The interval polling continues as before. Rapid worktree switching now only fires one git status + one git worktree list subprocess instead of N.
|
||||
|
||||
### Fix 19 — Cache FileExplorer dirCache Per Worktree
|
||||
**Files:** `src/renderer/src/components/right-sidebar/useFileExplorerTree.ts`
|
||||
**Change:** `dirCache` is local `useState` — reset on every worktree switch. Cache the directory tree per worktree in a `useRef<Map<string, DirCache>>()` at the hook level. On switch, restore from cache instantly (with a background revalidation fetch). This makes repeated worktree switches O(1) for the file explorer.
|
||||
|
||||
### Fix 21 — Local PTY Flow Control
|
||||
**Files:** `src/main/providers/local-pty-provider.ts`, `src/renderer/src/components/terminal-pane/pty-dispatcher.ts`
|
||||
**Change:** Wire up the already-defined `PTY_FLOW_HIGH_WATERMARK` (100KB) and `PTY_FLOW_LOW_WATERMARK` (5KB) constants. Track pending bytes per PTY in the renderer's `EagerPtyBuffer`. When pending exceeds high watermark, send an IPC message to pause the node-pty stream. Resume when acknowledged down to low watermark. The `acknowledgeDataEvent` channel is already plumbed — just needs implementation.
|
||||
|
||||
### Fix 22 — Narrow BrowserPane Selector
|
||||
**Files:** `src/renderer/src/components/browser-pane/BrowserPane.tsx`
|
||||
**Change:** Line 312: `useAppStore((s) => s.browserPagesByWorkspace)` subscribes to the entire map. Any tab's navigation re-renders all BrowserPane instances. Narrow to: `useAppStore((s) => s.browserPagesByWorkspace[browserTab.id] ?? EMPTY_BROWSER_PAGES)`.
|
||||
|
||||
### Fix 23 — Index-Based findPage/findWorkspace
|
||||
**Files:** `src/renderer/src/store/slices/browser.ts`
|
||||
**Change:** `findWorkspace` and `findPage` (lines 221-240) use `Object.values().flat().find()` on every navigation event. Accept `worktreeId`/`workspaceId` as a hint parameter and do direct key access: `browserTabsByWorktree[worktreeId]?.find(...)` instead of flattening across all worktrees.
|
||||
|
||||
### Fix 24 — Throttle Download Progress
|
||||
**Files:** `src/main/browser/browser-manager.ts`
|
||||
**Change:** `download.item.on('updated', ...)` fires at full Chromium frequency. Add per-download throttle timer — only call `sendDownloadProgress` at most once per 250ms. Clear throttle timer on download done/cancel.
|
||||
|
||||
### Fix 25 — Parallelize detectConflictOperation with git status
|
||||
**Files:** `src/main/git/status.ts`
|
||||
**Change:** `getStatus()` now kicks off both `detectConflictOperation()` and `git status` concurrently. The conflict detection promise is started first and awaited before the status result, preserving error semantics while overlapping I/O.
|
||||
|
||||
### Fix 26 — Parallelize getBranchCompare
|
||||
**Files:** `src/main/git/status.ts`
|
||||
**Change:** `loadBranchChanges` and `countAheadCommits` now run via `Promise.all` instead of sequentially. These are independent git subprocess calls.
|
||||
|
||||
### Fix 27 — Async addWorktree
|
||||
**Files:** `src/main/git/worktree.ts`, `src/main/ipc/worktree-remote.ts`, `src/main/runtime/orca-runtime.ts`
|
||||
**Change:** Converted `addWorktree` from synchronous (`gitExecFileSync`) to async (`gitExecFileAsync`). This was the last major sync git operation on the main thread — 4-5 sequential subprocess calls that blocked the event loop during worktree creation. Updated callers and all 7 tests.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark Validation Results (2026-04-14)
|
||||
|
||||
Ran 6 targeted benchmarks to validate optimization claims. Full scripts in `benchmarks/`.
|
||||
|
||||
| Fix | What was measured | Result | Verdict |
|
||||
|-----|-------------------|--------|---------|
|
||||
| 3 — PTY Batching | IPC calls/sec: unbatched vs 8ms window | 5000→125 calls/sec (98% reduction) | **Validated — high impact** |
|
||||
| 5/7 — Parallelization | Sequential vs parallel subprocess at N=5 | 119ms→46ms (2.6x, 73ms saved) | **Validated — high impact** |
|
||||
| 6 — Async I/O | Main-thread blocking: sync vs fire-and-forget | 439µs→21µs per write (21x less blocking) | **Validated — high impact** |
|
||||
| 1 — Zustand Subs | Selector cost: 53 subs vs 18 subs | 2.61µs→1.59µs per mutation (1.6x) | **Validated — moderate** (real win is cascade prevention via React.memo) |
|
||||
| 8 — Reverse Map | entries().find() vs Map.get() at N=10 | 409ns→1ns (479x) but 0.04ms/sec total | **Deprioritize** — micro-optimization |
|
||||
| 23 — flat().find() | Object.values().flat().find() vs direct at 50 tabs | 1958ns→62ns (31x) but 0.02ms/sec total | **Deprioritize** — micro-optimization |
|
||||
|
||||
### Deprioritized based on benchmarks
|
||||
|
||||
The following fixes are technically correct but save <0.1ms/sec at realistic load. Moved to "nice to have":
|
||||
- Fix 8 (Reverse Map) — already implemented, keep as-is
|
||||
- Fix 14 (sortEpoch) — per-mutation overhead is negligible
|
||||
- Fix 15 (CacheTimer shared interval) — 20 intervals/sec is fine for modern JS engines
|
||||
- Fix 22 (BrowserPane selector) — sub-microsecond per render
|
||||
- Fix 23 (findPage/findWorkspace) — 0.02ms/sec at 10 nav/sec
|
||||
- Fix 24 (Download progress throttle) — infrequent event
|
||||
- Fix 26 (getBranchCompare parallel) — implemented anyway since it was a one-liner
|
||||
|
||||
---
|
||||
|
||||
## Key Themes
|
||||
|
||||
1. **Zustand subscription granularity** — Fixes 1, 2, 13, 14, 17, 22 all reduce the blast radius of state changes on React re-renders.
|
||||
|
||||
2. **Synchronous I/O on main thread** — Fixes 6, 11, 12, 27 convert blocking filesystem and git operations to async.
|
||||
|
||||
3. **Unthrottled high-frequency events** — Fixes 3, 4, 9, 15, 20, 24 cap event processing to reasonable rates.
|
||||
|
||||
4. **Sequential → parallel** — Fixes 5, 7, 25, 26 run independent async operations concurrently.
|
||||
|
||||
5. **Aggressive polling** — Fixes 16, 18 consolidate and debounce polling intervals.
|
||||
@@ -1,101 +0,0 @@
|
||||
# Preload typecheck hole: why project-owned types live in `.ts`
|
||||
|
||||
## The rule
|
||||
|
||||
Project-owned type declarations under `src/preload/` and `src/shared/`
|
||||
**must live in `.ts` files, not `.d.ts`**. The CI step
|
||||
"Guard against project-owned .d.ts in preload/shared" in
|
||||
`.github/workflows/pr.yml` enforces this.
|
||||
|
||||
## Why
|
||||
|
||||
Orca inherits `skipLibCheck: true` from `@electron-toolkit/tsconfig`.
|
||||
That setting is the ecosystem default — it exists so a broken `.d.ts`
|
||||
in some random `node_modules` package can't block your build. TypeScript
|
||||
has no way to scope it to `node_modules`, so **`skipLibCheck`
|
||||
applies to our own `.d.ts` files too**.
|
||||
|
||||
In a project-owned `.d.ts`, any type reference that fails to resolve
|
||||
silently becomes `any` at its call sites instead of erroring. Downstream
|
||||
assignments against that `any` are also silently accepted. The error
|
||||
never surfaces during `pnpm typecheck`.
|
||||
|
||||
For example, with `skipLibCheck: true`:
|
||||
|
||||
```ts
|
||||
// in src/preload/index.d.ts — Worktree is never imported
|
||||
type WorktreesApi = {
|
||||
list: () => Promise<Worktree[]> // silently becomes Promise<any[]>
|
||||
}
|
||||
```
|
||||
|
||||
and at the call site:
|
||||
|
||||
```ts
|
||||
// in any renderer file
|
||||
window.api.worktrees.list().then((arr) => {
|
||||
setWorktreeName(arr) // setWorktreeName expects string; accepted anyway because arr is any[]
|
||||
})
|
||||
```
|
||||
|
||||
No compile error. Crashes at runtime.
|
||||
|
||||
The standard TS convention that sidesteps this: put project-owned types in
|
||||
`.ts` (which are always checked), reserve `.d.ts` for ambient shims
|
||||
(`env.d.ts`, `vite/client.d.ts`, etc.). The CI guard encodes that
|
||||
convention mechanically.
|
||||
|
||||
## Incident that forced the fix
|
||||
|
||||
PR #1186 changed the `repos:getBaseRefDefault` IPC return shape from
|
||||
`Promise<string | null>` to `Promise<BaseRefDefaultResult>` (an envelope
|
||||
object). Two of three renderer callers were updated; the third
|
||||
(`StartFromField.tsx`) wasn't. That caller passed the envelope object
|
||||
into a `setState<string | null>` setter, which rendered as JSX and threw
|
||||
React error #31 (`Objects are not valid as a React child`).
|
||||
|
||||
**The call site should have been a compile error.** It wasn't, because
|
||||
`src/preload/index.d.ts` (now deleted) was a 246-line project-owned
|
||||
`.d.ts` that referenced ~20 type names it never imported (`Worktree`,
|
||||
`PRInfo`, `GlobalSettings`, `BaseRefDefaultResult`, and more). Under
|
||||
`skipLibCheck`, each unresolved name became `any`, which widened the
|
||||
`.then((ref) => …)` callback parameter to `any` at the consuming call
|
||||
site. `setDefaultBaseRef(ref: any)` compiled cleanly.
|
||||
|
||||
The crash is fixed by #1189. The typecheck hole is fixed by this PR
|
||||
(#1197), which collapses the two preload type files (`index.d.ts` +
|
||||
`api-types.d.ts`) into a single type-checked `api-types.ts`. Full design
|
||||
discussion, alternatives considered, and rollout notes live in PR #1197.
|
||||
|
||||
## Non-obvious subtleties worth remembering
|
||||
|
||||
- **It's not the hand-authored types that failed — it was the missing
|
||||
imports.** The types in the old `index.d.ts` were individually fine;
|
||||
the file only went wrong because names like `Worktree` and `PRInfo`
|
||||
weren't imported and `skipLibCheck` swallowed the error. A future
|
||||
contributor copy-pasting types out of a `.d.ts` into `.ts` may be
|
||||
surprised by a wall of "Cannot find name 'X'" errors — that's the
|
||||
flag catching its target, not a real regression.
|
||||
- **`.d.ts` is still legitimate for ambient shims.** `env.d.ts`,
|
||||
`mermaid.d.ts`, `hosted-git-info.d.ts` all live *outside* the CI
|
||||
guard's scan roots (`src/preload/` and `src/shared/`) and stay as
|
||||
`.d.ts`. If a future file under those roots genuinely needs to be
|
||||
`.d.ts` (e.g., an ambient module shim for a third-party package that
|
||||
can't live in `.ts`), add it to an allowlist in `pr.yml` at that
|
||||
time — don't relax the guard wholesale.
|
||||
- **Intersection types on `window.api` are what actually widened the
|
||||
`.then` callback to `any`.** The old layout used
|
||||
`type Api = PreloadApi & { repos: ReposApi, worktrees: WorktreesApi, … }`.
|
||||
TypeScript's intersection-of-function-types resolution widens callback
|
||||
parameters to `any` when one side of the intersection has unresolved
|
||||
names, *even though static-inspection views
|
||||
(`ReturnType<typeof fn>`) still report the correct type*. So
|
||||
`ReturnType<typeof window.api.repos.getBaseRefDefault>` printed
|
||||
`Promise<BaseRefDefaultResult>` during debugging while the live
|
||||
`.then((ref) => …)` callback treated `ref` as `any`. Don't re-introduce
|
||||
intersection typing on the preload surface for any reason.
|
||||
- **Don't try to fix this by flipping `skipLibCheck: false` globally.**
|
||||
It would force every transitive `@types/*` package to type-check
|
||||
cleanly, which is why the ecosystem-wide default is `true`. The
|
||||
structural fix (project-owned types in `.ts`) removes our last
|
||||
reason to care about the flag for our code.
|
||||
@@ -1,242 +0,0 @@
|
||||
# Design Document: Quick Jump to Worktree (Issue #426)
|
||||
|
||||
## 1. Overview
|
||||
|
||||
As Orca scales to support multiple parallel agents and tasks, users frequently need to switch between dozens of active worktrees. Navigating via the sidebar becomes inefficient at scale.
|
||||
|
||||
This document describes the shipped "Quick Jump" palette in Orca: a globally accessible Command Palette-style dialog that lets users jump across active worktrees, open browser tabs that live inside worktrees, and create a new worktree from typed input. Search covers worktree metadata (name, branch, repo, comment, PR metadata, issue metadata) and browser metadata (page title, URL, worktree, repo).
|
||||
|
||||
## 2. User Experience (UX)
|
||||
|
||||
### 2.1 The Shortcut: `Cmd+J` (macOS) / `Ctrl+Shift+J` (Windows/Linux)
|
||||
|
||||
To establish this palette as the central "Switch Worktree" action in Orca, `**Cmd+J**` (macOS) and `**Ctrl+Shift+J**` (Windows/Linux) are the chosen shortcuts.
|
||||
|
||||
**Why `Cmd+J` / `Ctrl+Shift+J`?**
|
||||
|
||||
- **Matches the action honestly:** This palette switches between existing worktrees. "Jump" is a better semantic fit than "Open" because the user is navigating, not creating a new file-open flow.
|
||||
- **Avoids `Ctrl+J` (Line Feed) conflict:** On Windows and Linux, `Ctrl+J` translates to a Line Feed (`\n`) in bash, zsh, and almost all readline-based CLI applications. For many terminal power users, `Ctrl+J` and `Ctrl+M` (Carriage Return) are used interchangeably with the physical `Enter` key to execute commands. In Vim, it is used for navigation or inserting newlines, and in Emacs it maps to `newline-and-indent`. Intercepting `Ctrl+J` globally would severely disrupt core terminal workflows. Thus, `Ctrl+Shift+J` is used on these platforms. (On macOS, `Cmd` is an OS-level modifier, so `Cmd+J` safely avoids this issue).
|
||||
- **Avoids `Cmd+K` conflict:** In terminal-heavy apps, `Cmd+K` is universally expected to "Clear Terminal". Overriding it breaks developer muscle memory.
|
||||
- **Avoids `Cmd+P` conflict:** `Cmd+P` is already in use for Quick Open File (`QuickOpen.tsx`).
|
||||
- **Avoids `Ctrl+E` (readline):** `Ctrl+E` is "end of line" in bash/zsh readline. Stealing it in a terminal-heavy app would break shell navigation muscle memory — the same class of conflict that rules out `Cmd+K`.
|
||||
- **Discoverability:** The shortcut should be registered in the Electron Application Menu (e.g., `View -> Open Worktree Palette`) so users can discover it visually.
|
||||
|
||||
### 2.2 The Interface
|
||||
|
||||
When the shortcut is pressed, a modal dialog appears at the center top of the screen (similar to VS Code's palette or Spotlight).
|
||||
|
||||
- **Input:** A text input focused automatically.
|
||||
- **List:** A scrollable list constrained to `max-h-[min(460px,62vh)]` to prevent the palette from overflowing the viewport when many results are present.
|
||||
- **Default state (empty query):** When the palette opens with no query, the full list of non-archived worktrees is shown first, ordered by Orca's smart sort. If browser tabs also exist, they appear as a secondary section preview below the worktree list. The palette intentionally ignores the sidebar's `showActiveOnly` and `filterRepoIds` filters — it is a global jump tool, not a filtered view.
|
||||
- **Sorting (Smart Semantics):** The palette uses Orca's smart worktree ordering via `sortWorktreesSmart(...)`, not plain recency. In practice this prioritizes active agent work, permission-needed state, unread state, live terminals, PR signal, linked issue, and recent activity, with a cold-start fallback to persisted `sortOrder` until any PTY is live.
|
||||
- **Visual Hierarchy & Highlights:** Because search covers multiple fields simultaneously, the list items must visually clarify *why* a result matched. If the match is inside a comment, display a truncated snippet of that comment centered around the matched range, with the matching text highlighted.
|
||||
- **Multi-repo disambiguation:** Each list item always displays the repository name (e.g., `stablyai/orca`) alongside the worktree name. This is required because the palette spans all repos — without it, two worktrees named "main" from different repos would be indistinguishable.
|
||||
- **Cross-surface scope:** The palette is not limited to worktrees. It also surfaces browser tabs, and when the user types a string that matches no worktree results it offers a "Create worktree" action using the current query.
|
||||
- **Empty State:** Two cases: (1) If the user has no active worktrees and no browser tabs, display "No active worktrees or browser tabs". (2) If items exist but none match the search query, display "No results match your search." Both use `<Command.Empty>`.
|
||||
- **Search fields:** The search input will match against:
|
||||
- Worktree `displayName`
|
||||
- Worktree `branch`, normalized via `branchName()` to strip the `refs/heads/` prefix (e.g., `refs/heads/feature/auth-fix` → `feature/auth-fix`)
|
||||
- Repository name (e.g., `stablyai/orca`)
|
||||
- Full `comment` text attached to the worktree
|
||||
- Linked PR number/title. Two paths: (a) auto-detected PR via `prCache` (cache key: `${repo.path}::${branch}`), which has both number and title; (b) manual `linkedPR` fallback, which has number only (no title to search against). If `prCache` has a hit, prefer it; otherwise fall back to `linkedPR` number matching.
|
||||
- Linked issue number/title. The issue number comes from `w.linkedIssue`; the title comes from `issueCache` (cache key: `${repo.path}::${w.linkedIssue}`). Number matching works even without a cache hit; title matching requires the cache entry to be populated.
|
||||
- **Cache freshness caveat:** PR and issue data is populated by `refreshGitHubForWorktree`, which runs on worktree activation, and by `refreshAllGitHub`, which runs on window re-focus (`visibilitychange`). On startup, `initGitHubCache` loads previously persisted PR/issue data from disk, so worktrees fetched in prior sessions start with warm caches. Worktrees that have never been activated, were not covered by a `refreshAllGitHub` pass, and have no persisted cache entry will have empty caches — PR/issue title search will silently miss them. This is acceptable: the gap is limited to brand-new worktrees between creation and the next activation or window re-focus cycle. Number-based matching (e.g., `#304`) always works because it checks `w.linkedPR` / `w.linkedIssue` directly, without the cache.
|
||||
- `**#`-prefix handling:** A leading `#` in the query is stripped before matching PR/issue numbers (e.g., `#304` matches number `304`), with a guard against bare `#` which would produce an empty string and match everything. This mirrors the existing `matchesSearch()` behavior.
|
||||
- **Browser search fields:** Browser page title, URL/secondary text, worktree name, and repo name.
|
||||
- **Navigation:** `Up` / `Down` arrows to navigate the list, `Enter` to select. `Escape` closes the modal.
|
||||
|
||||
## 3. Technical Architecture
|
||||
|
||||
### 3.1 UI Components
|
||||
|
||||
Orca uses `shadcn/ui` and ships the **Command** component, which wraps the `cmdk` library.
|
||||
|
||||
**Dependency:** `cmdk` is a direct dependency in `package.json`.
|
||||
|
||||
```bash
|
||||
pnpm dlx shadcn@latest add command
|
||||
```
|
||||
|
||||
Note: `CommandDialog` uses Radix Dialog internally. Orca keeps this inside the shared `components/ui/command.tsx` wrapper so both palettes use the same dialog primitives and styling hooks.
|
||||
|
||||
**z-index:** The `CommandDialog` must use `z-50` or higher to reliably overlay the terminal and sidebar, consistent with `QuickOpen.tsx` which uses `z-50` on its fixed overlay container.
|
||||
|
||||
- `**WorktreeJumpPalette.tsx`:** A new component mounted at the root of the app (inside `App.tsx`, alongside the existing `<QuickOpen />`) to ensure it can be summoned from anywhere.
|
||||
- `**CommandDialog`:** The shadcn component used to render the modal.
|
||||
|
||||
### 3.2 Keyboard Shortcut
|
||||
|
||||
The shipped shortcut uses a **hybrid main-process + renderer architecture**:
|
||||
|
||||
1. The main window listens in `before-input-event` and resolves the chord through the shared window shortcut policy.
|
||||
2. On `toggleWorktreePalette`, the main process sends `ui:toggleWorktreePalette` to the renderer.
|
||||
3. The renderer toggles `activeModal === 'worktree-palette'` in `useIpcEvents.ts`.
|
||||
|
||||
This extra main-process hop is required because browser guests and other embedded Chromium surfaces can keep keyboard focus inside a guest `webContents`, bypassing the renderer's `window`-level `keydown` listener. A renderer-only implementation would fail from browser-tab focus.
|
||||
|
||||
**Toggle semantics:** If the palette is already open, the shortcut closes it; otherwise it opens it. There is no `activeWorktreeId` or `activeView` guard, so the palette is available from settings, from landing states with no active worktree, and from browser focus.
|
||||
|
||||
**Overlay mutual exclusion:** The current app models both Quick Open and the worktree palette inside the existing `activeModal` union in `ui.ts` (`'quick-open'` and `'worktree-palette'`). This keeps the two command palettes mutually exclusive without needing separate booleans.
|
||||
|
||||
**Menu registration:** Register a `View -> Open Worktree Palette` entry in `register-app-menu.ts` for discoverability, consistent with Section 2.1. The entry must use a **display-only shortcut hint** — do **not** set `accelerator: 'CmdOrCtrl+J'`. In Electron, menu accelerators intercept key events at the main-process level *before* the renderer's `keydown` handler fires (this is how `CmdOrCtrl+,` for Settings works — its `click` handler runs in the main process via `onOpenSettings`). If `CmdOrCtrl+J` were registered as a real accelerator, the renderer `keydown` handler would never see the event, and the overlay mutual-exclusion logic (which runs in the renderer) would be bypassed. Instead, show the shortcut text in the menu label (e.g., `label: 'Open Worktree Palette\tCmdOrCtrl+J'`) without binding `accelerator`, matching the pattern used by `Cmd+P` (QuickOpen), which has no menu entry at all and relies solely on the renderer handler.
|
||||
|
||||
### 3.3 State Management
|
||||
|
||||
- **Visibility state:** The palette is represented by `activeModal === 'worktree-palette'` in the UI slice. Quick Open similarly uses `activeModal === 'quick-open'`.
|
||||
- **Palette session state:** `query` and `selectedIndex` are ephemeral to the palette component and should live in React component state (not Zustand). They reset on every open.
|
||||
- **Render optimization:** When the modal is closed, `CommandDialog` unmounts its content, which is sufficient.
|
||||
- **Ordering:** Worktree results are fed through `sortWorktreesSmart(...)`, and browser results are ordered relative to that same worktree ordering so both sections feel consistent.
|
||||
|
||||
### 3.4 Data Layer & Search
|
||||
|
||||
The palette needs access to all worktrees known to Orca.
|
||||
|
||||
- **Data source:** Read from the existing `worktreesByRepo` in Zustand (already populated via `fetchAllWorktrees` on startup and kept in sync via IPC push events). No new IPC channel is needed. Filter out archived worktrees (`!w.isArchived`) before searching or displaying. Do **not** apply the sidebar's `showActiveOnly` or `filterRepoIds` filters — the palette is a global jump tool that surfaces all non-archived worktrees regardless of the sidebar's filter state. Because the palette reads directly from `worktreesByRepo`, it reactively updates if a worktree is created or deleted via IPC push while the palette is open — no special stale-list handling is needed.
|
||||
|
||||
#### Search implementation
|
||||
|
||||
The sidebar already has a `matchesSearch()` function in `worktree-list-groups.ts` that does **substring matching** (`includes(q)`) against displayName, branch, repo, comment, PR, and issue fields. The palette search builds on this foundation but extends it. Note: `branchName()` (used to strip `refs/heads/` prefixes) is currently exported from `worktree-list-groups.ts` — a sidebar-specific module that imports Lucide icons (`CircleCheckBig`, `CircleDot`, etc.) at the top level. Importing `branchName` from it would pull the entire module (including unused icon components) into the palette's bundle. `smart-sort.ts` has its own duplicate: `branchDisplayName()` doing the identical `branch.replace(/^refs\/heads\//, '')`. Extract `branchName()` to a shared utility (`lib/git-utils.ts`) in Phase 1, and update `worktree-list-groups.ts` and `smart-sort.ts` to import from there. This is a 3-line function — the extraction is trivial and avoids the bundle bloat.
|
||||
|
||||
1. **Matching strategy: substring, not fuzzy.** Use case-insensitive substring matching for worktrees and browser entries. True fuzzy matching (ordered-character, like `QuickOpen.tsx`'s `fuzzyMatch`) is not used here.
|
||||
2. **Structured match metadata:** Unlike `matchesSearch()` (which returns `boolean`), the palette search helper returns a result object:
|
||||
|
||||
```ts
|
||||
type MatchRange = { start: number; end: number }
|
||||
|
||||
type PaletteMatchBase = { worktreeId: string }
|
||||
|
||||
/** Empty query — all non-archived worktrees shown, no match metadata. */
|
||||
type PaletteMatchAll = PaletteMatchBase & {
|
||||
matchedField: null
|
||||
matchRange: null
|
||||
}
|
||||
|
||||
/** Comment match — includes a truncated snippet centered on the matched range. */
|
||||
type PaletteMatchComment = PaletteMatchBase & {
|
||||
matchedField: 'comment'
|
||||
matchRange: MatchRange
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** Non-comment field match — range within the matched field's display value. */
|
||||
type PaletteMatchField = PaletteMatchBase & {
|
||||
matchedField: 'displayName' | 'branch' | 'repo' | 'pr' | 'issue'
|
||||
matchRange: MatchRange
|
||||
}
|
||||
|
||||
type PaletteMatch = PaletteMatchAll | PaletteMatchComment | PaletteMatchField
|
||||
```
|
||||
|
||||
3. **Field priority order:** When multiple fields match, report the first match by priority: `displayName` > `branch` > `repo` > `comment` > `pr` > `issue`. This determines which badge/highlight is shown.
|
||||
4. **Comment snippet extraction:** Search against the full `comment` text. Only the *rendered snippet* is truncated — extract ~80 characters of surrounding context centered on the matched range. Clamping: `snippetStart = Math.max(0, matchStart - 40)`, `snippetEnd = Math.min(comment.length, matchEnd + 40)`. After clamping, snap to word boundaries: scan `snippetStart` backward (up to 10 chars) to the nearest whitespace or string start; scan `snippetEnd` forward (up to 10 chars) to the nearest whitespace or string end. This avoids cutting words mid-character (e.g., `…e implementation of th…` → `…the implementation of the…`). Prepend `…` if `snippetStart > 0`; append `…` if `snippetEnd < comment.length`.
|
||||
5. `**cmdk` wiring:** Render with `shouldFilter={false}` so the palette controls filtering. Pass only the filtered result set to `<Command.Item>`:
|
||||
|
||||
```tsx
|
||||
<Command.Item
|
||||
key={worktree.id}
|
||||
value={worktree.id}
|
||||
onSelect={() => handleSelectWorktree(worktree.id)}
|
||||
>
|
||||
{/* Render worktree row with match badge + highlighted range */}
|
||||
</Command.Item>
|
||||
```
|
||||
|
||||
6. **Performance:** Keep `value` compact and do not stuff full comments into `keywords`. The current implementation debounces the query by 150ms before recomputing result sets. That keeps mixed worktree + browser searching cheap without materially hurting responsiveness at Orca's current scale.
|
||||
|
||||
### 3.5 Action (Worktree Activation)
|
||||
|
||||
#### Existing callsite analysis
|
||||
|
||||
The codebase has several worktree activation paths with inconsistent step coverage:
|
||||
|
||||
|
||||
| Step | `WorktreeCard` click | `Cmd+1–9` | `AddRepoDialog` | `AddWorktreeDialog` |
|
||||
| ------------------------------------ | -------------------- | --------- | --------------- | ------------------- |
|
||||
| Set `activeRepoId` | No | No | Yes | Yes |
|
||||
| Set `activeView` | No | No | Yes | Yes |
|
||||
| `setActiveWorktree()` | Yes | Yes | Yes | Yes |
|
||||
| `ensureWorktreeHasInitialTerminal()` | No | No | Yes | Yes |
|
||||
| `revealWorktreeInSidebar()` | No | Yes | Yes | Yes |
|
||||
|
||||
|
||||
Sidebar card clicks and `Cmd+1–9` work without setting `activeRepoId` because `activeRepoId` is only consumed by the "Create Worktree" dialog (to pre-select a repo) and session persistence — it does not gate rendering or data fetching for the switched-to worktree. Similarly, `ensureWorktreeHasInitialTerminal` is only needed for newly created worktrees that have never been opened; existing worktrees already have terminal tabs.
|
||||
|
||||
#### Palette activation sequence
|
||||
|
||||
The palette should match what `Cmd+1–9` does today (the closest analog: jumping to a visible worktree from any context), plus a few extras justified by the palette's cross-repo scope:
|
||||
|
||||
1. **Set `activeRepoId`:** If the target worktree's `repoId` differs from the current `activeRepoId`, call `setActiveRepo(repoId)`. This keeps session persistence and the "Create Worktree" repo pre-selection accurate. Sidebar clicks skip this because they operate within a single repo group; the palette does not have that constraint.
|
||||
2. **Switch `activeView`:** If `activeView` is `'settings'`, set it to `'terminal'` so the main content area renders the worktree surface. `Cmd+1–9` does not handle this because it refuses to fire at all from the settings view (gated on `activeView !== 'settings'` in the `onKeyDown` handler); the palette intentionally has no such guard so users can jump to a worktree directly from settings.
|
||||
3. **Call `setActiveWorktree(worktreeId)`:** This runs Orca's existing activation sequence: sets `activeWorktreeId`, restores per-worktree editor state (`activeFileId`, `activeTabType`, `activeBrowserTabId`), restores the last-active terminal tab, clears unread state, bumps dead PTY generations, and triggers `refreshGitHubForWorktree` to ensure PR/issue/checks data is current for the newly active worktree.
|
||||
4. **Ensure a focusable surface:** If the worktree has no renderable tabs, call `ensureWorktreeHasInitialTerminal` (`worktree-activation.ts`). The helper now decides this via the reconciled tab model, not by checking whether the legacy terminal-tab array is empty.
|
||||
5. **Reveal in sidebar:** Call `revealWorktreeInSidebar(worktreeId)` to ensure the selected worktree is visible (handles collapsed groups and scroll position).
|
||||
6. **Close the palette.**
|
||||
|
||||
#### Shared helper
|
||||
|
||||
The five activation steps above overlap heavily with `AddRepoDialog.handleOpenWorktree` and `AddWorktreeDialog`'s post-create flow. With three callsites now sharing the same core sequence, extract a shared `activateAndRevealWorktree(worktreeId: string, opts?: { setup?: WorktreeSetupLaunch })` helper in `worktree-activation.ts` that covers the common steps: set `activeRepoId` (cross-repo), switch `activeView` (from settings), `setActiveWorktree`, `ensureWorktreeHasInitialTerminal`, clear sidebar filters that would hide the target, and `revealWorktreeInSidebar`.
|
||||
|
||||
**Sidebar filter clearing:** The helper must clear any sidebar filter state that would prevent the target card from being rendered, because `revealWorktreeInSidebar` relies on the worktree card being *rendered* in the sidebar (the `pendingRevealWorktreeId` effect in `WorktreeList` finds the target in the rendered `rows` array via `findIndex`). If sidebar filters exclude the target, the card is never rendered and the reveal silently no-ops — the user selects a worktree and nothing visually happens. `AddWorktreeDialog` already handles this inline (clears both `searchQuery` and `filterRepoIds` before activation); the shared helper absorbs that responsibility. Specifically:
|
||||
|
||||
- Clear `filterRepoIds` if it is non-empty and does not include the target worktree's repo.
|
||||
- Clear `searchQuery` unconditionally if it is non-empty. Even if the target repo is visible, an active text search might exclude the specific worktree being jumped to.
|
||||
|
||||
Callsite-specific extras that remain inline after calling the shared helper:
|
||||
|
||||
- `**AddWorktreeDialog`:** `setSidebarOpen(true)`, open right sidebar if `rightSidebarOpenByDefault`.
|
||||
- `**AddRepoDialog`:** `closeModal()` (the palette closes itself separately).
|
||||
- **Palette:** close the palette, focus management (Section 3.5 Focus management).
|
||||
|
||||
The helper derives `repoId` internally via `findWorktreeById(worktreesByRepo, worktreeId)` (`worktree-helpers.ts:45`) — the caller only passes `worktreeId`. If the worktree is not found (e.g., deleted between palette open and select), the helper returns early without side effects.
|
||||
|
||||
#### Focus management
|
||||
|
||||
- **On worktree select:** After closing the palette, use a double `requestAnimationFrame` (nested rAF) to focus the active terminal/editor surface for the target worktree. `onCloseAutoFocus` calls `preventDefault()` so Radix does not steal focus.
|
||||
- **On browser-tab select:** Restore focus to the selected browser page, preferring the address bar for blank/new pages and the webview for loaded pages.
|
||||
- **On escape/cancel:** Restore focus to the previously active browser page when the palette was opened from browser context; otherwise fall back to the terminal/editor surface for the previously active worktree. If no worktree was active, focus falls to the document body.
|
||||
|
||||
### 3.6 Accessibility
|
||||
|
||||
The `cmdk` library provides built-in ARIA support:
|
||||
|
||||
- `role="combobox"` on the input
|
||||
- `role="listbox"` / `role="option"` on the list and items
|
||||
- `aria-activedescendant` for keyboard navigation
|
||||
- `aria-expanded` on the dialog
|
||||
|
||||
**Additional requirements:**
|
||||
|
||||
- Announce filtered result count changes to screen readers via an `aria-live="polite"` region (e.g., "3 worktrees found").
|
||||
- Match-field badges (e.g., `Branch`, `Comment`) should include `aria-label` text so screen readers convey why the result matched.
|
||||
|
||||
## 4. Implementation Status
|
||||
|
||||
The core design is implemented:
|
||||
|
||||
- `cmdk` is a direct dependency and Orca ships a shared `CommandDialog`.
|
||||
- `WorktreeJumpPalette.tsx` is mounted at the app root.
|
||||
- The palette opens through main-process shortcut forwarding plus renderer IPC toggle handling.
|
||||
- Worktree activation is routed through `activateAndRevealWorktree(...)`.
|
||||
- Search supports comment snippets, PR/issue metadata, browser pages, and a create-worktree action.
|
||||
- Menu discoverability is implemented with a display-only `View -> Open Worktree Palette` hint and no accelerator binding.
|
||||
|
||||
## 5. Remaining Gaps / Future Work
|
||||
|
||||
- Add broader integration coverage for the mixed worktree/browser palette behavior; current tests focus on search helper behavior and shortcut/menu plumbing.
|
||||
- Evaluate whether the 150ms debounce should become adaptive if the palette eventually indexes substantially more browser pages.
|
||||
- Consider unifying the worktree and browser result models further if future result types are added.
|
||||
|
||||
**Future work (out of scope)**
|
||||
|
||||
- Evaluate migrating `QuickOpen.tsx` (currently a custom overlay with manual keyboard handling) to `cmdk`/`CommandDialog` for visual and behavioral consistency with the palette. This is a separate project — `QuickOpen` has its own fuzzy matching, file-loading, and keyboard handling that would need reworking.
|
||||
- Add richer end-to-end coverage for palette interactions launched from browser focus, including focus restoration after browser-tab selection and dismissal.
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
- `**Cmd+O` (Open):** Standard app semantic, but less honest for this feature because the palette switches between existing worktrees rather than opening a new file or workspace. Rejected in favor of `Cmd+J`, which better matches the action users are taking.
|
||||
- `**Ctrl+E` (Explore):** Initially considered for Windows/Linux. Rejected because `Ctrl+E` is "end of line" in bash/zsh readline — stealing it in a terminal-heavy app breaks shell navigation muscle memory.
|
||||
- `**Ctrl+Alt+O`:** Initially considered for Windows/Linux but rejected to avoid `AltGr` collisions on international keyboards (e.g., Polish, German layouts).
|
||||
- `**Cmd+1...9` (Direct jumping):** Doesn't scale past 9 worktrees and requires the user to memorize sidebar positions. Already implemented as a complementary feature.
|
||||
- `**Cmd+K`:** Rejected due to conflict with "Clear Terminal".
|
||||
- `**Cmd+P`:** Rejected because it is already used for file searching (`QuickOpen.tsx`).
|
||||
- **Renderer-only shortcut handling:** Initially attractive because it mirrors simpler shortcuts, but rejected for the shipped palette. Browser guests can keep keyboard focus inside a separate `webContents`, so a renderer-only `window` listener would miss `Cmd+J` / `Ctrl+Shift+J` from browser-tab focus.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Auto-refresh on entering the Checks tab
|
||||
|
||||
## Problem
|
||||
|
||||
The Checks panel fetches on visibility, but those fetches are cache-respecting:
|
||||
|
||||
- `CACHE_TTL = 300_000` for PRs and comments.
|
||||
- `CHECKS_CACHE_TTL = 60_000` for checks.
|
||||
- `ChecksPanel.tsx` fetches PRs on repo/branch changes without `force`.
|
||||
- Checks polling and the comments effect also call the store without `force`.
|
||||
|
||||
That means entering the Checks tab can render stale PR metadata, a cached `null`
|
||||
"no PR" result, stale comments, or stale checks. The checks path is partially
|
||||
protected by a shorter renderer TTL, but the main process also uses `gh api
|
||||
--cache 60s` for the REST checks endpoint unless `noCache` is set. Manual refresh
|
||||
already bypasses these renderer and `gh api` caches for the requests it starts.
|
||||
|
||||
## Goal
|
||||
|
||||
When the user enters the Checks tab, run one freshness check for the active
|
||||
worktree and force-refresh only when the relevant cache timestamps are older
|
||||
than a small grace window. "Entering" means:
|
||||
|
||||
- opening the right sidebar while the Checks tab is selected;
|
||||
- switching from another right-sidebar tab to Checks;
|
||||
- switching active worktree/repo/branch while Checks is already visible.
|
||||
|
||||
The refresh must cover PR discovery too, including cached `null`, so a PR opened
|
||||
outside Orca can appear without waiting up to 5 minutes.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the existing polling cadence while the panel stays visible.
|
||||
- Refreshing on window focus, network reconnect, or arbitrary background events.
|
||||
- Changing the manual refresh button.
|
||||
- Prefetching checks for non-active worktrees.
|
||||
- Adding cross-renderer or cross-window request coordination. Orca currently has
|
||||
one live main window; renderer in-flight maps are not a multi-window primitive.
|
||||
|
||||
## Design
|
||||
|
||||
1. **Entry trigger.** Add an effect in `ChecksPanel.tsx` keyed by:
|
||||
|
||||
```ts
|
||||
const entryKey = isPanelVisible && repo && !isFolder && branch
|
||||
? `${activeWorktreeId ?? ''}::${repo.path}::${branch}`
|
||||
: ''
|
||||
```
|
||||
|
||||
Track the last processed visible `entryKey` in a ref. When `entryKey` is empty,
|
||||
reset the ref to `''`. When it becomes non-empty and differs from the ref, this
|
||||
is an entry. This is required; a `prevKey !== currentKey` check that does not
|
||||
reset on hide would miss closing and reopening the same PR.
|
||||
|
||||
2. **Grace window.** Define `ENTRY_REFRESH_GRACE_MS = 15_000` in
|
||||
`ChecksPanel.tsx`. Select only timestamps, not whole cache records:
|
||||
|
||||
```ts
|
||||
const prFetchedAt = useAppStore(
|
||||
(s) => (prCacheKey ? s.prCache[prCacheKey]?.fetchedAt : undefined)
|
||||
)
|
||||
const checksFetchedAt = useAppStore(
|
||||
(s) => prNumber
|
||||
? s.checksCache[`${repo?.path ?? ''}::pr-checks::${prNumber}`]?.fetchedAt
|
||||
: undefined
|
||||
)
|
||||
const commentsFetchedAt = useAppStore(
|
||||
(s) => prNumber
|
||||
? s.commentsCache[`${repo?.path ?? ''}::pr-comments::${prNumber}`]?.fetchedAt
|
||||
: undefined
|
||||
)
|
||||
```
|
||||
|
||||
Missing PR cache is stale. A cached PR value of `null` is still a PR cache
|
||||
entry and should be refreshed on entry once outside the grace window. When a
|
||||
PR number is known, missing checks/comments timestamps are stale. When no PR is
|
||||
known, checks/comments are not relevant yet.
|
||||
|
||||
Run `handleRefresh()` if the oldest relevant timestamp is missing or older
|
||||
than `Date.now() - ENTRY_REFRESH_GRACE_MS`; otherwise skip.
|
||||
|
||||
3. **Reuse `handleRefresh`, but pass the refreshed head SHA through.** The
|
||||
manual refresh flow is the right shape: force `fetchPRForBranch`, then if a
|
||||
PR is returned, force checks and comments for the returned PR. Extend
|
||||
`fetchChecks()` to accept a `headShaOverride` (or call `fetchPRChecks`
|
||||
directly from `handleRefresh`) so the checks request uses
|
||||
`refreshedPR.headSha`, not the stale `pr?.headSha` captured before the PR
|
||||
refresh completed. This handles PR number changes, cached `null`, and
|
||||
external force-pushes correctly.
|
||||
|
||||
4. **Reset polling attention state before refresh.** When the entry refresh runs,
|
||||
set `pollIntervalRef.current = 30_000` and `prevChecksRef.current = ''` before
|
||||
calling `handleRefresh()`. `fetchChecks()` will then write the new signature
|
||||
from the forced result.
|
||||
|
||||
5. **Do not overstate in-flight behavior.** Current store behavior is:
|
||||
|
||||
- `fetchPRForBranch({ force: true })` bypasses a non-forced in-flight PR
|
||||
request and uses a generation guard so the older result cannot overwrite the
|
||||
newer cache entry.
|
||||
- `fetchPRChecks({ force: true })` and `fetchPRComments({ force: true })` do
|
||||
**not** bypass any in-flight request for the same key. If a non-forced poll
|
||||
is already in flight, entry refresh will join it and may not pass
|
||||
`noCache: true` to the main process.
|
||||
|
||||
Accept that tradeoff for this feature. It avoids duplicate `gh` calls during a
|
||||
visible polling race. If strict "entry always bypasses gh cache" semantics are
|
||||
required later, change checks/comments in-flight maps to track `{ promise,
|
||||
force, generation }` like PRs.
|
||||
|
||||
## API cost and feasibility
|
||||
|
||||
This is not free. A cold entry refresh can start:
|
||||
|
||||
- one PR lookup (`gh:prForBranch`);
|
||||
- one checks request (`gh:prChecks`);
|
||||
- one comments request (`gh:prComments`).
|
||||
|
||||
`gh:prChecks` usually calls `gh api repos/{owner}/{repo}/commits/{sha}/check-runs`
|
||||
and uses `--cache 60s` unless forced; if that fails it falls back to `gh pr
|
||||
checks`, which does not use the `--cache` flag.
|
||||
|
||||
`gh:prComments` is heavier than "one GitHub API call": it runs issue comments
|
||||
REST, review threads GraphQL, and reviews REST in parallel. `noCache` only
|
||||
removes `--cache 60s` from the REST `gh api` calls; the GraphQL call is always
|
||||
made.
|
||||
|
||||
The 15 s grace window is therefore required, not cosmetic.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Cached no-PR result.** Do not skip just because `prNumber` is null. Refresh
|
||||
the PR cache entry on tab entry when its timestamp is outside the grace window.
|
||||
- **First Checks entry after app start.** Only `prCache` and `issueCache` are
|
||||
persisted. `checksCache` and `commentsCache` start empty, so a known PR should
|
||||
force checks/comments on entry.
|
||||
- **Worktree switch while Checks is visible.** Include `activeWorktreeId` in the
|
||||
entry key so same repo/branch switches still count as a new entry. The existing
|
||||
render-time local reset handles stale title/loading state.
|
||||
- **Rapid tab toggles.** Hiding the panel resets the processed entry key; showing
|
||||
it again re-evaluates timestamps. The grace window suppresses duplicate calls.
|
||||
- **Concurrent polling tick.** Checks/comments may reuse the in-flight poll
|
||||
request instead of forcing a new `noCache` request. This is intentional for now.
|
||||
- **Conflicting PR refresh.** The existing `mergeable === 'CONFLICTING'` effect
|
||||
can still force a PR refresh. Entry refresh may also run, but PR generation
|
||||
guards prevent older PR responses from overwriting newer cache entries.
|
||||
- **PR head changed externally.** `handleRefresh()` must use the refreshed PR
|
||||
returned by `fetchPRForBranch` before fetching checks, so checks are requested
|
||||
with the current `headSha`.
|
||||
- **Component unmount mid-fetch.** The comments auto-fetch guards stale
|
||||
responses; the checks fetch path and `handleRefresh()` do not cancel local
|
||||
setters. This feature should keep that behavior unless tests expose a warning
|
||||
or stale-state regression.
|
||||
- **SSH / remote repo.** No path manipulation or platform-specific shortcut code
|
||||
is needed. The same IPC-backed `gh` path is used.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Add timestamp selectors, `ENTRY_REFRESH_GRACE_MS`, and the entry effect in
|
||||
`ChecksPanel.tsx`.
|
||||
2. Keep `handleRefresh()` as the single refresh implementation.
|
||||
3. Add `ChecksPanel.test.tsx` if absent. Cover stale PR/null cache refreshes,
|
||||
fresh-within-grace skips, known PR with missing checks/comments refreshes,
|
||||
hidden panel no-op, and hide/show same PR re-evaluation.
|
||||
4. Run `pnpm typecheck && pnpm lint`.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Remote SSH Folder Picker — Redesign Proposal
|
||||
|
||||
## Context
|
||||
|
||||
The "Browse remote filesystem" dialog lets users pick a directory on a connected SSH target to open as a remote project. It's reached from: sidebar → Add project → Open remote project → folder-picker icon next to the Remote path input.
|
||||
|
||||
**Source:** `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx`
|
||||
**Invoked from:** `src/renderer/src/components/sidebar/AddRepoSteps.tsx` (`RemoteStep`, around line 167)
|
||||
|
||||
## User complaints
|
||||
|
||||
1. **Hard to find the desired folder** — list is flat and unfiltered; on a home directory with 30+ dotfiles/dirs, you scroll.
|
||||
2. **Nested folders not discoverable as navigable** — current model is single-click = highlight, double-click = navigate. Users don't discover the double-click.
|
||||
3. **Unclear consequence of pressing "Select"** — button is generic; the dialog doesn't say what happens to the chosen folder next.
|
||||
|
||||
## Current behavior (for reference)
|
||||
|
||||
- Single-click a row → sets `selectedName`, highlights the row.
|
||||
- Double-click a folder row → navigates into it.
|
||||
- "Select" button → if a row is highlighted, returns `<resolvedPath>/<selectedName>`; otherwise returns `resolvedPath` (the current directory).
|
||||
- Breadcrumb bar at top with ↑, 🏠, and clickable path segments.
|
||||
- Footer shows the path that will be returned: either current dir or `current/highlighted`.
|
||||
|
||||
## Reference: how others do it
|
||||
|
||||
- **VS Code** (`simpleFileDialog.ts`): one unified input at the top doubles as (a) current path, (b) filter as you type, (c) editable path entry. Enter key navigates into folders; OK button label is caller-supplied ("Open Folder", etc.). Auto-complete suggests folders inline.
|
||||
- **Superset, Warp**: no comparable picker.
|
||||
|
||||
## Options considered
|
||||
|
||||
### Option A — VS Code-style: selection = current directory
|
||||
Drop the "highlight a row" model entirely. Navigate into the folder you want (single-click enters it), then "Select" always returns the current directory.
|
||||
|
||||
**Rejected because:** breaks Finder/Explorer muscle memory (double-click to open is a universal convention), and adds a click for the common case of picking a visible child folder.
|
||||
|
||||
### Option B — chevron-on-folders affordance
|
||||
Add a `›` navigation button on folder rows to make "enter this folder" discoverable to mouse users. A permanent chevron is visual noise, so we show it only on row hover (and on keyboard focus for a11y parity).
|
||||
|
||||
### Option C — add a filter input
|
||||
Add a text input at the top of the list that live-filters visible entries by substring. No change to the selection model.
|
||||
|
||||
### Chosen: Option C + Option B
|
||||
The filter input (C) addresses complaint #1 and surfaces `Enter`-to-navigate for keyboard users. The hover/focus chevron (B) addresses complaint #2 for mouse users without the visual noise of a permanent affordance. The two are independent and compose cleanly.
|
||||
|
||||
## Proposed change
|
||||
|
||||
### 1. Filter input (addresses complaint #1)
|
||||
|
||||
- Add a text input above the file list (below the breadcrumb bar) that auto-focuses on mount. The picker only mounts when the outer dialog opens (verified: `AddRepoSteps.tsx` conditionally renders `RemoteFileBrowser` based on dialog-open state), so the auto-focus cannot steal focus from the outer dialog's inputs.
|
||||
- Live-filter `entries` by case-insensitive substring match on `entry.name`. **Filters both files and folders** — hiding files would confuse users trying to confirm they're in the right directory (e.g. looking for a README). Files remain non-actionable.
|
||||
- Keyboard (handled on the input's `onKeyDown`):
|
||||
- `↓` / `↑` — move the highlight (`selectedName`) through the *filtered* list. `preventDefault` so the caret doesn't jump. Clamps at the ends: ArrowUp at the first filtered entry (or with nothing highlighted) stays put; it never triggers parent-directory navigation. Parent-nav is exclusively the breadcrumb `↑` button.
|
||||
- `Enter` — precedence: (a) if a folder is highlighted, navigate into it; (b) else if a **file** is highlighted, surface the transient footer hint (below) and do not navigate; (c) else if the filtered set contains exactly one folder (regardless of how many files are also in the set), navigate into that folder; (d) else highlight the first filtered entry (file or folder) — this is a highlight-only step and the visible highlight is the feedback; a subsequent `Enter` then re-enters this ladder and hits (a) or (b). Rule (b) is the only path that triggers the hint, so a filter that matches only files and is already highlighted on the first entry keeps yielding the hint on repeated `Enter`, never a silent no-op. The hint text is `Files can't be opened as a project`, shown in the footer for 2s before reverting. Chose the footer hint over an input shake/flash because it needs no animation infrastructure and the footer is already the dialog's status region.
|
||||
- `Esc` — if filter is non-empty, clear it and `stopPropagation` so the outer dialog doesn't close; otherwise let the event bubble and call `onCancel`.
|
||||
- **Focus management:** the input retains focus across row clicks. Clicking a row calls `setSelectedName` but does not steal focus (`onMouseDown` with `preventDefault` on the row buttons, or explicit `inputRef.current?.focus()` after selection). This keeps arrow keys and typing working after the user mouses.
|
||||
- **Navigation helper.** Introduce a `navigate(path)` wrapper that calls `loadDir(path)` *and* clears the filter. All user-initiated navigation (breadcrumb segment click, the breadcrumb `↑` parent-directory button, double-click, chevron click, `Enter`-to-enter-folder) goes through `navigate`. The `↑`/`↓` ArrowUp/ArrowDown *keys* move the filter-list highlight per the keyboard spec above and do not call `navigate`. The mount effect calls `loadDir(path)` directly — it never clears the filter, so a user who types before the first load completes does not lose their input. This removes the `didInitialLoad` ref.
|
||||
- When the filter changes, if the current `selectedName` is no longer in the filtered list, clear it so the button label doesn't go stale.
|
||||
- **Empty-state copy.** When `entries.length > 0` but `filteredEntries.length === 0`, render `No matches for '<filter>'` instead of the generic `Empty directory` copy — the latter is misleading when the directory has contents that are simply filtered out.
|
||||
- Placeholder: `Type to filter…` with a leading `Search` icon (lucide) for scannability, matching the existing icon-prefixed inputs in the sidebar.
|
||||
|
||||
### 2. Dynamic button label (addresses complaint #3)
|
||||
|
||||
Replace the static "Select" label with the path it will return:
|
||||
|
||||
- When a row is highlighted: `Select /home/neil/myproject`
|
||||
- When no row is highlighted: `Select /home/neil` (the current directory)
|
||||
- **Left-ellipsis truncation.** Plain Tailwind `truncate` right-ellipsizes, which hides the meaningful tail. Use `direction: rtl; text-align: left;` on an inner span wrapping the path (keep the word "Select" in a separate LTR span), or equivalent CSS. Add the full path as a `title` attribute for hover-tooltip verification. Note: RTL-directionality on the path span can reorder punctuation in filenames that themselves contain RTL characters — accepted as a tiny edge-case risk for SSH target paths.
|
||||
|
||||
This makes the two selection modes (child vs. current dir) visible without any model change, and tells the user exactly what will be opened.
|
||||
|
||||
### 3. Footer
|
||||
|
||||
Keep the existing muted full-path line — it's the unambiguous source of truth when the button label is ellipsized. Prepend a short hint so first-time users know what Select does:
|
||||
|
||||
> Opens as a remote project · `/home/neil/myproject`
|
||||
|
||||
Single line, muted, truncates with right-ellipsis (the prefix is the fixed part, the path tail can be cut since it's already in the button's `title`).
|
||||
|
||||
When `Enter` is pressed on a highlighted file, swap this line for `Files can't be opened as a project` (same muted style) for 2s, then revert. Clear the hint's timer eagerly on any filter change or navigation so the message never outlives the state it describes.
|
||||
|
||||
### 4. Folder-row chevron (addresses complaint #2)
|
||||
|
||||
Folder rows render a `›` icon (lucide `ChevronRight`) on the right edge, shown only on row hover or keyboard focus. Files never render the chevron.
|
||||
|
||||
- **Trigger:** CSS `:hover` on the row plus a `:focus-visible` rule so keyboard-focused rows also show it. Never always-visible — a permanent chevron is visual noise across a long list.
|
||||
- **Click handler:** the chevron is a nested `<button>` with its own `onClick` that calls `navigate(entry.path)`. It must `stopPropagation` so the parent row's single-click-select handler doesn't also fire. Clicking the chevron is equivalent to double-clicking the row.
|
||||
- **a11y:** `aria-label="Open <name>"`. Reachable via Tab when the row is focused; `Enter`/`Space` on the focused chevron navigates into the folder. Screen readers announce the label distinctly from the row's select action.
|
||||
- **Hit target:** ≥ 24px square, padded inside the row so touchpad taps land reliably.
|
||||
|
||||
## What is NOT changing
|
||||
|
||||
- **Selection model** — single-click highlights, double-click navigates. Finder/Explorer conventions preserved.
|
||||
- **Breadcrumb bar** — already good; nicer than VS Code's path-in-input approach.
|
||||
- **Non-git-folder handling** — `AddRepoSteps.tsx` lines 108–117 still opens the `confirm-non-git-folder` modal on `Not a valid git repository`. Out of scope.
|
||||
|
||||
## Tests
|
||||
|
||||
Add a co-located test file (following the pattern of `smart-sort.test.ts` etc. in the same directory). Minimum coverage:
|
||||
|
||||
- Filter substring-matches case-insensitively across files and folders.
|
||||
- `Enter` with a single folder match navigates into it.
|
||||
- `Enter` with multiple matches and no highlight highlights the first filtered entry.
|
||||
- `Enter` on a highlighted file surfaces the transient footer hint (`Files can't be opened as a project`) and does not navigate.
|
||||
- `Esc` with a non-empty filter clears it and does not call `onCancel`; `Esc` with empty filter calls `onCancel`.
|
||||
- `selectedName` is cleared when the filter change removes it from the visible list.
|
||||
- Filter is *not* cleared by the initial mount load; *is* cleared by a subsequent `navigate(path)` call.
|
||||
- **Path equivalence:** navigating into `foo` (via chevron/double-click/`Enter`) then pressing `Select` returns the same `onSelect` path as highlighting `foo` from its parent and pressing `Select`.
|
||||
- Clicking a folder row's chevron navigates into the folder and does not leave the row merely highlighted (i.e. chevron click is not swallowed by the row's select handler).
|
||||
- Empty-state copy: with a non-empty directory and a filter that matches nothing, the list shows `No matches for '<filter>'`, not `Empty directory`.
|
||||
|
||||
## Files to change
|
||||
|
||||
- `src/renderer/src/components/sidebar/RemoteFileBrowser.tsx` — all UI changes live here (filter input, `navigate` wrapper, chevron on folder rows, footer hint state, empty-state copy).
|
||||
- `src/renderer/src/components/sidebar/RemoteFileBrowser.test.tsx` — new.
|
||||
- No IPC, main-process, or `AddRepoSteps.tsx` changes required.
|
||||
|
||||
## Follow-ups (not this PR)
|
||||
|
||||
- **State bloat.** The component will accumulate several pieces of local state (`entries`, `selectedName`, `filter`, `resolvedPath`, transient footer-hint flag, loading/error). Consider consolidating into a `useReducer` in a later pass; leaving as discrete `useState` hooks for this PR to keep the diff reviewable.
|
||||
@@ -0,0 +1,387 @@
|
||||
# Resource Usage popover: merge Resources + Sessions into one view
|
||||
|
||||
## Problem
|
||||
|
||||
The status-bar Resource Usage popover currently has two tabs:
|
||||
|
||||
- **Resources** — per-worktree CPU/memory grouped by repo, sourced from
|
||||
`MemorySnapshot.worktrees`. Local PTYs only (SSH excluded by design at
|
||||
`src/main/ipc/pty.ts:832` because remote process trees are not visible
|
||||
to the local `ps`/`wmic` sweep).
|
||||
- **Sessions** — flat list of every PTY the daemon tracks (local **and**
|
||||
SSH), sourced from `pty.listSessions()`. Adds tab-binding info, click-
|
||||
to-navigate, kill-X per row, and "Kill N orphans".
|
||||
|
||||
The data sources only partially overlap: Sessions can show worktrees
|
||||
(e.g. `orca/Porpoise`, `orca/Stingray`) that never appear in Resources
|
||||
because they're SSH-backed. Users see this as a confusing inconsistency
|
||||
between two adjacent tabs in the same popover.
|
||||
|
||||
The user wants one unified view that:
|
||||
|
||||
1. Lists every PTY the daemon knows about (the union, not the
|
||||
intersection).
|
||||
2. Shows CPU/memory **when available** (i.e. for local PTYs) and `—` for
|
||||
remote ones, rather than silently dropping remote sessions.
|
||||
3. Keeps tab-binding affordances (click row → navigate, kill X with
|
||||
confirm dialog) on every row.
|
||||
4. Surfaces orphans (PTYs not bound to any tab) with the "Kill N
|
||||
orphans" bulk action.
|
||||
|
||||
## Direction (already decided by user)
|
||||
|
||||
> Merge into one tab, drop the switcher entirely.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Adding new IPC. The merge uses **only** the data already available:
|
||||
`MemorySnapshot` and `pty.listSessions()`.
|
||||
- Changing what the memory collector tracks. SSH PTYs remain unsampled.
|
||||
The unified view simply shows them with empty metric cells instead of
|
||||
hiding them.
|
||||
- Adding new actions (e.g. "stop all in repo", per-repo restart, etc.).
|
||||
The exact action set today (kill one with confirm, kill orphans, sleep
|
||||
worktree, restart daemon, kill-all-sessions) is preserved.
|
||||
|
||||
## Data model
|
||||
|
||||
### Inputs
|
||||
|
||||
- `snapshot: MemorySnapshot | null` — local-only CPU/Mem data, grouped
|
||||
by `worktreeId`. May be `null` while the daemon is unreachable.
|
||||
- `sessions: DaemonSession[]` — every PTY id the daemon knows, with
|
||||
`cwd`, `title`, and a sticky `isAlive`-equivalent.
|
||||
- `tabsByWorktree`, `ptyIdsByTabId`, `runtimePaneTitlesByTabId`,
|
||||
`workspaceSessionReady` — store-side context used to (a) compute
|
||||
bound/orphan and (b) resolve human labels.
|
||||
|
||||
### Output: a renderer-local view model
|
||||
|
||||
The merge produces a renderer-local view-model — **not** a widening of
|
||||
the shared `WorktreeMemory` shape. `src/shared/types.ts` and the
|
||||
collector are untouched. New types live next to the merge helper:
|
||||
|
||||
```ts
|
||||
// src/renderer/src/components/status-bar/unifiedRow.ts
|
||||
type Metric = number | null // null === "no local sample" (e.g. SSH)
|
||||
|
||||
type UnifiedSessionRow = {
|
||||
sessionId: string
|
||||
label: string
|
||||
bound: boolean
|
||||
tabId: string | null
|
||||
cpu: Metric
|
||||
memory: Metric
|
||||
hasLocalSamples: boolean
|
||||
}
|
||||
|
||||
type UnifiedWorktreeRow = {
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: Metric
|
||||
memory: Metric
|
||||
history: number[] // empty when hasLocalSamples is false
|
||||
hasLocalSamples: boolean
|
||||
sessions: UnifiedSessionRow[]
|
||||
}
|
||||
|
||||
type UnifiedRepoGroup = {
|
||||
repoId: string
|
||||
repoName: string
|
||||
cpu: Metric // null if every child has hasLocalSamples false
|
||||
memory: Metric
|
||||
hasRemoteChildren: boolean // drives the "· remote" badge on the row
|
||||
worktrees: UnifiedWorktreeRow[]
|
||||
}
|
||||
```
|
||||
|
||||
A small adapter inside `mergeSnapshotAndSessions` converts each
|
||||
`WorktreeMemory` from the shared type into a `UnifiedWorktreeRow` with
|
||||
numeric metrics and `hasLocalSamples: true`. Synthetic remote rows are
|
||||
constructed directly with `null` metrics. The existing `MetricPair` and
|
||||
`Sparkline` callsites in the popover get a thin wrapper that renders
|
||||
`—` when its input is `null`; nothing in `src/shared/` or `src/main/`
|
||||
changes.
|
||||
|
||||
### Merge algorithm
|
||||
|
||||
The merge is renderer-only and pure. Inputs in priority order:
|
||||
|
||||
- **Snapshot first.** `snapshot.worktrees` is the authoritative source
|
||||
for any local PTY's CPU/Mem and for the worktree's identity (it
|
||||
already carries `worktreeId`, `worktreeName`, `repoId`, `repoName`,
|
||||
and a `sessions[]` array with per-session metrics).
|
||||
- **Sessions augments.** `sessions: DaemonSession[]` is the union over
|
||||
the snapshot — it adds (a) SSH PTYs that the local memory collector
|
||||
cannot see and (b) any local PTY that registered too late to make
|
||||
this snapshot's sweep.
|
||||
|
||||
#### Join key (snapshot session ↔ daemon session)
|
||||
|
||||
Both layers identify a session by the **same string**: the PTY id.
|
||||
|
||||
- `SessionMemory.sessionId` (collector) is `pty.sessionId ?? pty.ptyId`
|
||||
from `pty-registry.ts`. In-process local PTYs spawn with
|
||||
`args.sessionId === undefined` (`pty.ts:684`), so the registry stores
|
||||
`sessionId: null` and the fallback `pty.ptyId` is used — which equals
|
||||
`result.id` from the local provider's spawn, which is also what
|
||||
`LocalPtyProvider.listProcesses()` returns as `id`
|
||||
(`local-pty-provider.ts:518-522`).
|
||||
- For daemon-hosted spawns, `mintPtySessionId(worktreeId)` produces the
|
||||
`${worktreeId}@@${shortUuid}` form
|
||||
(`src/main/daemon/pty-session-id.ts`); both the registry and
|
||||
`pty.listSessions()` carry that exact string as the id.
|
||||
|
||||
Therefore the dedup key is simply `session.id` on both sides. The
|
||||
merge MUST build `Set<string>` of all `SessionMemory.sessionId`s from
|
||||
the snapshot and skip any `DaemonSession` whose `id` is already in the
|
||||
set (so a local session never appears twice — once with metrics, once
|
||||
as a `—` placeholder).
|
||||
|
||||
#### Worktree resolution for `DaemonSession`s
|
||||
|
||||
When a `DaemonSession` is **not** already accounted for by the
|
||||
snapshot, we need to bucket it under a worktree group. Try in order:
|
||||
|
||||
1. **Tab-store walk** (existing logic in `SessionsTabPanel`): look up
|
||||
`ptyIdsByTabId` → `tabId` → `tabsByWorktree` → `worktreeId`. This
|
||||
resolves any session bound to a live tab in *this* renderer.
|
||||
2. **Session-id parse**: if the id contains `@@`, take
|
||||
`id.slice(0, id.lastIndexOf('@@'))` as a candidate `worktreeId`.
|
||||
This is the convention enforced by `mintPtySessionId` (see
|
||||
`pty-session-id.ts:5-7` and the symmetric parser in
|
||||
`daemon-pty-adapter.ts:328-331`). It correctly recovers the
|
||||
worktreeId for SSH sessions that haven't been bound to a tab in
|
||||
this Orca instance — which is the user's primary scenario
|
||||
(`orca/Stingray`, `orca/Sawfish`, etc.).
|
||||
3. **Unattributed**: if neither resolves, bucket under a synthetic
|
||||
`unattributed` repo group at the bottom of the list.
|
||||
|
||||
The `repoId` for each new worktree group is recovered the same way the
|
||||
collector does it (`collector.ts:resolveWorktreeNames`): split the
|
||||
worktreeId on the first `::`. Resolved repo display name comes from
|
||||
the renderer-side store (`store.repos[repoId]?.displayName`) when
|
||||
available; otherwise the bare `repoId` is used.
|
||||
|
||||
#### Step-by-step
|
||||
|
||||
1. Initialize `repos: Map<repoId, UnifiedRepoGroup>` empty.
|
||||
2. Insert all `snapshot.worktrees` (if any) into the map, grouped by
|
||||
their `repoId`. Each worktree carries `hasLocalSamples: true` and
|
||||
numeric metrics; its sessions inherit numeric metrics.
|
||||
3. Build `seenSessionIds = new Set<string>(...all session ids in
|
||||
snapshot.worktrees[].sessions)`.
|
||||
4. For each `DaemonSession` in `sessions: DaemonSession[]`:
|
||||
- If `seenSessionIds.has(session.id)`, skip (already merged from
|
||||
snapshot).
|
||||
- Resolve `worktreeId` per the three-step rule above.
|
||||
- If the resolved `worktreeId` is already a worktree group in the
|
||||
map, append the session to its `sessions[]` with `cpu: null`,
|
||||
`memory: null`, `hasLocalSamples: false`. (Reachable for SSH
|
||||
sessions whose worktree happens to match a locally-active
|
||||
worktree id — rare but possible.)
|
||||
- Otherwise, create a new worktree group with `cpu: null`,
|
||||
`memory: null`, `history: []`, `hasLocalSamples: false`, and
|
||||
append the session.
|
||||
5. Compute per-repo aggregates: sum `cpu`/`memory` from worktrees with
|
||||
`hasLocalSamples === true`. If the repo has *any* worktree with
|
||||
`hasLocalSamples === false`, set `repoHasRemoteChildren: true` (used
|
||||
by the UI to render a `· remote` badge on the repo header so the
|
||||
user knows the displayed totals exclude remote worktrees).
|
||||
6. The Orca app section (Main / Renderer / Other) renders unchanged
|
||||
below the list.
|
||||
|
||||
The whole pass is O(W + S) where W = snapshot worktrees and S =
|
||||
sessions. No nested scans.
|
||||
|
||||
### Why this is safe with current data
|
||||
|
||||
- Every existing local session continues to render with the same
|
||||
numbers. The merge **adds** rows; it does not change values.
|
||||
- The snapshot rebuilds every poll (10s), so SSH sessions transitioning
|
||||
to local (or vice versa) flip representation on the next tick. No
|
||||
reconciliation logic needed.
|
||||
- Orphan detection still works: a session is "bound" iff its
|
||||
`sessionId` appears in `boundPtyIds`. Sessions in repos we couldn't
|
||||
resolve still get the X-kill affordance.
|
||||
|
||||
## UI
|
||||
|
||||
### Trigger (status-bar badge) — unchanged
|
||||
|
||||
Mac/Linux/Windows safe (no platform-specific glyphs introduced).
|
||||
|
||||
### Popover — single panel, fixed height
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Resource Usage ⟳ 🗑 ✕ │ ← header
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 1.9% · 955.2 MB · 3% of system RAM │ ← summary
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Name CPU Memory · │ ← sort row
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ▾ Triton 0.0% 125.5 MB │
|
||||
│ Terminal 1 0.0% 63.2 MB ✕│
|
||||
│ Terminal 1 0.0% 1.3 MB ✕│
|
||||
│ ▾ Stingray ⓘ remote — — │
|
||||
│ orca/Stingray — — ✕│
|
||||
│ ▾ Sawfish ⓘ remote — — │
|
||||
│ orca/Sawfish — — ✕│
|
||||
│ ───────────────────────── │
|
||||
│ ▸ Orca app 0.5% 829.7 MB │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑ fixed 420px body, owns its own scroll
|
||||
↓ orphan bulk-kill pill renders here when N>0
|
||||
```
|
||||
|
||||
The header keeps the icon-only **Restart daemon** (RotateCw) and
|
||||
**Kill all sessions** (Trash2) buttons in the top-right.
|
||||
|
||||
### Interaction states (4 paths)
|
||||
|
||||
| Scenario | Resource cells | Kill X | Click row |
|
||||
|-----------------------------|----------------|-------------|--------------|
|
||||
| Local PTY, bound | numeric | hover only | navigates |
|
||||
| Local PTY, orphan | numeric | always | no-op |
|
||||
| Remote PTY, bound | `—` | hover only | navigates |
|
||||
| Remote PTY, orphan | `—` | always | no-op |
|
||||
|
||||
`—` is rendered as `text-muted-foreground/50`. The worktree row carries
|
||||
a small `· remote` badge when `hasLocalSamples === false`; the repo row
|
||||
carries the same badge when `hasRemoteChildren === true` (so users
|
||||
know the displayed repo totals exclude remote worktrees).
|
||||
|
||||
**`bound` semantics are unchanged from today:** a session is bound iff
|
||||
`boundPtyIds.has(session.id)` evaluated against this renderer's
|
||||
`ptyIdsByTabId`. SSH sessions bound to tabs in *another* Orca process
|
||||
look identical to local orphans here — same as before the merge.
|
||||
|
||||
### Empty / loading / error states
|
||||
|
||||
- Daemon unreachable → existing banner remains, body shows nothing.
|
||||
- Sessions error AND no snapshot AND zero sessions → "Resource data
|
||||
unavailable. Restart daemon."
|
||||
- Snapshot present but zero worktrees AND zero sessions → "Nothing
|
||||
running right now."
|
||||
- Loading (no snapshot, no error) → "Loading…".
|
||||
|
||||
### Sort
|
||||
|
||||
Three buttons in the sort row: **Name**, **CPU**, **Memory**. CPU/Mem
|
||||
sort puts `null` metrics last (stable). Name uses `localeCompare`.
|
||||
Selection is repo-group aware: sorting by CPU sorts repos by aggregate
|
||||
CPU and worktrees within a repo by their CPU.
|
||||
|
||||
### Confirm dialogs (unchanged)
|
||||
|
||||
- Per-row kill X: confirm Dialog with copy "Kill this session? Force-
|
||||
quits `<id>`. Any unsaved work in that pane is lost. This can't be
|
||||
undone."
|
||||
- Header Trash2: existing "Kill all sessions" Dialog from
|
||||
`useDaemonActions`.
|
||||
- Header RotateCw: existing "Restart daemon" Dialog from
|
||||
`useDaemonActions`.
|
||||
- Worktree-level Sleep / Delete: unchanged.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
1. **New helper** `mergeSnapshotAndSessions.ts` (renderer-side, pure
|
||||
function) that takes `(snapshot, sessions, storeContext)` and
|
||||
returns the unified `RepoGroup[]` shape above. Unit-testable.
|
||||
2. **Refactor `ResourceUsageStatusSegment.tsx`**:
|
||||
- Remove the tab switcher (drop `activeTab` state, both
|
||||
`'resources'` / `'sessions'` branches, the inline pill component,
|
||||
and the `Trash2` import for the old switcher).
|
||||
- Replace the body with a single render path that consumes the
|
||||
unified `RepoGroup[]` from the helper.
|
||||
- Keep the existing `WorktreeSection`/`AppSection` patterns — extend
|
||||
them to render `null` metrics as `—` and to render the kill-X on
|
||||
each session row (with bound-vs-orphan visibility rule).
|
||||
- Promote `killConfirm` state and the confirm Dialog from
|
||||
`SessionsTabPanel` to the segment level since there's only one
|
||||
panel now.
|
||||
- Keep `onSessionsChanged` plumbing — call it from kill / kill-
|
||||
orphans handlers to trigger an immediate `refreshSessions()`.
|
||||
- Delete the now-unused `SessionsTabPanel` component.
|
||||
3. **Wire metric formatting** to render `—` when metric is `null`. Use
|
||||
the `null`-vs-`0` distinction already present in the data model;
|
||||
don't conflate them.
|
||||
4. **Migration**: status-bar item id stays `'resource-usage'`. The
|
||||
`migrateStatusBarItems` helper is unchanged. No persistence
|
||||
migration needed for this UI-only change.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit: `mergeSnapshotAndSessions` with synthetic inputs covering:
|
||||
- All 4 interaction-state paths (local-bound, local-orphan,
|
||||
remote-bound, remote-orphan).
|
||||
- **Dedup**: a local session that appears in both the snapshot and
|
||||
the daemon list is rendered exactly once with numeric metrics.
|
||||
- **`@@` parse**: an SSH session id `repoX::/path/wtA@@abcd` with no
|
||||
matching tab resolves to repoId `repoX`, worktreeId
|
||||
`repoX::/path/wtA`.
|
||||
- **Tab walk wins over `@@` parse**: a session bound to a tab whose
|
||||
worktreeId differs from the `@@` prefix uses the tab's worktreeId
|
||||
(defensive against id-format drift).
|
||||
- **Repo aggregate excludes remote children**: a repo with one
|
||||
local (125 MB) and one remote (`null`) worktree reports
|
||||
`cpu/memory = 125 MB` and `hasRemoteChildren = true`.
|
||||
- Edge cases: snapshot null, sessions empty, both empty, sessionId
|
||||
without `@@` and no tab match → falls into Unattributed.
|
||||
- Manual:
|
||||
- Open with mixed local + SSH worktrees → both render, SSH rows show
|
||||
`—` and a `· remote` badge on their worktree and repo headers.
|
||||
- Kill a remote session via the X → confirm dialog → optimistic
|
||||
removal so row disappears immediately (does not wait 10s).
|
||||
- Kill all (header trash) → orphans gone, bound sessions remain.
|
||||
- Resize window / scroll the body → fixed 420px holds, no reflow.
|
||||
- Accessibility: tab through rows → kill X focusable; after kill
|
||||
confirm, focus lands back on the popover content root, not on
|
||||
`<body>`. Dialog is keyboard-dismissable except while in-flight.
|
||||
|
||||
## Risks & mitigations
|
||||
|
||||
- **Risk**: SSH worktrees the user never opened locally still appear in
|
||||
Resources (potentially noisy if the daemon retains many idle SSH
|
||||
sessions). **Mitigation**: bucketing under a `· remote` badge keeps
|
||||
them visually distinct from local-active groups; sort-by-memory pushes
|
||||
them to the bottom. If noise becomes a real complaint, a future PR
|
||||
can add a "hide remote" toggle.
|
||||
- **Risk**: Some sessions can't be resolved to any worktreeId (e.g.
|
||||
daemon-internal sessions whose ids don't follow the `@@` convention,
|
||||
or sessions whose worktreeId resolves to a repo store doesn't know
|
||||
about). **Mitigation**: synthetic `Unattributed` repo group at the
|
||||
bottom, which already exists in spirit as `ORPHAN_WORKTREE_ID` in the
|
||||
memory collector.
|
||||
- **Risk**: Kill-X on a remote session fires `pty.kill(id)`, which the
|
||||
IPC layer routes through `ptyOwnership` to the right SSH provider.
|
||||
Behavior is identical to the old Sessions tab — no new failure mode.
|
||||
- **Risk: poll-rate skew between snapshot (2s) and sessions (10s).**
|
||||
After killing an SSH session, the daemon-side `sessions` list can
|
||||
retain the dead row for up to 10s while the snapshot has already
|
||||
moved on; conversely a freshly-spawned local PTY shows up in
|
||||
`sessions` first and is rendered as a remote placeholder for up to
|
||||
2s before the next snapshot promotes it.
|
||||
**Mitigation**: per-row kill optimistically removes the session id
|
||||
from the renderer's local `sessions` state immediately after the IPC
|
||||
resolves (in addition to calling `onSessionsChanged()`), so the
|
||||
killed row never lingers on screen. The new-session-flash is
|
||||
bounded by `POLL_MS` and acceptable.
|
||||
- **Risk: focus dropped to `<body>` after kill confirm.** When the
|
||||
killed session disappears on the next refresh, the per-row X that
|
||||
had focus is unmounted. With the confirm Dialog now mounted at the
|
||||
segment level, this regression is more visible.
|
||||
**Mitigation**: after `runKillConfirmed` resolves, focus the
|
||||
popover's content root via a stable ref so keyboard users land back
|
||||
in the list, not on `<body>`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No new IPC.
|
||||
- No collector changes.
|
||||
- No new persisted UI prefs (the per-tab visibility split is gone, not
|
||||
replaced with a per-section toggle).
|
||||
- No remote memory sampling — that's a separate, much bigger project.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Sidebar Repo Filter Redesign
|
||||
|
||||
## Problem
|
||||
|
||||
The current sidebar filter UI does not scale beyond a small repo count. Rendering every repo as a checkbox row in a menu creates poor scanability, high click cost, and clipping/scroll friction.
|
||||
|
||||
Constraints from current code:
|
||||
- Filter semantics are store-driven (`showActiveOnly`, `hideDefaultBranchWorkspace`, `filterRepoIds`) and consumed by `computeVisibleWorktreeIds`.
|
||||
- `searchRepos()` already provides ranking (display name first, path fallback).
|
||||
- The trigger badge is the primary “filters active” affordance and must stay.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Make repo filtering usable with large repo sets.
|
||||
2. Keep toggle filters one click.
|
||||
3. Add fast bulk actions (`All`, `None`, `Clear all`).
|
||||
4. Preserve existing filter semantics and badge behavior.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No semantic changes to filtering logic in `visible-worktrees.ts`.
|
||||
- No sticky-all persistence model like `RepoMultiCombobox`.
|
||||
- No store schema migration.
|
||||
|
||||
## Correctness Notes (from code audit)
|
||||
|
||||
- `searchRepos()` is not debounced; it is synchronous `useMemo` filtering.
|
||||
- Popover wheel rescue in `popover.tsx` only runs when the wheel handler is attached to a `PopoverContent` element that has class `popover-scroll-content`. Putting that class on an inner div does not activate the rescue path.
|
||||
- `command.tsx` already includes a wheel fallback on `CommandList` for scroll-locked Radix Dialog parents.
|
||||
- Sidebar filters are persisted via `window.api.ui.set` (debounced in `App.tsx`) and restored from persisted UI on launch.
|
||||
|
||||
## Proposed UI
|
||||
|
||||
Use `Popover` and keep trigger button/badge unchanged.
|
||||
|
||||
Popover sections:
|
||||
1. Header: `Filters` + `Clear all` (shown only when any filter is active).
|
||||
2. Toggle rows: `Active only`, `Hide default branch`.
|
||||
3. Repo section (only when `repos.length > 1`):
|
||||
- Label + selected count.
|
||||
- `All` and `None` actions.
|
||||
- Search box (`Search repos...`).
|
||||
- Scrollable repo list with checkmark, repo dot/name, and SSH badge when `connectionId` exists.
|
||||
- Secondary path line for disambiguation when names collide.
|
||||
4. Bottom action: `Add project` pinned below repo list.
|
||||
|
||||
## Implementation Decision: cmdk vs plain input
|
||||
|
||||
Use `Command` primitives (as in `repo-multi-combobox.tsx`) with `shouldFilter={false}` and feed pre-ranked `searchRepos()` results.
|
||||
|
||||
Why:
|
||||
- Better keyboard behavior out of the box (arrow navigation, enter selection).
|
||||
- Existing `CommandList` wheel fallback handles scroll-lock contexts that currently break plain inner-scroll regions.
|
||||
- Consistent behavior with an existing repo selector pattern in the app.
|
||||
|
||||
## Edge Cases / Invalidation
|
||||
|
||||
- Repo removed while popover is open: selection count and rows derive from live `repos`; stale ids must not count toward badge or selected count.
|
||||
- Repo added while open: `All` should include the new repo immediately (derive from current `repos` each render).
|
||||
- External repo mutations (sync/import) during search: filtered list updates from live `repos`; empty-state must not hide `Add project`.
|
||||
- SSH repos: show SSH indicator to avoid ambiguity with local repos of same display name.
|
||||
- 0 or 1 repo: hide repo filter section. Keep `Add project` visible outside the repo-count gate so users can still recover from low-repo states.
|
||||
- Multi-window: filter state is window-local; this redesign does not introduce cross-window synchronization.
|
||||
|
||||
## Accessibility / Focus
|
||||
|
||||
- Do not use `role="menuitemcheckbox"` unless the container is a true menu. Use semantic buttons or cmdk items with explicit `aria-selected`/checked indicators.
|
||||
- Autofocus search on open.
|
||||
- Esc closes via Radix Popover default.
|
||||
- Ensure focus returns to trigger on close.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Replace sidebar filter content with Popover + Command-based repo list.
|
||||
2. Keep `searchRepos()` as the only ranking source.
|
||||
3. Keep current filter setters and badge derivation semantics.
|
||||
4. Ensure scroll behavior works inside dialog parents by using `CommandList` (or by moving wheel handling to the actual scroll container).
|
||||
5. Validate with `pnpm typecheck` and `pnpm lint`.
|
||||
|
||||
## Test Scope
|
||||
|
||||
- Unit coverage remains in `visible-worktrees` for filter semantics.
|
||||
- Add/adjust component tests for:
|
||||
- keyboard navigation and selection,
|
||||
- stale repo id handling,
|
||||
- `All`/`None` behavior with live repo mutations,
|
||||
- `Add project` visibility at repo count 0/1.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Sidekick animation state mapping
|
||||
|
||||
Orca's normalized agent status model has four hook-reported states: `working`, `blocked`, `waiting`, and `done`. `interrupted` is an optional flag on `done`, not a separate failure state.
|
||||
|
||||
Codex pet spritesheets can expose more visual rows than Orca has agent states. Sidekick should treat those row names as an asset contract, not as proof that Codex or Orca reports matching runtime states.
|
||||
|
||||
The current mapping is:
|
||||
|
||||
| Orca condition | Sidekick animation |
|
||||
| --- | --- |
|
||||
| Sidekick is being dragged | `jumping` |
|
||||
| Any fresh agent is `blocked` or `waiting` | `waiting` |
|
||||
| Any fresh agent is `working` | `running` |
|
||||
| Any fresh agent is `done` | `review` |
|
||||
| Any retained completed agent exists | `review` |
|
||||
| No fresh or retained agent state exists | `idle` |
|
||||
|
||||
`SidekickAnimationName` deliberately omits `failed`. Orca distinguishes interrupted completions from normal completions, but interruption can mean user cancellation rather than agent failure, so mapping it to `failed` would overstate the status until Orca has a real failure/error signal. Codex spritesheets may still expose a `failed` row — that row stays as part of the asset contract but is never selected at runtime.
|
||||
@@ -1,26 +0,0 @@
|
||||
# Split Groups PR 1: Model Foundations
|
||||
|
||||
This branch lands the behavior-neutral tab-group model groundwork.
|
||||
|
||||
Scope:
|
||||
- add persisted tab-group layout state
|
||||
- add active-group persistence and hydration
|
||||
- add group-aware unified-tab helpers in the store
|
||||
- connect editor/open-file flows to the unified tab-group model
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- the store persists `groupsByWorktree`, `layoutByWorktree`, and `activeGroupIdByWorktree`
|
||||
- workspace session save/hydration includes tab-group layouts
|
||||
- editor actions create and activate unified tabs through the group model
|
||||
- the visible workspace renderer is still the legacy single-surface path
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- `Terminal.tsx` does not render split groups
|
||||
- no split-group UI components are mounted
|
||||
- no PTY lifecycle changes land here
|
||||
- no worktree activation fallback changes land here
|
||||
|
||||
Non-goals:
|
||||
- no split-group UI rollout
|
||||
- no terminal PTY lifecycle changes
|
||||
- no worktree activation changes
|
||||
@@ -1,24 +0,0 @@
|
||||
# Split Groups PR 2: Terminal Lifecycle Hardening
|
||||
|
||||
This branch lands the terminal ownership and remount safety work required
|
||||
before split groups can be exposed.
|
||||
|
||||
Scope:
|
||||
- preserve PTYs across remounts
|
||||
- fix pending-spawn dedupe paths
|
||||
- fix split-pane PTY ownership
|
||||
- keep visible-but-unfocused panes rendering correctly
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- the existing terminal path uses the new PTY attach/detach/remount behavior
|
||||
- split panes inside a terminal tab get distinct PTY ownership
|
||||
- visible terminal panes continue rendering even when another pane or group has focus
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- no split-group layout is rendered
|
||||
- `Terminal.tsx` still uses the legacy single-surface host path
|
||||
- no worktree restore/activation changes land here
|
||||
|
||||
Non-goals:
|
||||
- no split-group UI rollout
|
||||
- no worktree activation fallback changes
|
||||
@@ -1,23 +0,0 @@
|
||||
# Split Groups PR 3: Worktree Restore Ownership
|
||||
|
||||
This branch moves worktree activation and restore logic onto the reconciled
|
||||
tab-group model.
|
||||
|
||||
Scope:
|
||||
- reconcile stale unified tabs before restore
|
||||
- restore active surfaces from the group model first
|
||||
- fall back to terminal when a grouped worktree has no renderable surface
|
||||
- create a root group before initial terminal fallback attaches a new tab
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- opening an existing worktree restores from the reconciled group/tab model
|
||||
- reopening an empty grouped worktree falls back to a terminal instead of a blank pane
|
||||
- initial terminal creation is now driven by renderable grouped content instead of one-time init guards
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- no split-group layout is rendered
|
||||
- the visible workspace host is still the legacy terminal/browser/editor surface path
|
||||
- tab-group UI components still are not mounted here
|
||||
|
||||
Non-goals:
|
||||
- no split-group UI enablement yet
|
||||
@@ -1,22 +0,0 @@
|
||||
# Split Groups PR 4: Split-Group UI Scaffolding
|
||||
|
||||
This branch adds the split-group UI pieces, but does not mount them in the
|
||||
main workspace host yet.
|
||||
|
||||
Scope:
|
||||
- add `TabGroupPanel`, `TabGroupSplitLayout`, and `useTabGroupController`
|
||||
- add split-group actions to tab menus and tab-bar affordances
|
||||
- add the follow-up design note for the architecture
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- the new split-group components compile and exist in the tree
|
||||
- tab-bar level split-group affordances are present in the component layer
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- `Terminal.tsx` does not mount `TabGroupSplitLayout` in this branch
|
||||
- users still see the legacy single-surface renderer
|
||||
- no feature switch exists here because the code path is not wired in yet
|
||||
|
||||
Non-goals:
|
||||
- no rollout to users
|
||||
- no main-renderer ownership change yet
|
||||
@@ -1,19 +0,0 @@
|
||||
# Split Groups PR 5: Hook Group Surfaces Into Flagged Path
|
||||
|
||||
This branch wires terminal, editor, and browser surfaces into the split-group
|
||||
ownership path inside `Terminal.tsx`, but holds that path behind a temporary
|
||||
local gate.
|
||||
|
||||
Scope:
|
||||
- remove duplicate legacy ownership under the flagged path
|
||||
- route group-local surface creation and restore through the new model
|
||||
- preserve existing default behavior while the flag stays off
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- `Terminal.tsx` now contains the real split-group surface path
|
||||
- the new path mounts `TabGroupSplitLayout` and avoids keeping duplicate legacy surfaces mounted underneath
|
||||
- the old legacy surface path is still present as the active runtime path in this branch
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- the split-group path is still disabled by the temporary local rollout gate in `Terminal.tsx`
|
||||
- users should still get legacy behavior by default in this branch
|
||||
@@ -1,14 +0,0 @@
|
||||
# Split Groups PR 6: Enable Split Groups
|
||||
|
||||
This branch turns split groups on after the earlier model, lifecycle, restore,
|
||||
and renderer-ownership branches are in place.
|
||||
|
||||
Scope:
|
||||
- remove the temporary rollout gate from `Terminal.tsx`
|
||||
- make the split-group ownership path the active renderer path
|
||||
- keep only the small bootstrap fallback for cases where no layout exists yet
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- split groups are live
|
||||
- `Terminal.tsx` always resolves through the split-group path when layout/group state exists
|
||||
- the temporary gate introduced in the dark-launch PR is removed instead of left behind as dead config
|
||||
@@ -1,176 +0,0 @@
|
||||
# Split-Pane CWD Inheritance (Cmd+D / Cmd+Shift+D)
|
||||
|
||||
## Problem
|
||||
|
||||
When the user presses **Cmd+D** or **Cmd+Shift+D** to split a terminal pane, the new pane spawns at the worktree root instead of the source pane's current working directory. Most visible on **SSH** workspaces, but true locally as well — local only appears to "work" when the user hasn't `cd`'d away from the worktree root.
|
||||
|
||||
Reference point: Ghostty, iTerm2, kitty, Warp, and WezTerm all inherit the source pane's live CWD when splitting.
|
||||
|
||||
## Current behavior (code-level trace)
|
||||
|
||||
1. `src/renderer/src/components/terminal-pane/keyboard-handlers.ts:283` dispatches on `action.type === 'splitActivePane'`; the `manager.splitPane(pane.id, direction)` call is at `:296`.
|
||||
2. `src/renderer/src/lib/pane-manager/pane-manager.ts:93 splitPane()` creates the new pane and fires `onPaneCreated(newPane)`.
|
||||
3. `src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts:349 onPaneCreated` → `connectPanePty(pane, manager, {...ptyDeps})`.
|
||||
4. `src/renderer/src/components/terminal-pane/pty-connection.ts:272` builds the transport with `cwd: deps.cwd`.
|
||||
5. `deps.cwd` is set once per tab at mount from the `TerminalPane` prop, always `worktree.path`.
|
||||
|
||||
There is **no plumbing** anywhere in the renderer that (a) tracks the live CWD of a pane or (b) threads a per-split CWD override into `splitPane`.
|
||||
|
||||
The renderer does not currently register any OSC-7 handler. Only the headless daemon emulator (`src/main/daemon/headless-emulator.ts`) parses OSC-7 — that path only feeds daemon snapshot metadata, never the renderer's pane state.
|
||||
|
||||
## Prior art: Superset
|
||||
|
||||
Superset (sibling project at `../../superset`) already implements renderer-side OSC-7 tracking:
|
||||
|
||||
- `parseCwd.ts` — regex `ESC]7;file://[^/]*(/...)BEL|ST`, URI-decodes the path, returns the most recent match in a data chunk.
|
||||
- `useTerminalCwd.ts` — per-pane hook: seeds from `initialCwd`/`workspaceCwd`, calls `parseCwd` on every xterm data event, stores `{terminalCwd, cwdConfirmed}` with a 150ms debounce.
|
||||
|
||||
Superset's split action accepts `options.initialCwd` but its Cmd+D wiring passes a **preset** CWD, not the source pane's tracked `terminalCwd`. We will close that loop.
|
||||
|
||||
## Design
|
||||
|
||||
### Overview
|
||||
|
||||
Track each pane's live CWD in the renderer via OSC-7, fall back to a one-shot `pty.getCwd` query, and pass the resolved value as `cwd` when spawning the new split PTY.
|
||||
|
||||
### 1. Track per-pane CWD via OSC-7
|
||||
|
||||
Add an OSC-7 handler alongside the existing OSC-52 handler in `use-terminal-pane-lifecycle.ts` `onPaneCreated` (near line 369):
|
||||
|
||||
```ts
|
||||
const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => {
|
||||
const cwd = parseOsc7(data)
|
||||
if (cwd) {
|
||||
paneCwdRef.current.set(pane.id, cwd)
|
||||
}
|
||||
// Return true so xterm marks the sequence handled and does not fall
|
||||
// through to any builtin behavior. Note: xterm's `registerOscHandler`
|
||||
// contract is "first handler that returns true wins"; if a future
|
||||
// consumer (e.g. a shared daemon emulator path) also registers on
|
||||
// code 7, registration order decides who sees each sequence.
|
||||
return true
|
||||
})
|
||||
```
|
||||
|
||||
- New ref on `TerminalPane`: `paneCwdRef = useRef<Map<number, string>>(new Map())`.
|
||||
- Disposable tracked in an `osc7DisposablesRef` map parallel to `osc52DisposablesRef`, torn down in `onPaneClosed` (see `use-terminal-pane-lifecycle.ts:481-484` for the OSC-52 pattern). `onPaneClosed` must also `paneCwdRef.current.delete(paneId)` so the map doesn't accumulate dead entries across splits/closes.
|
||||
- **Why `registerOscHandler(7)` rather than scanning raw data:** xterm's OSC parser handles BEL/ST termination, sequence fragmentation across chunks, and nesting. A regex on raw data (as Superset does) misses sequences split across PTY read boundaries.
|
||||
- **Handler install must stay before PTY attach.** The current `onPaneCreated` body (`use-terminal-pane-lifecycle.ts:349-460`) installs OSC parser handlers synchronously *before* `connectPanePty`. That ordering is load-bearing: it guarantees the first byte of the PTY stream — which in the cold-restore path is replayed scrollback containing OSC-7 — reaches our handler. Add a `// Why:` comment so a future refactor does not move the install after attach.
|
||||
- **Replay + stale OSC-7.** Replayed scrollback can contain OSC-7 from an earlier `cd` that no longer reflects the live shell cwd. `paneCwdRef` does not distinguish replayed vs. live entries. Store `{cwd, confirmed: boolean}` and mark `confirmed = false` when `isPaneReplaying(...)` is true at handler fire time, `confirmed = true` otherwise. `resolveSplitCwd` prefers a `confirmed` entry and otherwise falls through to `pty.getCwd`, which reflects live shell state via `/proc` or `lsof`. **Why the flag is reliable during replay:** `replayIntoTerminal` (`replay-guard.ts:37-55`) increments the counter *before* `pane.terminal.write(data, cb)` and decrements it inside xterm's write-completion callback. xterm fires parser handlers synchronously as it consumes the buffer, so every OSC-7 parsed out of the replayed chunk sees a non-zero counter. Do **not** change `replay-guard.ts` to a pre-`write` decrement — the whole design depends on the decrement being post-parse.
|
||||
|
||||
No debouncing needed — `Map.set` is cheap and the value is read only on demand at split time.
|
||||
|
||||
### 2. Resolve CWD at split time
|
||||
|
||||
Introduce a helper `resolveSplitCwd(sourcePaneId, sourcePtyId, fallbackCwd): Promise<string>`. Owned by `use-terminal-pane-lifecycle.ts` (co-located with `paneCwdRef`) and passed down to keyboard and context-menu handlers as a dep, symmetric with the existing `paneTransportsRef` wiring.
|
||||
|
||||
1. If `paneCwdRef.current.get(sourcePaneId)` has `confirmed === true`, return its `cwd` **synchronously** (no await). OSC-7 from a live shell is authoritative and instant.
|
||||
2. Otherwise `await window.api.pty.getCwd(sourcePtyId)` with a ~200ms soft timeout enforced renderer-side via `Promise.race` — keeps the "fall through to fallback" semantics in one place rather than threading timeouts into the provider. Use the result if non-empty.
|
||||
3. Otherwise, if `paneCwdRef.current.get(sourcePaneId)` has `confirmed === false` (replayed OSC-7 only), return its `cwd` as a last-ditch guess before falling back.
|
||||
4. Otherwise return `fallbackCwd` (the existing worktree root).
|
||||
|
||||
**Sync vs async in the keydown handler.** The current Cmd+D handler at `keyboard-handlers.ts:283` is synchronous and calls `e.preventDefault()` / `e.stopImmediatePropagation()` before `splitPane`. The new flow preserves sync ordering:
|
||||
|
||||
- Compute `preventDefault` + pane snapshot synchronously.
|
||||
- Read `paneCwdRef` synchronously. If hit → call `splitPane` immediately (no `await`).
|
||||
- If miss → fire-and-forget an `async` IIFE that awaits `pty.getCwd` then calls `splitPane`. This is equivalent to today's async pane-creation lifecycle, which is already non-blocking.
|
||||
|
||||
Double Cmd+D in the cache-miss window: the second keypress fires before the first fire-and-forget IIFE has called `splitPane`, so both async callbacks still see the original source pane as active, resolve the same `pty.getCwd`, and produce two splits off that source (not a chain). This differs from today's fully-sync behavior, which would have chained the second split off the just-created pane. Accepted tradeoff — the window is ~1 IPC round-trip (≤200 ms) and "both splits inherit the same cwd" is not wrong, just not chained. Cache-hit (common case: an active shell with OSC-7 already seen) is still sync and preserves chaining.
|
||||
|
||||
### 3. Thread CWD through splitPane
|
||||
|
||||
`PaneManager.splitPane(paneId, direction, opts?)` currently accepts `opts: { ratio?: number }` (`pane-manager.ts:93-97`). Extend to `{ ratio?: number; cwd?: string }`.
|
||||
|
||||
Plumb the hint to `onPaneCreated` by widening its signature to `(pane, spawnHints?: { cwd?: string })`. The hint is forwarded synchronously inside `splitPane` and has no reason to outlive that call. The existing `pendingSplitScrollState` field on `ManagedPaneInternal` exists only because rAFs read it *later*; that is not the case here.
|
||||
|
||||
**Typed API break — all call sites of `onPaneCreated` must update:**
|
||||
|
||||
- `PaneManagerOptions.onPaneCreated` type declaration in `pane-manager.ts`.
|
||||
- The `createPane` invocation at `pane-manager.ts:89` passes `undefined` for `spawnHints` (new panes aren't splits).
|
||||
- The `splitPane` invocation at `pane-manager.ts:133` forwards `opts?.cwd` as `{ cwd: opts.cwd }` when set.
|
||||
|
||||
In `use-terminal-pane-lifecycle.ts`:
|
||||
|
||||
```ts
|
||||
onPaneCreated: (pane, spawnHints) => {
|
||||
// ...existing handler install...
|
||||
const panePtyBinding = connectPanePty(pane, manager, {
|
||||
...ptyDeps,
|
||||
...(spawnHints?.cwd ? { cwd: spawnHints.cwd } : {}),
|
||||
restoredLeafId
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Spread order matters: `spawnHints.cwd` overrides the tab-level `ptyDeps.cwd`.
|
||||
|
||||
**Callers of `splitPane` — which inherit, which don't.**
|
||||
|
||||
| Call site | File:line | Passes `cwd`? | Why |
|
||||
|---|---|---|---|
|
||||
| Cmd+D / Cmd+Shift+D | `keyboard-handlers.ts:296` | **yes** | primary feature |
|
||||
| Context menu: Split Right / Split Down | `use-terminal-pane-context-menu.ts:114,121` | **yes** | same user intent as Cmd+D |
|
||||
|
||||
Both Cmd+D and context-menu call sites receive a new dep `resolveSplitCwd: (paneId, ptyId, fallbackCwd) => Promise<string>` and replace `manager.splitPane(pane.id, dir)` with roughly:
|
||||
|
||||
```ts
|
||||
const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null
|
||||
const cached = paneCwdRef.current.get(pane.id)
|
||||
if (cached?.confirmed) {
|
||||
manager.splitPane(pane.id, dir, { cwd: cached.cwd })
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
const cwd = await resolveSplitCwd(pane.id, ptyId, fallbackCwd)
|
||||
managerRef.current?.splitPane(pane.id, dir, { cwd })
|
||||
})()
|
||||
```
|
||||
|
||||
The keydown handler still calls `e.preventDefault()` / `e.stopImmediatePropagation()` synchronously before dispatching the async resolution — matches existing async pane-creation lifecycle.
|
||||
| Worktree setup split | `use-terminal-pane-lifecycle.ts:693` | no | bootstrap command runs at worktree root by contract |
|
||||
| Issue-command split | `use-terminal-pane-lifecycle.ts:719` | no | per-repo automation; runs at worktree root by contract |
|
||||
| CLI-triggered split (`onCliSplitPane`) | `use-terminal-pane-lifecycle.ts:754,757` | no | CLI protocol today carries only `paneRuntimeId` + `direction`; no cwd field. Preserve today's behavior until the CLI protocol adds one. |
|
||||
| Layout restore | `layout-serialization.ts:299` | no | restored panes re-emit their own OSC-7 on first prompt |
|
||||
|
||||
The setup/issue/CLI sites go through `splitPaneWithOneShotStartup`, which is a wrapper receiving a `() => manager.splitPane(...)` thunk. Widening the `splitPane` opts is backward-compatible; the wrapper is unaffected because the new `spawnHints` is a parameter of the `onPaneCreated` *callback*, not of `splitPane` itself.
|
||||
|
||||
`pendingSpawnByPaneKey` (`pty-connection.ts:21`) keys by pane key; each new split gets a fresh pane id, so no collision between the inherited-cwd path and existing dedup.
|
||||
|
||||
### 4. Preload / main bridge for `pty.getCwd`
|
||||
|
||||
`SshPtyProvider.getCwd` already exists (`ssh-pty-provider.ts:132`) and delegates to the relay which calls `resolveProcessCwd(pid, initialCwd)` via `/proc/<pid>/cwd` or `lsof`. Changes needed:
|
||||
|
||||
- **`LocalPtyProvider.getCwd` (`src/main/providers/local-pty-provider.ts:388`) currently `throw`s when the id is unknown and returns `''` otherwise.** Change: return `''` on unknown id (do not throw), and on a known id call `resolveProcessCwd(proc.pid, '')` where `proc = ptyProcesses.get(id)`. Pass `''` as the fallback — **not** the pane's initial cwd — because the renderer uses empty-string to mean "no result, try the next fallback layer"; returning the initial cwd would make the renderer think it had an answer and skip its own fallback chain. Do **not** throw on unknown id: the caller treats that case as non-exceptional.
|
||||
- **Code sharing for `resolveProcessCwd`.** The helper currently lives in `src/relay/pty-shell-utils.ts` and is bundled into the relay binary. The Electron main process and the relay have separate build graphs; having main import from `src/relay/` (or vice-versa) is not a pattern this repo uses. Rather than force a `src/shared/` split (and touch the relay build), keep the relay's copy in place and add a tiny duplicate in `src/main/providers/process-cwd.ts` for `LocalPtyProvider`. The function is ~30 lines and has no shared state; the cost of duplication is lower than the cost of reshaping two bundle graphs.
|
||||
- **IPC handler:** add `ipcMain.handle('pty:getCwd', (_e, {id}) => getProviderForPty(id).getCwd(id))` in `src/main/ipc/pty.ts`, and add `ipcMain.removeHandler('pty:getCwd')` to the removeHandler block at `pty.ts:325-331` (next to the existing `pty:hasChildProcesses` / `pty:getForegroundProcess` entries).
|
||||
- **Preload:** add `pty.getCwd(id: string): Promise<string>` in `src/preload/index.ts` alongside `hasChildProcesses`/`getForegroundProcess`, and mirror in `src/preload/api-types.ts`. Returns `''` when unknown.
|
||||
|
||||
### 5. Edge cases
|
||||
|
||||
- **Agent TUIs (Claude Code, Codex, cursor-agent).** No OSC-7 because no shell prompt. `paneCwdRef` stays empty; `pty.getCwd` via `/proc/<pid>/cwd` still resolves against the running agent process.
|
||||
- **Just-split panes (no OSC-7 yet).** A fresh pane before its first prompt has no entry. `pty.getCwd` fallback handles it. Double-Cmd+D inherits from the still-fresh shell's spawn CWD — correct.
|
||||
- **SSH reconnect / cold-restore.** OSC-7 entries are keyed by ephemeral `paneId`. Replayed OSC-7 in the scrollback re-feeds the handler via `terminal.write` (`replay-guard.ts:47`) and repopulates the map with `confirmed: false`. The first live OSC-7 after the first prompt upgrades it to `confirmed: true`. If the user hits Cmd+D before the first live prompt, `resolveSplitCwd` takes the `pty.getCwd` path (which queries `/proc/<pid>/cwd` on the live shell) and only falls back to the replayed entry if IPC yields nothing.
|
||||
- **Non-emitting shells.** Minimal `sh` without OSC-7 → `pty.getCwd` fallback carries it. Explicit reason to keep the two-layer strategy.
|
||||
- **Windows.** `/proc` is absent and `lsof` isn't native. On Windows, `LocalPtyProvider.getCwd` must return `''` (fall through to worktree root) and must **not** throw — after the §4 change, the existing `throw`-on-unknown-id is replaced with `''`, and the Windows branch simply returns `''` unconditionally. Not a regression over today's behavior. OSC-7 from PowerShell/pwsh still works because parsing is renderer-side and platform-agnostic.
|
||||
- **Expanded-pane mode.** `keyboard-handlers.ts:286` exits expanded mode before splitting. CWD lookup runs on the currently-active pane before that exit, which is the correct source.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- Unit: `parseOsc7` — BEL vs ST termination, percent-decoded spaces, empty host, Windows drive-letter URIs (`file:///C:/Users/...`). On Windows, `parseOsc7` must return a string that `node-pty` accepts as `cwd` on `spawn` (backslash- or forward-slash-separated, with drive letter, no leading `/`). Normalize inside `parseOsc7` via `path.win32.normalize` when `process.platform === 'win32'` and assert the test output equals what `spawn`'s `cwd` option tolerates — don't leave the choice implicit.
|
||||
- Unit: `resolveSplitCwd` priority — OSC-7 cache hit skips IPC; cache miss queries IPC; empty IPC result falls through to `deps.cwd`; IPC timeout falls through to `deps.cwd`.
|
||||
- Unit: `LocalPtyProvider.getCwd` returns `''` (not throws) for unknown ids.
|
||||
- Integration (`tests/e2e/terminal-panes.spec.ts`): `cd /tmp && emit OSC-7; Cmd+D → new pane pwd === /tmp`.
|
||||
- No SSH e2e suite exists in `tests/e2e/` today; skip.
|
||||
|
||||
### 7. Rollout / compatibility
|
||||
|
||||
- No migration or settings surface.
|
||||
- Safe default: if OSC-7 and `pty.getCwd` both fail, fall back to worktree root. Users never end up worse off.
|
||||
- Ships without a flag.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Persisting per-pane CWD across app restarts. Each session starts fresh; OSC-7 re-arrives on first prompt.
|
||||
- **New-tab-from-pane inheritance (Cmd+T).** Deliberately deferred — users will ask why Cmd+D inherits and Cmd+T does not, but new-tab also implies worktree choice and startup payload semantics that are not in scope here. Reuse `paneCwdRef` + `resolveSplitCwd` when the new-tab flow is revisited.
|
||||
- Using tracked CWD for status-bar display or any other non-split consumer. Separate features that can reuse `paneCwdRef` later.
|
||||
- CWD for context-menu entries that open external tools (file manager, editor). Those already have their own resolution paths.
|
||||
@@ -1,408 +0,0 @@
|
||||
# Terminal Search Next/Previous Shortcuts — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add `Cmd+G` / `Cmd+Shift+G` shortcuts to navigate terminal search matches when the search bar is open.
|
||||
|
||||
**Architecture:** A `searchStateRef` bridges the search query/options from `TerminalSearch` (which owns the state) to `keyboard-handlers.ts` (which handles the shortcut). The `Cmd+G` handler is placed before the `isEditableTarget` guard so it works even when focus is in the search input.
|
||||
|
||||
**Tech Stack:** React, TypeScript, xterm.js (`@xterm/addon-search`), Vitest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|------|--------|---------------|
|
||||
| `src/renderer/src/components/terminal-pane/keyboard-handlers.ts` | Modify | Add `Cmd+G` / `Cmd+Shift+G` handler |
|
||||
| `src/renderer/src/components/TerminalSearch.tsx` | Modify | Accept and sync `searchStateRef` |
|
||||
| `src/renderer/src/components/terminal-pane/TerminalPane.tsx` | Modify | Create and wire `searchStateRef` |
|
||||
| `src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts` | Create | Test the new shortcut handler logic |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Extract and test the `Cmd+G` / `Cmd+Shift+G` key-matching logic
|
||||
|
||||
The keyboard handler uses a capture-phase `window` listener with DOM dependencies (`e.target`, `pane.terminal.focus()`). Rather than mocking all of that, we test the **decision logic** in isolation: given a key event shape + search state, should the handler fire findNext, findPrevious, or do nothing?
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write failing tests for the search-navigate decision logic**
|
||||
|
||||
We'll test a pure helper function `matchSearchNavigate` that we'll extract in Task 2. For now, write the tests against the expected interface.
|
||||
|
||||
```ts
|
||||
// src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { matchSearchNavigate } from './keyboard-handlers'
|
||||
|
||||
function makeKeyEvent(overrides: Partial<{
|
||||
key: string
|
||||
metaKey: boolean
|
||||
ctrlKey: boolean
|
||||
shiftKey: boolean
|
||||
altKey: boolean
|
||||
}>): Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'> {
|
||||
return {
|
||||
key: 'g',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('matchSearchNavigate', () => {
|
||||
const isMac = true
|
||||
const searchState = { query: 'hello', caseSensitive: false, regex: false }
|
||||
|
||||
it('returns "next" for Cmd+G on macOS', () => {
|
||||
const e = makeKeyEvent({ metaKey: true })
|
||||
expect(matchSearchNavigate(e, isMac, true, searchState)).toBe('next')
|
||||
})
|
||||
|
||||
it('returns "previous" for Cmd+Shift+G on macOS', () => {
|
||||
const e = makeKeyEvent({ metaKey: true, shiftKey: true })
|
||||
expect(matchSearchNavigate(e, isMac, true, searchState)).toBe('previous')
|
||||
})
|
||||
|
||||
it('returns null when search is closed', () => {
|
||||
const e = makeKeyEvent({ metaKey: true })
|
||||
expect(matchSearchNavigate(e, isMac, false, searchState)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when query is empty', () => {
|
||||
const e = makeKeyEvent({ metaKey: true })
|
||||
expect(matchSearchNavigate(e, isMac, true, { query: '', caseSensitive: false, regex: false })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for wrong key', () => {
|
||||
const e = makeKeyEvent({ metaKey: true, key: 'f' })
|
||||
expect(matchSearchNavigate(e, isMac, true, searchState)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when alt is pressed', () => {
|
||||
const e = makeKeyEvent({ metaKey: true, altKey: true })
|
||||
expect(matchSearchNavigate(e, isMac, true, searchState)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns "next" for Ctrl+G on Linux/Windows', () => {
|
||||
const e = makeKeyEvent({ ctrlKey: true })
|
||||
expect(matchSearchNavigate(e, false, true, searchState)).toBe('next')
|
||||
})
|
||||
|
||||
it('returns null for Ctrl+G on macOS (wrong modifier)', () => {
|
||||
const e = makeKeyEvent({ ctrlKey: true })
|
||||
expect(matchSearchNavigate(e, true, true, searchState)).toBeNull()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm test -- src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
|
||||
Expected: FAIL — `matchSearchNavigate` is not exported from `keyboard-handlers`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
|
||||
git commit -m "test: add failing tests for search-navigate key matching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Implement `matchSearchNavigate` and wire it into the keyboard handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/src/components/terminal-pane/keyboard-handlers.ts`
|
||||
|
||||
- [ ] **Step 1: Add the `SearchState` type and `matchSearchNavigate` function**
|
||||
|
||||
Add this above the `useTerminalKeyboardShortcuts` function:
|
||||
|
||||
```ts
|
||||
export type SearchState = {
|
||||
query: string
|
||||
caseSensitive: boolean
|
||||
regex: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function for Cmd+G / Cmd+Shift+G search navigation.
|
||||
* Returns 'next', 'previous', or null (no match).
|
||||
* Extracted so the key-matching logic is testable without DOM dependencies.
|
||||
*/
|
||||
export function matchSearchNavigate(
|
||||
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
|
||||
isMac: boolean,
|
||||
searchOpen: boolean,
|
||||
searchState: SearchState
|
||||
): 'next' | 'previous' | null {
|
||||
if (e.altKey) return null
|
||||
const mod = isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey
|
||||
if (!mod) return null
|
||||
if (e.key.toLowerCase() !== 'g') return null
|
||||
if (!searchOpen) return null
|
||||
if (!searchState.query) return null
|
||||
return e.shiftKey ? 'previous' : 'next'
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `searchOpen` and `searchStateRef` to the deps type**
|
||||
|
||||
Update the `KeyboardHandlersDeps` type — add two new fields:
|
||||
|
||||
```ts
|
||||
type KeyboardHandlersDeps = {
|
||||
isActive: boolean
|
||||
managerRef: React.RefObject<PaneManager | null>
|
||||
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
|
||||
expandedPaneIdRef: React.RefObject<number | null>
|
||||
setExpandedPane: (paneId: number | null) => void
|
||||
restoreExpandedLayout: () => void
|
||||
refreshPaneSizes: (focusActive: boolean) => void
|
||||
persistLayoutSnapshot: () => void
|
||||
toggleExpandPane: (paneId: number) => void
|
||||
setSearchOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
onRequestClosePane: (paneId: number) => void
|
||||
searchOpen: boolean
|
||||
searchStateRef: React.RefObject<SearchState>
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the `Cmd+G` handler inside `onKeyDown`, before the `isEditableTarget` guard**
|
||||
|
||||
Insert this block in `onKeyDown` right after the `if (e.repeat) return` check (line 62) and before `if (isEditableTarget(e.target))` (line 64):
|
||||
|
||||
```ts
|
||||
// Cmd+G / Cmd+Shift+G navigates terminal search matches.
|
||||
// Placed before the isEditableTarget guard so it works when focus
|
||||
// is in the search input. Uses its own mod-key check because this
|
||||
// runs before the shared `mod` variable is declared.
|
||||
// preventDefault suppresses macOS/Electron's native "find next".
|
||||
const direction = matchSearchNavigate(e, isMac, searchOpen, searchStateRef.current)
|
||||
if (direction !== null) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!pane) return
|
||||
const { query, caseSensitive, regex } = searchStateRef.current
|
||||
if (direction === 'next') {
|
||||
pane.searchAddon.findNext(query, { caseSensitive, regex })
|
||||
} else {
|
||||
pane.searchAddon.findPrevious(query, { caseSensitive, regex })
|
||||
}
|
||||
pane.terminal.focus()
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
Note: this block accesses `manager` which is declared further down. Move the `const manager = managerRef.current; if (!manager) return` check above the `isEditableTarget` guard as well, so the search handler can reference it. This reorder is safe — the code between the old `manager` position and `isEditableTarget` only computes `mod` and checks `altKey`, neither of which references `manager`. The minor behavioral change is that `!manager` now short-circuits before `isEditableTarget` runs, which is harmless (no manager means no terminal to handle shortcuts for). The full reordering inside `onKeyDown` becomes:
|
||||
|
||||
```
|
||||
if (e.repeat) return
|
||||
const manager = managerRef.current // ← moved up from line 72
|
||||
if (!manager) return // ← moved up from line 73
|
||||
// Cmd+G / Cmd+Shift+G handler (new)
|
||||
if (isEditableTarget(e.target)) return
|
||||
const mod = ...
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `searchOpen` and `searchStateRef` to the destructuring and useEffect deps array**
|
||||
|
||||
Update the function signature destructuring to include `searchOpen` and `searchStateRef`. Add both to the `useEffect` dependency array (alongside the existing entries):
|
||||
|
||||
```ts
|
||||
export function useTerminalKeyboardShortcuts({
|
||||
isActive,
|
||||
managerRef,
|
||||
paneTransportsRef,
|
||||
expandedPaneIdRef,
|
||||
setExpandedPane,
|
||||
restoreExpandedLayout,
|
||||
refreshPaneSizes,
|
||||
persistLayoutSnapshot,
|
||||
toggleExpandPane,
|
||||
setSearchOpen,
|
||||
onRequestClosePane,
|
||||
searchOpen,
|
||||
searchStateRef
|
||||
}: KeyboardHandlersDeps): void {
|
||||
useEffect(() => {
|
||||
// ...
|
||||
}, [
|
||||
isActive,
|
||||
managerRef,
|
||||
paneTransportsRef,
|
||||
expandedPaneIdRef,
|
||||
setExpandedPane,
|
||||
restoreExpandedLayout,
|
||||
refreshPaneSizes,
|
||||
persistLayoutSnapshot,
|
||||
toggleExpandPane,
|
||||
setSearchOpen,
|
||||
onRequestClosePane,
|
||||
searchOpen,
|
||||
searchStateRef
|
||||
])
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm test -- src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts`
|
||||
Expected: All 8 tests PASS
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/terminal-pane/keyboard-handlers.ts src/renderer/src/components/terminal-pane/keyboard-handlers.test.ts
|
||||
git commit -m "feat: add Cmd+G / Cmd+Shift+G search navigation to keyboard handler"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire `searchStateRef` through `TerminalPane`, `TerminalSearch`, and sync it
|
||||
|
||||
Tasks 3 modifies both `TerminalPane.tsx` and `TerminalSearch.tsx` together because `searchStateRef` is a required prop — modifying them separately would break TypeScript compilation between commits.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/src/components/terminal-pane/TerminalPane.tsx`
|
||||
- Modify: `src/renderer/src/components/TerminalSearch.tsx`
|
||||
|
||||
- [ ] **Step 1: Import `SearchState` and create the ref in `TerminalPane`**
|
||||
|
||||
Add the import at the top of `TerminalPane.tsx` alongside the existing `keyboard-handlers` import:
|
||||
|
||||
```ts
|
||||
import { useTerminalKeyboardShortcuts } from './keyboard-handlers'
|
||||
```
|
||||
|
||||
becomes:
|
||||
|
||||
```ts
|
||||
import { useTerminalKeyboardShortcuts, type SearchState } from './keyboard-handlers'
|
||||
```
|
||||
|
||||
Then add the ref near the other refs (after `const [searchOpen, setSearchOpen] = useState(false)` on line 65):
|
||||
|
||||
```ts
|
||||
const searchStateRef = useRef<SearchState>({ query: '', caseSensitive: false, regex: false })
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Pass `searchOpen` and `searchStateRef` to `useTerminalKeyboardShortcuts`**
|
||||
|
||||
Update the call (around line 263) to include the two new fields:
|
||||
|
||||
```ts
|
||||
useTerminalKeyboardShortcuts({
|
||||
isActive,
|
||||
managerRef,
|
||||
paneTransportsRef,
|
||||
expandedPaneIdRef,
|
||||
setExpandedPane,
|
||||
restoreExpandedLayout,
|
||||
refreshPaneSizes,
|
||||
persistLayoutSnapshot,
|
||||
toggleExpandPane,
|
||||
setSearchOpen,
|
||||
onRequestClosePane: handleRequestClosePane,
|
||||
searchOpen,
|
||||
searchStateRef
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Pass `searchStateRef` to `TerminalSearch`**
|
||||
|
||||
Update the `TerminalSearch` JSX (around line 567) to include the new prop:
|
||||
|
||||
```tsx
|
||||
<TerminalSearch
|
||||
isOpen={searchOpen}
|
||||
onClose={() => setSearchOpen(false)}
|
||||
searchAddon={activePane.searchAddon ?? null}
|
||||
searchStateRef={searchStateRef}
|
||||
/>,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `searchStateRef` prop to `TerminalSearch`**
|
||||
|
||||
In `TerminalSearch.tsx`, import the `SearchState` type from `keyboard-handlers` and use it in the props type. This keeps the type definition in one place so it can't silently diverge:
|
||||
|
||||
```ts
|
||||
import type { SearchState } from '@/components/terminal-pane/keyboard-handlers'
|
||||
```
|
||||
|
||||
Then update the props type and destructuring:
|
||||
|
||||
```ts
|
||||
type TerminalSearchProps = {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
searchAddon: SearchAddon | null
|
||||
searchStateRef: React.MutableRefObject<SearchState>
|
||||
}
|
||||
|
||||
export default function TerminalSearch({
|
||||
isOpen,
|
||||
onClose,
|
||||
searchAddon,
|
||||
searchStateRef
|
||||
}: TerminalSearchProps): React.JSX.Element | null {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Sync the ref inside the existing incremental-search `useEffect`**
|
||||
|
||||
The existing `useEffect` (lines 47–55) already runs whenever `query`, `caseSensitive`, or `regex` change. Add the ref sync at the top, before the early return on empty query. This ensures the ref stays in sync even when the query is cleared (so it reflects the true current state):
|
||||
|
||||
```ts
|
||||
useEffect(() => {
|
||||
// Keep the ref in sync so the keyboard handler (Cmd+G / Cmd+Shift+G)
|
||||
// can read the current search state without lifting it to parent state.
|
||||
searchStateRef.current = { query, caseSensitive, regex }
|
||||
|
||||
if (!query) {
|
||||
searchAddon?.clearDecorations()
|
||||
return
|
||||
}
|
||||
if (searchAddon && isOpen) {
|
||||
searchAddon.findNext(query, { caseSensitive, regex, incremental: true })
|
||||
}
|
||||
}, [query, searchAddon, isOpen, caseSensitive, regex, searchStateRef])
|
||||
```
|
||||
|
||||
Note: `searchStateRef` is added to the dependency array to satisfy the linter, though as a ref it never changes identity.
|
||||
|
||||
- [ ] **Step 6: Run the full test suite to verify nothing is broken**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All tests pass (no regressions)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/terminal-pane/TerminalPane.tsx src/renderer/src/components/TerminalSearch.tsx
|
||||
git commit -m "feat: wire searchStateRef through TerminalPane and TerminalSearch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: TypeScript build verification
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the TypeScript compiler to check for type errors**
|
||||
|
||||
Run: `pnpm run typecheck` (or the equivalent — check `package.json` scripts)
|
||||
If no `typecheck` script exists, run: `npx tsc --noEmit`
|
||||
Expected: No type errors
|
||||
|
||||
- [ ] **Step 2: Run the full test suite one final time**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All tests pass
|
||||
|
||||
- [ ] **Step 3: Commit if any fixes were needed, otherwise skip**
|
||||
@@ -1,360 +0,0 @@
|
||||
# Mermaid File Viewer Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Open `.mmd` / `.mermaid` files and render them as live, themed diagrams with a source/diagram toggle in the editor header.
|
||||
|
||||
**Architecture:** Orca already has a `MermaidBlock` component that renders mermaid syntax to SVG, and a `MarkdownViewToggle` that switches between source and rich modes. This plan registers `.mmd`/`.mermaid` as a new language (`'mermaid'`), adds a `MermaidViewer` component that wraps `MermaidBlock` for full-file rendering with scroll caching and centering, and wires it into the existing `EditorPanel` / `EditorContent` routing alongside the markdown path. The view mode store (`markdownViewMode`) is reused since it is already keyed by file ID.
|
||||
|
||||
**Tech Stack:** React, Zustand (existing store), mermaid (already installed v11.14), DOMPurify (already installed), Monaco (source mode), existing `MermaidBlock` component.
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|--------|------|---------------|
|
||||
| Modify | `src/renderer/src/lib/language-detect.ts` | Register `.mmd` and `.mermaid` extensions |
|
||||
| Create | `src/renderer/src/components/editor/MermaidViewer.tsx` | Full-file mermaid diagram viewer with scroll caching |
|
||||
| Modify | `src/renderer/src/components/editor/EditorPanel.tsx` | Add `isMermaid` flag, show view toggle for mermaid files |
|
||||
| Modify | `src/renderer/src/components/editor/EditorContent.tsx` | Add `isMermaid` prop, route to `MermaidViewer` or Monaco |
|
||||
| Modify | `src/renderer/src/assets/markdown-preview.css` | Add `.mermaid-viewer` styles |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Register `.mmd` and `.mermaid` file extensions
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/src/lib/language-detect.ts:10-76` (add two entries to `EXT_TO_LANGUAGE`)
|
||||
|
||||
- [ ] **Step 1: Add mermaid extensions to the language map**
|
||||
|
||||
In `src/renderer/src/lib/language-detect.ts`, add these two entries to the `EXT_TO_LANGUAGE` object (between `.mdx` and `.css`):
|
||||
|
||||
```typescript
|
||||
'.mmd': 'mermaid',
|
||||
'.mermaid': 'mermaid',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the change compiles**
|
||||
|
||||
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
|
||||
Expected: No errors related to `language-detect.ts`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/lib/language-detect.ts
|
||||
git commit -m "feat: register .mmd and .mermaid file extensions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `MermaidViewer` component
|
||||
|
||||
**Files:**
|
||||
- Create: `src/renderer/src/components/editor/MermaidViewer.tsx`
|
||||
|
||||
This component renders the entire file content as a mermaid diagram. It reuses the existing `MermaidBlock` for rendering and follows the same dark-mode detection and scroll-caching patterns as `MarkdownPreview`.
|
||||
|
||||
- [ ] **Step 1: Create the `MermaidViewer` component**
|
||||
|
||||
Create `src/renderer/src/components/editor/MermaidViewer.tsx`:
|
||||
|
||||
```tsx
|
||||
import React, { useLayoutEffect, useRef } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { scrollTopCache, setWithLRU } from '@/lib/scroll-cache'
|
||||
import MermaidBlock from './MermaidBlock'
|
||||
|
||||
type MermaidViewerProps = {
|
||||
content: string
|
||||
filePath: string
|
||||
}
|
||||
|
||||
// Why: MermaidViewer is the full-file counterpart to MermaidBlock (which
|
||||
// renders fenced mermaid blocks inside markdown). When a user opens a .mmd
|
||||
// or .mermaid file in diagram mode, the entire file content is the diagram
|
||||
// source — no markdown wrapper, no frontmatter, just mermaid syntax.
|
||||
export default function MermaidViewer({
|
||||
content,
|
||||
filePath
|
||||
}: MermaidViewerProps): React.JSX.Element {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const isDark =
|
||||
settings?.theme === 'dark' ||
|
||||
(settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
// Why: Each viewing mode (source vs diagram) produces different DOM heights.
|
||||
// Mode-scoped keys prevent restoring a source-mode scroll position in diagram
|
||||
// mode (same reasoning as MarkdownPreview's scrollCacheKey).
|
||||
const scrollCacheKey = `${filePath}:mermaid-diagram`
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = rootRef.current
|
||||
if (!container) {
|
||||
return
|
||||
}
|
||||
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
throttleTimer = setTimeout(() => {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
throttleTimer = null
|
||||
}, 150)
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => {
|
||||
setWithLRU(scrollTopCache, scrollCacheKey, container.scrollTop)
|
||||
if (throttleTimer !== null) {
|
||||
clearTimeout(throttleTimer)
|
||||
}
|
||||
container.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [scrollCacheKey])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = rootRef.current
|
||||
const targetScrollTop = scrollTopCache.get(scrollCacheKey)
|
||||
if (!container || targetScrollTop === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
let attempts = 0
|
||||
|
||||
// Why: mermaid.render() is async, so the SVG may not exist on the first
|
||||
// frame. Retry up to 30 frames (~500ms) to match MarkdownPreview's pattern.
|
||||
const tryRestore = (): void => {
|
||||
const maxScrollTop = Math.max(0, container.scrollHeight - container.clientHeight)
|
||||
const nextScrollTop = Math.min(targetScrollTop, maxScrollTop)
|
||||
container.scrollTop = nextScrollTop
|
||||
|
||||
if (Math.abs(container.scrollTop - targetScrollTop) <= 1 || maxScrollTop >= targetScrollTop) {
|
||||
return
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 30) {
|
||||
frameId = window.requestAnimationFrame(tryRestore)
|
||||
}
|
||||
}
|
||||
|
||||
tryRestore()
|
||||
return () => window.cancelAnimationFrame(frameId)
|
||||
}, [scrollCacheKey, content])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="mermaid-viewer h-full min-h-0 overflow-auto scrollbar-editor"
|
||||
>
|
||||
<div className="mermaid-viewer-canvas">
|
||||
<MermaidBlock content={content.trim()} isDark={isDark} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify it compiles**
|
||||
|
||||
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/editor/MermaidViewer.tsx
|
||||
git commit -m "feat: add MermaidViewer component for .mmd file rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add `.mermaid-viewer` styles
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/src/assets/markdown-preview.css` (append after existing `.mermaid-error` block, around line 242)
|
||||
|
||||
- [ ] **Step 1: Add viewer styles**
|
||||
|
||||
Append after the `.mermaid-error` rule block (line ~242) in `markdown-preview.css`:
|
||||
|
||||
```css
|
||||
/* Full-file mermaid diagram viewer — used when opening .mmd/.mermaid files
|
||||
in diagram mode. Centers the rendered SVG and adds padding so diagrams
|
||||
don't press against viewport edges. */
|
||||
.mermaid-viewer {
|
||||
background: var(--color-background, #fff);
|
||||
}
|
||||
.mermaid-viewer-canvas {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.mermaid-viewer-canvas .mermaid-block {
|
||||
max-width: 100%;
|
||||
}
|
||||
.mermaid-viewer-canvas .mermaid-block svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/assets/markdown-preview.css
|
||||
git commit -m "feat: add mermaid-viewer CSS for full-file diagram display"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Wire mermaid files into `EditorPanel` and `EditorContent`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/src/components/editor/EditorPanel.tsx:427-519`
|
||||
- Modify: `src/renderer/src/components/editor/EditorContent.tsx:1-67,262-270`
|
||||
|
||||
This task threads the `isMermaid` flag through the same path as `isMarkdown`, reusing the existing `markdownViewMode` store (keyed by file ID, so no collision) and `MarkdownViewToggle` (icon-only, works for both).
|
||||
|
||||
- [ ] **Step 1: Update `EditorPanel.tsx`**
|
||||
|
||||
**Change 1** — add `isMermaid` flag and widen the view-mode condition (around line 427):
|
||||
|
||||
Replace:
|
||||
```typescript
|
||||
const isMarkdown = resolvedLanguage === 'markdown'
|
||||
const mdViewMode: MarkdownViewMode =
|
||||
isMarkdown && activeFile.mode === 'edit'
|
||||
? (markdownViewMode[activeFile.id] ?? 'rich')
|
||||
: 'source'
|
||||
```
|
||||
|
||||
With:
|
||||
```typescript
|
||||
const isMarkdown = resolvedLanguage === 'markdown'
|
||||
const isMermaid = resolvedLanguage === 'mermaid'
|
||||
// Why: mermaid files reuse the same per-file view mode store as markdown.
|
||||
// Both default to 'rich' (rendered view) and fall back to 'source' (Monaco).
|
||||
const hasViewModeToggle = (isMarkdown || isMermaid) && activeFile.mode === 'edit'
|
||||
const mdViewMode: MarkdownViewMode = hasViewModeToggle
|
||||
? (markdownViewMode[activeFile.id] ?? 'rich')
|
||||
: 'source'
|
||||
```
|
||||
|
||||
**Change 2** — update the toggle condition in the header (around line 515):
|
||||
|
||||
Replace:
|
||||
```tsx
|
||||
{isMarkdown && activeFile.mode === 'edit' && (
|
||||
<MarkdownViewToggle
|
||||
```
|
||||
|
||||
With:
|
||||
```tsx
|
||||
{hasViewModeToggle && (
|
||||
<MarkdownViewToggle
|
||||
```
|
||||
|
||||
**Change 3** — pass `isMermaid` to `EditorContent` (around line 531):
|
||||
|
||||
Add `isMermaid={isMermaid}` after the `isMarkdown` prop:
|
||||
|
||||
```tsx
|
||||
isMarkdown={isMarkdown}
|
||||
isMermaid={isMermaid}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `EditorContent.tsx`**
|
||||
|
||||
**Change 1** — add lazy import for `MermaidViewer` (after the `ImageDiffViewer` lazy import, around line 18):
|
||||
|
||||
```typescript
|
||||
const MermaidViewer = lazy(() => import('./MermaidViewer'))
|
||||
```
|
||||
|
||||
**Change 2** — add `isMermaid` to the component props type and destructuring (around line 34-67):
|
||||
|
||||
Add `isMermaid: boolean` to the props type (after `isMarkdown: boolean`), and add `isMermaid` to the destructuring.
|
||||
|
||||
**Change 3** — add mermaid routing in the edit-mode branch (around line 266):
|
||||
|
||||
Replace:
|
||||
```tsx
|
||||
{isMarkdown ? renderMarkdownContent(fc) : renderMonacoEditor(fc)}
|
||||
```
|
||||
|
||||
With:
|
||||
```tsx
|
||||
{isMarkdown
|
||||
? renderMarkdownContent(fc)
|
||||
: isMermaid && mdViewMode === 'rich'
|
||||
? <MermaidViewer
|
||||
key={activeFile.id}
|
||||
content={editBuffers[activeFile.id] ?? fc.content}
|
||||
filePath={activeFile.filePath}
|
||||
/>
|
||||
: renderMonacoEditor(fc)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify everything compiles**
|
||||
|
||||
Run: `cd src/renderer && npx tsc --noEmit --pretty 2>&1 | head -20`
|
||||
Expected: No errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/src/components/editor/EditorPanel.tsx src/renderer/src/components/editor/EditorContent.tsx
|
||||
git commit -m "feat: wire mermaid file viewer into editor panel routing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Manual verification
|
||||
|
||||
- [ ] **Step 1: Create a test `.mmd` file in any worktree**
|
||||
|
||||
Place a file like `test-diagram.mmd` in a worktree directory with content:
|
||||
|
||||
```
|
||||
graph TD
|
||||
A[Open .mmd file] --> B{Rendered?}
|
||||
B -->|Yes| C[Diagram view]
|
||||
B -->|No| D[Source view]
|
||||
C --> E[Toggle to source]
|
||||
D --> F[Toggle to diagram]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Open the file in Orca and verify**
|
||||
|
||||
Check:
|
||||
1. File opens in diagram mode by default (rendered SVG, centered)
|
||||
2. Source/diagram toggle appears in the editor header
|
||||
3. Clicking the Code icon switches to Monaco with mermaid syntax
|
||||
4. Clicking the Eye icon switches back to the rendered diagram
|
||||
5. Dark mode: diagram respects the current theme
|
||||
6. Large diagrams scroll properly
|
||||
7. Scroll position is preserved when switching tabs and back
|
||||
|
||||
- [ ] **Step 3: Verify no regressions**
|
||||
|
||||
Check:
|
||||
1. Opening a `.md` file still works with source/rich toggle
|
||||
2. Mermaid fenced blocks inside `.md` files still render
|
||||
3. Non-markdown, non-mermaid files open normally in Monaco
|
||||
|
||||
- [ ] **Step 4: Clean up the test file and commit everything**
|
||||
|
||||
```bash
|
||||
rm test-diagram.mmd
|
||||
```
|
||||
@@ -1,51 +0,0 @@
|
||||
# Terminal Search Next/Previous Shortcuts
|
||||
|
||||
## Summary
|
||||
|
||||
Add `Cmd+G` (find next) and `Cmd+Shift+G` (find previous) keyboard shortcuts to navigate terminal search matches. These follow macOS native conventions and only activate when the search bar is already open.
|
||||
|
||||
## Requirements
|
||||
|
||||
- `Cmd+G` calls `findNext` on the active pane's `SearchAddon`
|
||||
- `Cmd+Shift+G` calls `findPrevious` on the active pane's `SearchAddon`
|
||||
- Shortcuts are no-ops when the search bar is closed
|
||||
- After navigating, focus moves to the terminal (not the search input)
|
||||
- Shortcuts are not documented in ShortcutsPane (consistent with `Cmd+F`)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data bridge: `searchStateRef`
|
||||
|
||||
The search query and options (`caseSensitive`, `regex`) live as local state in `TerminalSearch.tsx`. The keyboard handler in `keyboard-handlers.ts` needs read access to call `searchAddon.findNext(query, opts)`.
|
||||
|
||||
A `MutableRefObject<{ query: string; caseSensitive: boolean; regex: boolean }>` is created in `TerminalPane` and passed to both components. `TerminalSearch` writes to it on state changes; the keyboard handler reads from it on `Cmd+G` / `Cmd+Shift+G`. This follows the existing ref-bridge pattern used throughout `TerminalPane` (e.g., `paneTitlesRef`, `isActiveRef`, `settingsRef`).
|
||||
|
||||
### Changes by file
|
||||
|
||||
**`TerminalPane.tsx`**
|
||||
- Create `searchStateRef = useRef({ query: '', caseSensitive: false, regex: false })`
|
||||
- Pass `searchStateRef` to `TerminalSearch` as a new prop
|
||||
- Pass `searchOpen` and `searchStateRef` to `useTerminalKeyboardShortcuts`
|
||||
|
||||
**`TerminalSearch.tsx`**
|
||||
- Accept `searchStateRef: React.MutableRefObject<{ query: string; caseSensitive: boolean; regex: boolean }>` prop
|
||||
- Sync the ref whenever `query`, `caseSensitive`, or `regex` changes — inside the existing `useEffect` that already depends on `[query, caseSensitive, regex]`, so all three values are kept in sync together. Note: the existing effect has an early return when query is empty (`clearDecorations`), so the ref won't update on clear — this is benign because the keyboard handler already guards on non-empty query
|
||||
|
||||
**`keyboard-handlers.ts`**
|
||||
- Add `searchOpen` (boolean) and `searchStateRef` to `KeyboardHandlersDeps`
|
||||
- Exempt `[data-terminal-search-root]` descendants from the `isEditableTarget` early return for `Cmd+G` / `Cmd+Shift+G`. Without this, pressing the shortcut while the search input has focus would be silently swallowed. The paste handler in `TerminalPane` already uses this same `data-terminal-search-root` exemption pattern.
|
||||
- Add handler for `Cmd+G` / `Cmd+Shift+G` inside `onKeyDown`, placed before the `isEditableTarget` guard. Because this runs before the `const mod = ...` declaration, the handler must perform its own mod-key check (`isMac ? e.metaKey && !e.ctrlKey : e.ctrlKey && !e.metaKey`) and key match (`e.key.toLowerCase() === 'g'`) inline:
|
||||
- Guard: mod key active, key is `g`, `searchOpen` is true, `searchStateRef.current.query` is non-empty
|
||||
- Read `query`, `caseSensitive`, `regex` from `searchStateRef.current`
|
||||
- Get active pane's `searchAddon` via `manager.getActivePane().searchAddon`
|
||||
- Call `findNext` or `findPrevious` with `{ caseSensitive, regex }` (no `incremental` — matches the chevron button behavior, not the live-typing behavior)
|
||||
- Call `pane.terminal.focus()` to return focus to the terminal
|
||||
- `preventDefault` + `stopPropagation` — important to suppress macOS/Electron's native "find next" which could otherwise trigger the built-in find bar
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **No query**: no-op (empty string guard)
|
||||
- **No active pane**: no-op (existing pane guard)
|
||||
- **Search closed**: no-op (`searchOpen` guard)
|
||||
- **Key repeat**: filtered by existing `if (e.repeat) return` at top of `onKeyDown`
|
||||
- **Focus in search input**: `Cmd+G` / `Cmd+Shift+G` must bypass the `isEditableTarget` guard for search input descendants (see keyboard-handlers.ts changes above)
|
||||
@@ -1,216 +0,0 @@
|
||||
# Tab Group Model Follow-Up
|
||||
|
||||
## Goal
|
||||
|
||||
Move Orca's split tab-group implementation from a store-plus-controller shape to a true model/service boundary that is closer to VS Code's editor-group architecture, without porting VS Code wholesale.
|
||||
|
||||
This follow-up is intentionally **not** part of the current PR. The current PR already moved the feature in the right direction by:
|
||||
|
||||
- making split ratios persisted model state
|
||||
- centralizing move/copy/merge group operations in the tabs store
|
||||
- thinning `TabGroupPanel` into more of a view
|
||||
|
||||
The next step is to make tab-group behavior a first-class model instead of a set of store records plus helper/controller logic.
|
||||
|
||||
## Why
|
||||
|
||||
Split tab groups are no longer just a rendering concern. They now carry:
|
||||
|
||||
- layout structure
|
||||
- persisted split ratios
|
||||
- active group state
|
||||
- cross-group move/copy semantics
|
||||
- close/merge behavior
|
||||
- mixed content types per group
|
||||
|
||||
As this grows, keeping behavior split across Zustand records, controller hooks, and React components will become harder to reason about and easier to regress.
|
||||
|
||||
VS Code handles this by making editor groups a first-class model/service. Orca does not need the full VS Code abstraction surface, but it should adopt the same direction:
|
||||
|
||||
- model owns behavior
|
||||
- views render model state
|
||||
- imperative operations go through one boundary
|
||||
|
||||
## Current Gaps
|
||||
|
||||
Even after the current PR, these gaps remain:
|
||||
|
||||
1. Group activation is still minimal.
|
||||
Orca tracks `activeGroupIdByWorktree`, but not true MRU group ordering or activation reasons.
|
||||
|
||||
2. Group operations are still store-action centric, not model-object centric.
|
||||
The store now owns the mutations, but callers still think in terms of raw IDs and records.
|
||||
|
||||
3. There are no group lifecycle events.
|
||||
React consumers read state snapshots, but there is no explicit event surface for add/remove/move/merge/activate.
|
||||
|
||||
4. Hydration and runtime behavior are still tightly coupled to raw store shape.
|
||||
This makes it harder to evolve the model without touching many callers.
|
||||
|
||||
5. `TabGroupPanel` is thinner, but still knows too much about worktree/group coordination.
|
||||
|
||||
## Target Shape
|
||||
|
||||
Introduce a per-worktree tab-group model/controller layer, for example:
|
||||
|
||||
- `TabGroupWorkspaceModel`
|
||||
- `TabGroupModel`
|
||||
- `TabGroupLayoutModel`
|
||||
|
||||
This layer should:
|
||||
|
||||
- wrap the normalized store state for a single worktree
|
||||
- expose typed operations instead of raw state surgery
|
||||
- centralize MRU group activation
|
||||
- centralize group lifecycle transitions
|
||||
- provide derived read models for rendering
|
||||
|
||||
React components should consume:
|
||||
|
||||
- derived selectors for render state
|
||||
- a small command surface for mutations
|
||||
|
||||
They should not need to understand layout tree mutation details.
|
||||
|
||||
## Proposed Responsibilities
|
||||
|
||||
### `TabGroupWorkspaceModel`
|
||||
|
||||
Owns all tab-group state for one worktree:
|
||||
|
||||
- groups
|
||||
- layout tree
|
||||
- active group
|
||||
- MRU group order
|
||||
|
||||
Exposes commands like:
|
||||
|
||||
- `splitGroup(groupId, direction)`
|
||||
- `closeGroup(groupId)`
|
||||
- `mergeGroup(groupId, targetGroupId?)`
|
||||
- `activateGroup(groupId, reason)`
|
||||
- `moveTab(tabId, targetGroupId, options?)`
|
||||
- `copyTab(tabId, targetGroupId, options?)`
|
||||
- `reorderGroupTabs(groupId, orderedTabIds)`
|
||||
- `resizeSplit(nodePath, ratio)`
|
||||
|
||||
### `TabGroupModel`
|
||||
|
||||
Represents one group and exposes:
|
||||
|
||||
- `id`
|
||||
- `tabs`
|
||||
- `activeTab`
|
||||
- `tabOrder`
|
||||
- `isActive`
|
||||
- `isEmpty`
|
||||
|
||||
This can be a thin wrapper over store state rather than a heavy OO abstraction.
|
||||
|
||||
### `TabGroupLayoutModel`
|
||||
|
||||
Encapsulates layout operations:
|
||||
|
||||
- replace leaf with split
|
||||
- remove leaf and collapse tree
|
||||
- find sibling group
|
||||
- update split ratio
|
||||
- validate layout against live groups
|
||||
|
||||
This logic is currently spread across `tabs.ts` helpers and should move into one focused module.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Extract Pure Model Utilities
|
||||
|
||||
Create a new module for pure tab-group model operations:
|
||||
|
||||
- layout mutation
|
||||
- group merge/collapse rules
|
||||
- MRU group bookkeeping
|
||||
- validation helpers
|
||||
|
||||
This phase should not change runtime behavior.
|
||||
|
||||
### Phase 2: Add Workspace Model Facade
|
||||
|
||||
Introduce a facade over the Zustand store for one worktree:
|
||||
|
||||
- input: `worktreeId`
|
||||
- output: commands + derived state
|
||||
|
||||
This can begin as a hook-backed facade, but the logic should live outside React as much as possible.
|
||||
|
||||
### Phase 3: Move Components To Render-Only
|
||||
|
||||
Reduce `TabGroupPanel` and `TabGroupSplitLayout` to:
|
||||
|
||||
- render derived state
|
||||
- dispatch commands
|
||||
|
||||
They should no longer assemble group/tab mutation behavior themselves.
|
||||
|
||||
### Phase 4: Add MRU Group Semantics
|
||||
|
||||
Track:
|
||||
|
||||
- active group
|
||||
- most recently active group order
|
||||
|
||||
Use this for:
|
||||
|
||||
- close-group merge target selection
|
||||
- focus restoration after group removal
|
||||
- more VS Code-like group activation behavior
|
||||
|
||||
### Phase 5: Hydration Boundary Cleanup
|
||||
|
||||
Move hydration/restore validation through the model layer so layout and groups are repaired in one place.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Porting VS Code's editor-group implementation directly
|
||||
- Replacing Zustand
|
||||
- Introducing a large class hierarchy for its own sake
|
||||
- Refactoring terminal pane internals as part of the same follow-up
|
||||
|
||||
## Risks
|
||||
|
||||
1. Terminal/editor/browser tabs currently share the unified tab model.
|
||||
Refactors must preserve mixed-content behavior across groups.
|
||||
|
||||
2. Hydration and worktree switching depend on current store shape.
|
||||
The migration should preserve persisted session compatibility.
|
||||
|
||||
3. Closing and merging groups can easily regress active-tab restoration.
|
||||
MRU rules need explicit tests.
|
||||
|
||||
## Test Plan For Follow-Up
|
||||
|
||||
Add focused tests around:
|
||||
|
||||
- split + resize + restore
|
||||
- close empty group
|
||||
- close non-empty group merges into MRU/sibling target
|
||||
- move tab between groups
|
||||
- copy tab between groups
|
||||
- active group restoration after merge
|
||||
- hydration repairing invalid layout/group combinations
|
||||
- worktree switch preserving active group and active tab
|
||||
|
||||
## Suggested PR Breakdown
|
||||
|
||||
1. `refactor: extract tab group layout model helpers`
|
||||
2. `refactor: add worktree tab group model facade`
|
||||
3. `refactor: move tab group components to render-only`
|
||||
4. `feat: add MRU group activation model`
|
||||
5. `refactor: route hydration through tab group model`
|
||||
|
||||
## Recommendation
|
||||
|
||||
Do this as a dedicated follow-up PR sequence, not as an extension of the current PR.
|
||||
|
||||
The current PR is already the right stopping point:
|
||||
|
||||
- enough model centralization to stabilize the feature
|
||||
- not so much architectural churn that review and regression risk explode
|
||||
@@ -0,0 +1,176 @@
|
||||
# Tasks Page Resume State
|
||||
|
||||
## Goal
|
||||
|
||||
When a user leaves the Tasks page and comes back later, Orca should reopen the page in the same working context instead of falling back to a generic GitHub Issues/PRs list.
|
||||
|
||||
The resume behavior should cover:
|
||||
|
||||
- GitHub vs Linear.
|
||||
- GitHub Issues/PRs vs GitHub Project mode.
|
||||
- The selected GitHub Issues/PRs preset or custom search.
|
||||
- The selected GitHub Project and Project view.
|
||||
- The selected repos for GitHub Issues/PRs.
|
||||
- The selected Linear teams.
|
||||
|
||||
This should be lightweight. It should not introduce a second project-selection model or duplicate state that is already persisted elsewhere.
|
||||
|
||||
## Existing Persisted State
|
||||
|
||||
The current settings model already remembers most durable task choices:
|
||||
|
||||
- `settings.defaultTaskSource` remembers GitHub vs Linear.
|
||||
- `settings.defaultRepoSelection` remembers GitHub repo selection for cross-repo Issues/PRs.
|
||||
- `settings.defaultLinearTeamSelection` remembers Linear team selection.
|
||||
- `settings.githubProjects.activeProject` remembers the active GitHub Project.
|
||||
- `settings.githubProjects.lastViewByProject` remembers the last selected Project view per Project.
|
||||
- `settings.defaultTaskViewPreset` remembers the user's default GitHub Issues/PRs preset.
|
||||
|
||||
These should stay in place. They are already wired into the page and, for repo/team/project selection, they represent actual user preferences rather than purely transient page state.
|
||||
|
||||
## Missing State
|
||||
|
||||
The missing piece is the user's current page position inside Tasks:
|
||||
|
||||
- Whether GitHub is showing `Issues/PRs` or `Project`.
|
||||
- Which GitHub Issues/PRs preset is currently active.
|
||||
- The currently applied GitHub Issues/PRs query when the user has typed a custom search.
|
||||
- Which Linear preset is currently active.
|
||||
- The currently applied Linear query when the user has typed a search.
|
||||
|
||||
Linear presets are visible task tabs and drive the fetch path, so they must be restored too.
|
||||
|
||||
## Proposed Shape
|
||||
|
||||
Add a small optional field to `PersistedUIState`:
|
||||
|
||||
```ts
|
||||
export type TaskResumeState = {
|
||||
githubMode?: 'items' | 'project'
|
||||
githubItemsPreset?: TaskViewPresetId | null
|
||||
githubItemsQuery?: string
|
||||
linearPreset?: 'assigned' | 'created' | 'all' | 'completed'
|
||||
linearQuery?: string
|
||||
}
|
||||
```
|
||||
|
||||
Then add:
|
||||
|
||||
```ts
|
||||
taskResumeState?: TaskResumeState
|
||||
```
|
||||
|
||||
to `PersistedUIState`.
|
||||
|
||||
Why `PersistedUIState`: this is page-position UI state, similar to sidebar filters and widths. It is not an app-wide setting and should not be presented as a configurable default.
|
||||
|
||||
Do not add `source` to `TaskResumeState`. `settings.defaultTaskSource` already represents the user's last-used task source today, and duplicating it would create two persisted sources of truth. Keep source changes on the existing settings path.
|
||||
|
||||
Do not reset `githubMode` just because the current source is Linear. Source selection and GitHub sub-mode are independent pieces of context: if the user was in GitHub Project mode, switched to Linear, then later switches back to GitHub, GitHub should still reopen in Project mode.
|
||||
|
||||
## Restore Rules
|
||||
|
||||
On Tasks page mount:
|
||||
|
||||
1. If `taskPageData.taskSource` was passed by a caller, use it. Explicit navigation intent should win.
|
||||
2. Otherwise fall back to `settings.defaultTaskSource`.
|
||||
3. Apply these restore rules only after both settings and persisted UI state have hydrated. The current app loads those asynchronously before setting `persistedUIReady`; a `useState` initializer that reads `settings === null` or an unhydrated `taskResumeState` will capture defaults and miss the restored context.
|
||||
|
||||
The restore should run once per Tasks page mount, after hydration is ready. It should not keep reapplying persisted state over local user interactions while the page remains open.
|
||||
|
||||
For GitHub:
|
||||
|
||||
1. Restore `githubMode` from `taskResumeState.githubMode`.
|
||||
2. If mode is `items`, restore `githubItemsPreset` and `githubItemsQuery`.
|
||||
3. Repo selection continues to come from `settings.defaultRepoSelection`.
|
||||
4. If mode is `project`, use `settings.githubProjects.activeProject` and `settings.githubProjects.lastViewByProject`.
|
||||
5. If Project mode has no active project or view, show the Project picker empty state instead of silently switching to Issues/PRs.
|
||||
|
||||
For Linear:
|
||||
|
||||
1. Restore `linearPreset`.
|
||||
2. Restore `linearQuery`.
|
||||
3. Team selection continues to come from `settings.defaultLinearTeamSelection`.
|
||||
|
||||
Fallback behavior:
|
||||
|
||||
- If resume state is absent, use today's defaults.
|
||||
- If `githubItemsPreset` is non-null, derive the query from the preset unless `githubItemsQuery` disagrees because of a future migration. The preset should be treated as authoritative.
|
||||
- If `githubItemsPreset` is null, use `githubItemsQuery` as a custom search.
|
||||
- If `linearQuery` is non-empty, treat it as custom search and keep the restored `linearPreset` as the preset to return to after the search is cleared. The visible active preset should be suppressed while the search input is non-empty, matching today's UI.
|
||||
|
||||
## Write Rules
|
||||
|
||||
Update `taskResumeState` only when the user changes the active page context:
|
||||
|
||||
- Switching GitHub mode between Issues/PRs and Project.
|
||||
- Clicking a GitHub Issues/PRs preset.
|
||||
- Applying the debounced GitHub Issues/PRs search.
|
||||
- Clearing the GitHub Issues/PRs search.
|
||||
- Clicking a Linear preset.
|
||||
- Applying the debounced Linear search.
|
||||
- Clearing the Linear search.
|
||||
|
||||
Do not persist every raw search keystroke. The current UI applies search after a 300 ms debounce, so the persisted value should follow the debounced applied query, not the raw input. A user who types, waits for results, leaves Tasks, and comes back should see the same applied query.
|
||||
|
||||
Task source, repo selection, Linear team selection, active Project, and active Project view should continue writing through their existing settings paths.
|
||||
|
||||
The resume setter should merge partial updates with the existing resume state so changing one dimension does not erase the others. It should persist via `window.api.ui.set({ taskResumeState })` after updating local Zustand state.
|
||||
|
||||
When writing preset selections, clear stale custom-query ambiguity explicitly:
|
||||
|
||||
- GitHub preset click: write `{ githubItemsPreset: presetId, githubItemsQuery: undefined }` or the canonical preset query. Restore must still use the preset as authoritative.
|
||||
- GitHub custom search apply/debounce: write `{ githubItemsPreset: null, githubItemsQuery: trimmedQuery }`.
|
||||
- GitHub search clear: write `{ githubItemsPreset: null, githubItemsQuery: '' }`.
|
||||
- Linear preset click: write `{ linearPreset: presetId, linearQuery: '' }`.
|
||||
- Linear custom search apply/debounce: write `{ linearQuery: trimmedQuery }` without changing `linearPreset`.
|
||||
- Linear search clear: write `{ linearQuery: '' }` without changing `linearPreset`.
|
||||
|
||||
Do not persist mode changes caused only by hiding Project mode while another source is active. In practice, avoid an effect that changes persisted `githubMode` from `project` to `items` when `taskSource !== 'github'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Primary files:
|
||||
|
||||
- `src/shared/types.ts`: add `TaskResumeState` and `taskResumeState?: TaskResumeState`.
|
||||
- `src/shared/constants.ts`: no required default beyond leaving the field absent. If a concrete default is preferred, use GitHub Issues/PRs with the existing default preset.
|
||||
- `src/renderer/src/store/slices/ui.ts`: hydrate `taskResumeState` defensively, expose a setter such as `setTaskResumeState`, and use it in `openTaskPage` prefetch selection.
|
||||
- `src/renderer/src/components/TaskPage.tsx`: initialize source from existing settings/page data, and initialize GitHub mode, GitHub item preset/query, Linear preset, and Linear query from the resume state.
|
||||
- `src/renderer/src/App.tsx`: no new work should be needed if the field rides the existing `ui:get` hydration.
|
||||
|
||||
The setter should call `window.api.ui.set({ taskResumeState })` through the same persistence path used by other persisted UI fields, or follow the existing store pattern if there is already a central UI save effect for the relevant slice.
|
||||
|
||||
Hydration must sanitize the nested object because `PersistedUIState` comes from disk. Invalid `githubMode`, invalid GitHub preset ids, invalid Linear preset ids, or non-string queries should fall back field-by-field rather than entering Zustand as untrusted values.
|
||||
|
||||
`TaskPage` should subscribe to `persistedUIReady`, `settings`, and `taskResumeState`, then perform a one-shot local initialization once both settings and UI hydration are available. This avoids the current class of bugs where `useState(settings?.defaultTaskViewPreset ?? 'all')` captures `'all'` before settings arrive.
|
||||
|
||||
`openTaskPage` currently prefetches the settings default GitHub preset. After this change, it should prefetch the query the Tasks page will actually mount with:
|
||||
|
||||
1. If explicit `taskPageData.taskSource` is `linear`, skip GitHub prefetch.
|
||||
2. If explicit `taskPageData.taskSource` is `github`, or the resolved source is GitHub, prefetch only when the resolved GitHub mode is `items`.
|
||||
3. If `githubItemsPreset` is non-null, prefetch that preset query.
|
||||
4. If `githubItemsPreset` is null and `githubItemsQuery` is non-empty, prefetch the custom query.
|
||||
5. Otherwise prefetch `settings.defaultTaskViewPreset`.
|
||||
|
||||
The prefetch should also use the same repo selection the page will use: `taskPageData.preselectedRepoId` when present, otherwise `settings.defaultRepoSelection`, otherwise all eligible repos. Warming only `activeRepoId` is acceptable as an optimization fallback, but it is not equivalent to the mounted cross-repo query.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Do not remember scroll position, selected table row, open dialogs, pagination page, Project search overrides, or cached API results in this pass.
|
||||
|
||||
Those states are more fragile because they depend on live remote data. The first version should only restore the user's broad working context.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Open Tasks, switch to Linear, leave Tasks, return to Tasks: Linear is selected.
|
||||
- Open Tasks, switch to GitHub Project mode, switch to Linear, leave Tasks, return to Tasks, then switch back to GitHub: GitHub is still in Project mode.
|
||||
- Open Tasks, switch to GitHub Project mode, select a Project view, leave Tasks, return to Tasks: Project mode opens on the same Project view.
|
||||
- Open Tasks, choose GitHub Issues/PRs `My PRs`, leave Tasks, return to Tasks: GitHub Issues/PRs opens with `My PRs` active.
|
||||
- Open Tasks, type and apply a custom GitHub search, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that custom query and no preset selected.
|
||||
- Open Tasks, type a GitHub search, wait for the debounced results, leave Tasks, return to Tasks: GitHub Issues/PRs opens with that applied query and no preset selected.
|
||||
- Open Tasks, choose Linear `Completed`, leave Tasks, return to Tasks: Linear opens with `Completed` active.
|
||||
- Open Tasks, type a Linear search, wait for the debounced results, leave Tasks, return to Tasks: Linear opens with that applied query.
|
||||
- Open Tasks before hydration has completed, then wait for hydration: Tasks applies the persisted source/mode/preset/query exactly once and does not overwrite subsequent user changes.
|
||||
- Corrupt `taskResumeState` on disk with invalid modes/preset ids/non-string queries, restart, and open Tasks: invalid fields are ignored field-by-field without breaking the page.
|
||||
- Existing repo selection, Linear team selection, and GitHub Project view persistence keep working unchanged.
|
||||
- Restart the app after each source/mode/preset/custom-query scenario above: the same Tasks context is restored from persisted UI/settings state.
|
||||
@@ -1,95 +0,0 @@
|
||||
# Terminal Shortcut Audit And Fix Plan
|
||||
|
||||
## Context
|
||||
|
||||
Linked reports:
|
||||
|
||||
- `#443`: `Ctrl+R` / `Cmd+R` reverse search was blocked in the terminal.
|
||||
- `#453`: fixed `#443` by removing the app-level reload accelerator conflict.
|
||||
- `#481`: reports `Ctrl+U` (`unix-line-discard`) being swallowed.
|
||||
- `#482`: reports `Ctrl+E` (`end-of-line`) being swallowed, but is marked not reproducible.
|
||||
|
||||
The symptom across all four links is "terminal control chord does not reach readline", but the current code shows they are not all the same bug.
|
||||
|
||||
## Findings
|
||||
|
||||
1. `#443` and `#453` are directly related.
|
||||
`CmdOrCtrl+R` was reserved above the renderer, so the terminal never saw the chord. `#453` fixed that by removing the reload accelerator and keeping only `Shift+CmdOrCtrl+R` for force reload.
|
||||
|
||||
2. `#481` and `#482` are related to the same problem class, but not proven to share `#443`'s exact root cause.
|
||||
Current `mainWindow.webContents.on('before-input-event', ...)` no longer reserves `R`, `U`, or `E`.
|
||||
Current terminal renderer shortcut handling also does not reserve macOS `Ctrl+R`, `Ctrl+U`, or `Ctrl+E`.
|
||||
|
||||
3. The real gap was auditability, not just one missing exception.
|
||||
Shortcut interception lived in multiple places:
|
||||
- main window `before-input-event`
|
||||
- browser guest `before-input-event`
|
||||
- terminal renderer `keydown` capture
|
||||
|
||||
That made it easy to fix one conflict (`Cmd/Ctrl+R`) while leaving the overall reservation surface implicit and hard to verify.
|
||||
|
||||
## Reproduction Matrix
|
||||
|
||||
Expected behavior from a focused terminal on macOS:
|
||||
|
||||
- Pass through to shell/readline:
|
||||
- `Ctrl+R`
|
||||
- `Ctrl+U`
|
||||
- `Ctrl+E`
|
||||
- `Ctrl+A`
|
||||
- `Ctrl+W`
|
||||
- `Ctrl+K`
|
||||
- `Alt+B`
|
||||
- `Alt+F`
|
||||
- `Alt+D`
|
||||
|
||||
- Reserved by Orca:
|
||||
- `Cmd+F`
|
||||
- `Cmd+K`
|
||||
- `Cmd+W`
|
||||
- `Cmd+D`
|
||||
- `Cmd+Shift+D`
|
||||
- `Cmd+[`
|
||||
- `Cmd+]`
|
||||
- `Cmd+Shift+Enter`
|
||||
- `Ctrl+Backspace`
|
||||
- `Cmd+Backspace`
|
||||
- `Cmd+Delete`
|
||||
- `Alt+Backspace`
|
||||
|
||||
Expected behavior from main-process/browser-guest forwarding:
|
||||
|
||||
- Reserved:
|
||||
- zoom shortcuts
|
||||
- worktree palette
|
||||
- quick open
|
||||
- worktree index jump
|
||||
|
||||
- Must never be reserved there:
|
||||
- `Cmd/Ctrl+R`
|
||||
- readline control chords like `Ctrl+U`, `Ctrl+E`, `Ctrl+R`
|
||||
|
||||
## Plan
|
||||
|
||||
1. Centralize the window-level shortcut allowlist into a shared pure helper.
|
||||
Why: main-window and browser-guest forwarding should not drift apart, because either one can steal terminal input before the renderer sees it.
|
||||
|
||||
2. Centralize terminal-pane shortcut classification into a pure helper.
|
||||
Why: the terminal shortcut layer must stay an explicit allowlist so future shortcuts do not accidentally swallow readline chords.
|
||||
|
||||
3. Add regression tests that enumerate both sides:
|
||||
- allowed Orca shortcuts
|
||||
- guaranteed shell passthrough chords
|
||||
|
||||
4. Keep `#443` fixed and make the current status of `#481` / `#482` testable.
|
||||
Why: even if one report later turns out to be environment-specific, Orca should still have an executable contract for what it reserves.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Shared helper added for main-process shortcut resolution.
|
||||
- Shared helper added for terminal-pane shortcut resolution.
|
||||
- Tests added to encode the allowlist and passthrough matrix explicitly.
|
||||
|
||||
## Follow-Up Risk
|
||||
|
||||
This change makes the current reservation surface auditable, but it does not redesign non-macOS terminal shortcuts. Orca still uses `Ctrl` as the primary modifier for several terminal actions on Linux/Windows, which is a separate UX question from the macOS control-chord regressions linked above.
|
||||
@@ -1,452 +0,0 @@
|
||||
# Terminal drag-and-drop over SSH
|
||||
|
||||
## Problem
|
||||
|
||||
Dragging a local file onto a terminal pane inserts the file's absolute path
|
||||
into the PTY, so the user can reference it in a CLI or TUI-agent prompt. On
|
||||
SSH worktrees the terminal runs remotely, so injecting a **local** path
|
||||
(`/Users/alice/Desktop/log.txt`) is useless — the remote agent has no access
|
||||
to it.
|
||||
|
||||
The file-explorer drop path was fixed in PR #1279 by routing through SFTP
|
||||
upload (`importExternalPathsSsh`). The terminal drop path was not touched
|
||||
and still breaks for SSH worktrees.
|
||||
|
||||
Reported: https://stablygroup.slack.com/archives/C0ASMDT6LQZ/p1777530155421009
|
||||
|
||||
## Goals
|
||||
|
||||
- Dropping a local file onto a terminal connected to an SSH worktree makes
|
||||
that file available to the remote shell/agent, and injects a path the
|
||||
remote process can read.
|
||||
- Local terminal drops keep their current behavior: reference-in-place, no
|
||||
copy, no authorization, no repo pollution.
|
||||
- Local and SSH paths share one main-side resolver. The renderer may pass
|
||||
connection context and show progress UI, but copy/upload/deconfliction
|
||||
policy stays out of the renderer so the two modes do not drift over time.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Unifying terminal drop with file-explorer drop. They have different
|
||||
semantics (explorer always copies into a user-picked `destDir`; terminal
|
||||
references a path). They share the SSH upload primitive internally but
|
||||
remain separate IPCs.
|
||||
- Cleanup / garbage collection of staged remote files. Tracked as follow-up
|
||||
(see "Follow-ups" below — file **before merging** and replace this
|
||||
parenthetical with the issue number so the reference is locatable). Until
|
||||
GC lands, files uploaded by a drop whose pane is unmounted before the
|
||||
upload resolves are orphaned in `.orca/drops/` with no injected path.
|
||||
Users should know uploads are not cancellable.
|
||||
- Abortable uploads. SFTP transfers run to completion even if the terminal
|
||||
pane is unmounted mid-flight.
|
||||
|
||||
## Considered: Option A (full unification)
|
||||
|
||||
Terminal drop calls `fs:importExternalPaths` with
|
||||
`destDir = worktreePath`, then injects `result.destPath` into the PTY.
|
||||
Shares exactly the file-explorer code path.
|
||||
|
||||
Rejected because it changes **local** terminal-drop UX: today dropping
|
||||
`~/Desktop/log.txt` into the terminal pastes `/Users/…/Desktop/log.txt`
|
||||
so the agent reads the file in place; under Option A the file would be
|
||||
copied into the repo. Users rely on the reference-in-place behavior to
|
||||
point agents at files without polluting the worktree.
|
||||
|
||||
## Design (Option B)
|
||||
|
||||
### New IPC
|
||||
|
||||
```
|
||||
fs:resolveDroppedPathsForAgent({
|
||||
paths: string[],
|
||||
worktreePath: string,
|
||||
connectionId?: string,
|
||||
}) → {
|
||||
resolvedPaths: string[],
|
||||
skipped: { sourcePath: string; reason: 'missing' | 'symlink' | 'permission-denied' | 'unsupported' }[],
|
||||
failed: { sourcePath: string; reason: string }[],
|
||||
}
|
||||
```
|
||||
|
||||
Contract:
|
||||
|
||||
- **Local (`connectionId == null`):** returns
|
||||
`{ resolvedPaths: paths, skipped: [], failed: [] }` unchanged. No copy. No
|
||||
authorization (matches today's behavior — the agent's own read is what gets
|
||||
authorized, not the drop). Use `args.connectionId == null` (not `!args.connectionId`)
|
||||
so an empty string cannot silently pick the local branch.
|
||||
- **SSH (`connectionId` is a non-empty string):** uploads each path via SFTP
|
||||
into a staging dir under the worktree. Returns remote absolute paths for
|
||||
items that uploaded successfully, items rejected by policy (symlinks,
|
||||
missing sources, permission-denied, unsupported file types) in `skipped`,
|
||||
and hard upload errors in `failed`. The split mirrors `ImportItemResult`'s
|
||||
existing `'imported' | 'skipped' | 'failed'` and lets the renderer toast
|
||||
"Skipped N symlinks" distinctly from "Failed to upload N files." Collapsing
|
||||
skipped into failed would mislabel routine policy rejections as errors.
|
||||
|
||||
### SSH staging dir
|
||||
|
||||
`${worktreePath}/.orca/drops/` on the remote. `.orca/` is reserved as an
|
||||
Orca-owned directory for future remote state (GC metadata, cached remote
|
||||
capability probes, etc.); this is its first use. Future features adding
|
||||
subpaths under `.orca/` should namespace themselves (`.orca/drops/`,
|
||||
`.orca/<feature>/`) rather than placing files at the root.
|
||||
|
||||
Rationale:
|
||||
|
||||
- No need to resolve remote `$HOME` (which would require a round-trip and
|
||||
caching layer).
|
||||
- Lives inside the worktree, so cleaned up naturally when the worktree is
|
||||
deleted.
|
||||
- The agent has read access by construction (it runs with the worktree's
|
||||
cwd).
|
||||
|
||||
The main process must bootstrap `.orca/.gitignore` with `*\n!.gitignore\n`
|
||||
before the first successful upload. Otherwise every SSH terminal drop dirties
|
||||
source control with an untracked `.orca/` directory, which recreates the
|
||||
repo-pollution problem that ruled out Option A for local terminal drops. The
|
||||
`!.gitignore` negation keeps the marker file itself trackable if we ever want
|
||||
to (and costs nothing today — `git status` stays clean either way because
|
||||
nothing tries to add it).
|
||||
|
||||
The staging directory must be created recursively over SFTP before upload:
|
||||
|
||||
- create `${worktreePath}/.orca` (ignore "already exists")
|
||||
- write `${worktreePath}/.orca/.gitignore` as `*\n!.gitignore\n` **only if it
|
||||
does not already exist** — never overwrite. A user may have added patterns
|
||||
there, and silently clobbering user-authored content violates least
|
||||
surprise even inside an Orca-owned directory. Use `sftpPathExists` before
|
||||
writing. (Two concurrent first-drops racing through the `sftpPathExists`
|
||||
check will both write the same bytes — last writer wins, idempotent, so
|
||||
the race is benign and not worth locking.)
|
||||
- create `${worktreePath}/.orca/drops` (ignore "already exists")
|
||||
|
||||
Do not rely on `uploadFile`, `uploadDirectory`, or the existing
|
||||
`mkdirSftp(destPath)` calls to create missing parents. `uploadFile` writes
|
||||
directly to the final remote file path, and `mkdirSftp` is not recursive, so
|
||||
the first terminal drop into a fresh SSH worktree would fail if the parents do
|
||||
not already exist.
|
||||
|
||||
### Main-side implementation
|
||||
|
||||
`src/main/ipc/filesystem-mutations.ts`:
|
||||
|
||||
```ts
|
||||
ipcMain.handle('fs:resolveDroppedPathsForAgent', async (_e, args) => {
|
||||
// Why: `== null` (not `!args.connectionId`) so an empty string is treated
|
||||
// as an error from the renderer, not silently routed to the local branch.
|
||||
if (args.connectionId == null) {
|
||||
return { resolvedPaths: args.paths, skipped: [], failed: [] }
|
||||
}
|
||||
const worktreePath = args.worktreePath.replace(/\/+$/, '')
|
||||
const destDir = `${worktreePath}/.orca/drops`
|
||||
const { results } = await importExternalPathsSsh(
|
||||
args.paths,
|
||||
destDir,
|
||||
args.connectionId,
|
||||
{ ensureDir: true },
|
||||
)
|
||||
const resolvedPaths: string[] = []
|
||||
const skipped: { sourcePath: string; reason: ImportSkipReason }[] = []
|
||||
const failed: { sourcePath: string; reason: string }[] = []
|
||||
// Iterate in input order so injected paths align with the user's drop order.
|
||||
for (const r of results) {
|
||||
if (r.status === 'imported') {
|
||||
resolvedPaths.push(r.destPath)
|
||||
} else if (r.status === 'skipped') {
|
||||
skipped.push({ sourcePath: r.sourcePath, reason: r.reason })
|
||||
} else {
|
||||
failed.push({ sourcePath: r.sourcePath, reason: r.reason })
|
||||
}
|
||||
}
|
||||
return { resolvedPaths, skipped, failed }
|
||||
})
|
||||
```
|
||||
|
||||
Reuses `importExternalPathsSsh` — SFTP upload, symlink pre-scan, name
|
||||
deconfliction, per-item error reporting are all already there.
|
||||
|
||||
**Staging bootstrap lives inside `importExternalPathsSsh`** behind a new
|
||||
optional `{ ensureDir?: boolean }` parameter. When set, before the first
|
||||
upload the function creates `${destDir}`'s parent chain (`.orca`, then
|
||||
`drops`) and writes `.orca/.gitignore` (`*\n`) only if missing, all on the
|
||||
**same SFTP session** already opened for the upload. Do not add a separate
|
||||
`ensureSshDropStagingDir` helper that opens its own channel — that would
|
||||
double the SFTP handshake cost on every drop.
|
||||
|
||||
### Renderer-side implementation
|
||||
|
||||
**API change to `shellEscapePath`.** Today the second arg is a *userAgent
|
||||
string* (substring-matched for `"Windows"`), which couples escape rules to
|
||||
the client OS. For SSH drops we need to escape for the *target shell*,
|
||||
which is always POSIX on the remote regardless of client OS. Change the
|
||||
signature to take an explicit target:
|
||||
|
||||
```ts
|
||||
shellEscapePath(path: string, targetShell: 'posix' | 'windows')
|
||||
```
|
||||
|
||||
Callers derive `targetShell` from context: local drops pass
|
||||
`isWindowsUserAgent() ? 'windows' : 'posix'`; SSH drops always pass
|
||||
`'posix'`. This makes test #11 (Windows client → Linux SSH worktree)
|
||||
correct by construction instead of by coincidence.
|
||||
|
||||
**Migration — all three call sites + tests must change together:**
|
||||
|
||||
- `src/renderer/src/components/terminal-pane/pane-helpers.ts:53` — update
|
||||
signature; drop the `userAgent` default.
|
||||
- `src/renderer/src/components/terminal-pane/TerminalPane.tsx:937`
|
||||
(file-explorer → terminal drop). This is a **local-only** code path
|
||||
(explorer drag uses a DOM MIME type that the preload SSH bridge does not
|
||||
forward), so pass `isWindowsUserAgent() ? 'windows' : 'posix'` to preserve
|
||||
today's behavior exactly.
|
||||
- `src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts:344`
|
||||
— replaced by the new SSH-aware handler below; the `shellEscapePath` call
|
||||
moves inside it with an explicit `targetShell`.
|
||||
- `src/renderer/src/components/terminal-pane/pane-helpers.test.ts` — the
|
||||
existing cases pass `'Macintosh'`, `'Linux'`, `'Windows'` as the userAgent
|
||||
arg. Rewrite to pass `'posix'` (for Mac/Linux) and `'windows'`, so tests
|
||||
exercise the new contract rather than the legacy substring match.
|
||||
|
||||
No local behavior changes if this migration is done in one commit: Mac/Linux
|
||||
already took the POSIX branch via the userAgent string match; Windows
|
||||
already took the Windows branch. The new signature just names that
|
||||
explicitly.
|
||||
|
||||
`src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts`:
|
||||
|
||||
```ts
|
||||
return window.api.ui.onFileDrop(async (data) => {
|
||||
if (data.target !== 'terminal') return
|
||||
if (data.paths.length === 0) return
|
||||
const manager = managerRef.current
|
||||
if (!manager) return
|
||||
const pane = manager.getActivePane() ?? manager.getPanes()[0]
|
||||
if (!pane) return
|
||||
const paneId = pane.id
|
||||
const transport = paneTransportsRef.current.get(paneId)
|
||||
if (!transport) return
|
||||
|
||||
const wtId = worktreeIdRef.current
|
||||
const worktreePath = worktreePathRef.current
|
||||
if (!wtId || !worktreePath) return
|
||||
|
||||
// Why: getConnectionId (selector on the terminals/repos slice:
|
||||
// `state.repos.find(r => r.id === <worktree's repoId>)?.connectionId`,
|
||||
// exposed via the store) returns `string` (SSH), `null` (local repo
|
||||
// found), or `undefined` (store not hydrated / worktree not found).
|
||||
// Treat `undefined` as an error, not as "local" — otherwise a drop
|
||||
// during hydration would silently paste local paths into a remote
|
||||
// shell.
|
||||
const connectionId = getConnectionId(wtId)
|
||||
if (connectionId === undefined) {
|
||||
toast.error('Worktree not ready — try again in a moment.')
|
||||
return
|
||||
}
|
||||
const isRemote = connectionId !== null
|
||||
const targetShell: 'posix' | 'windows' = isRemote
|
||||
? 'posix'
|
||||
: isWindowsUserAgent()
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
|
||||
// Local fast path: no IPC round-trip, no toast. Preserves today's
|
||||
// zero-latency behavior exactly — same code shape as before, only the
|
||||
// shellEscapePath signature is new (and resolves to the same branch).
|
||||
if (!isRemote) {
|
||||
for (const p of data.paths) {
|
||||
transport.sendInput(`${shellEscapePath(p, targetShell)} `)
|
||||
}
|
||||
pane.terminal.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const pending = toast.loading(
|
||||
`Uploading ${data.paths.length} file(s) to remote…`,
|
||||
)
|
||||
try {
|
||||
const { resolvedPaths, skipped, failed } =
|
||||
await window.api.fs.resolveDroppedPathsForAgent({
|
||||
paths: data.paths,
|
||||
worktreePath,
|
||||
connectionId,
|
||||
})
|
||||
// Why: pane may have unmounted during the SFTP upload (tab closed,
|
||||
// worktree switched). Re-check the transport map before writing so
|
||||
// we don't call sendInput on a torn-down PTY. Orphaned uploads are
|
||||
// acknowledged in Non-goals.
|
||||
const liveTransport = paneTransportsRef.current.get(paneId)
|
||||
if (liveTransport) {
|
||||
// resolvedPaths preserves input order (main-side iterates results in
|
||||
// order); injected paths line up with the user's drop gesture.
|
||||
for (const p of resolvedPaths) {
|
||||
liveTransport.sendInput(`${shellEscapePath(p, targetShell)} `)
|
||||
}
|
||||
pane.terminal.focus()
|
||||
}
|
||||
if (skipped.length > 0) {
|
||||
const symlinkCount = skipped.filter((s) => s.reason === 'symlink').length
|
||||
const noun = skipped.length === 1 ? 'item' : 'items'
|
||||
toast.message(
|
||||
symlinkCount === skipped.length
|
||||
? `Skipped ${skipped.length} symlink${skipped.length === 1 ? '' : 's'}.`
|
||||
: `Skipped ${skipped.length} ${noun}.`,
|
||||
)
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
const noun = failed.length === 1 ? 'file' : 'files'
|
||||
toast.error(`Failed to upload ${failed.length} ${noun}.`)
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(extractIpcErrorMessage(err, 'Failed to upload files.'))
|
||||
} finally {
|
||||
toast.dismiss(pending)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
`extractIpcErrorMessage` is the existing helper at
|
||||
`src/renderer/src/lib/ipc-error.ts:6` (already used by
|
||||
`useFileExplorerImport.ts`, `Terminal.tsx`, etc.). Reuse it — do not copy
|
||||
the body locally.
|
||||
|
||||
New dependencies on the hook: `worktreeId` and `worktreePath` refs. Use the
|
||||
`TerminalPane`'s own `worktreeId` prop, not global `activeWorktreeId`. The
|
||||
drop listener is already gated by `isActive`, and the pane's own
|
||||
`worktreeId` is the authoritative identity of the terminal being written
|
||||
to; reading from global state would race during worktree switches. Promote
|
||||
this reasoning into a `// Why:` comment at the call site per CLAUDE.md.
|
||||
|
||||
Derive `worktreePath` the same way `use-terminal-pane-lifecycle.ts` does
|
||||
today: find the worktree by `worktreeId` in the store, then fall back to
|
||||
`cwd`.
|
||||
|
||||
### Why not branch in the renderer
|
||||
|
||||
- Keeps upload semantics in one place. Adding a second terminal consumer
|
||||
(e.g. a standalone TUI pane) should call the same resolver instead of
|
||||
deciding where to copy, how to deconflict names, or how to report per-item
|
||||
failures.
|
||||
- The renderer can know that the worktree is SSH for progress UI and shell
|
||||
escaping, but it should not know SFTP details or construct uploaded
|
||||
destination filenames itself.
|
||||
|
||||
### UX details
|
||||
|
||||
- **SSH "Uploading…" toast:** SFTP of a few MB can take seconds. Without
|
||||
feedback the user thinks the drop failed. Dismiss on success, replace
|
||||
with an error toast on failure.
|
||||
- **Don't inject until upload resolves.** Injecting the remote path before
|
||||
the file lands means the agent may try to read a file that doesn't yet
|
||||
exist and error. Worth the extra perceived latency.
|
||||
- **Failure policy:** partial failures still inject the succeeded paths
|
||||
and toast the count of failures (same pattern as the explorer).
|
||||
- **Escape for the terminal that receives the path.** Local drops keep the
|
||||
existing platform-specific quoting. SSH drops must use POSIX shell quoting
|
||||
for the returned remote paths; do not let a Windows client choose Windows
|
||||
quoting for a Linux/macOS SSH shell.
|
||||
|
||||
## System fit
|
||||
|
||||
```
|
||||
[Electron preload native drop]
|
||||
|
|
||||
v
|
||||
[terminal:file-drop IPC relay]
|
||||
|
|
||||
v
|
||||
[active TerminalPane drop handler]
|
||||
|
|
||||
v
|
||||
[fs:resolveDroppedPathsForAgent]
|
||||
| local | SSH
|
||||
v v
|
||||
[return original paths] [SFTP stage into ${worktreePath}/.orca/drops]
|
||||
| |
|
||||
| v
|
||||
| [return remote readable paths]
|
||||
| |
|
||||
+---------------+-----------------+
|
||||
|
|
||||
v
|
||||
[PTY sendInput escaped paths]
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. Local worktree, drop a single file onto the terminal → original
|
||||
absolute path pasted. No copy. No change from today.
|
||||
2. Local worktree, drop multiple files → each path pasted separated by a
|
||||
space. No change from today.
|
||||
3. SSH worktree, drop a single file → file uploads to
|
||||
`${worktreePath}/.orca/drops/<file>` on remote; remote path pasted
|
||||
into PTY; agent can read it.
|
||||
4. SSH worktree, drop a folder → folder uploads recursively; remote dir
|
||||
path pasted.
|
||||
5. SSH worktree, drop a symlink → no injection; toast reads
|
||||
"Skipped 1 symlink." (not "Failed to upload") because symlink rejection
|
||||
is policy, not error.
|
||||
6. SSH worktree, drop 5 files where items 2 and 4 are permission-denied at
|
||||
the local source → paths 1, 3, 5 are injected **in that order**
|
||||
(matching input order), toast says "Skipped 2 items." (permission-denied
|
||||
is classified `skipped`, not `failed`, by `importExternalPathsSsh`).
|
||||
Additionally, simulate an SFTP write error mid-upload to cover the
|
||||
`failed` branch → "Failed to upload N files" toast.
|
||||
7. SSH worktree disconnected mid-drag → user-visible error toast, no
|
||||
partial injection.
|
||||
8. Name collision: drop the same file twice in quick succession → second
|
||||
upload lands as `<name> copy.<ext>` (deconfliction inherited from
|
||||
`importExternalPathsSsh`).
|
||||
9. Fresh SSH worktree with no `.orca` directory → first drop creates
|
||||
`.orca/`, `.orca/.gitignore`, and `.orca/drops/`; upload succeeds.
|
||||
10. After an SSH drop, `git status --short` in the remote worktree does not
|
||||
show `.orca/`.
|
||||
11. Windows client dropping `my file's $draft.txt` into a Linux SSH worktree
|
||||
pastes a POSIX-escaped remote path, not Windows double-quoted syntax.
|
||||
Expected output for remote path `/home/u/wt/.orca/drops/my file's $draft.txt`:
|
||||
`'/home/u/wt/.orca/drops/my file'\''s $draft.txt'` (literal `$draft`
|
||||
inside single quotes — no expansion, no backslash-escaping).
|
||||
16. Local terminal drop on macOS / Linux / Windows clients behaves
|
||||
byte-identically to pre-change: same injected string, no toast, no IPC
|
||||
call. Run the existing `pane-helpers.test.ts` expectations through the
|
||||
new `'posix' | 'windows'` API and confirm outputs match the legacy
|
||||
userAgent-based outputs.
|
||||
17. File-explorer → terminal drop (`TerminalPane.tsx` onDrop) continues to
|
||||
work on local and SSH worktrees exactly as today — this path is not
|
||||
touched by the new IPC and must still use the explorer's own handling.
|
||||
12. Empty drop (`data.paths.length === 0`) → no IPC call, no toast, no
|
||||
injection. (Possible on some OSes when a drag contains only non-file
|
||||
items.)
|
||||
13. Store unhydrated (`getConnectionId` returns `undefined`) → user-visible
|
||||
"Worktree not ready" toast, no injection, no IPC call. Not a silent
|
||||
local fallback.
|
||||
14. Existing user-authored `.orca/.gitignore` with extra patterns → after
|
||||
first SSH drop the file is unchanged (bootstrap writes only when
|
||||
missing).
|
||||
15. Unit coverage:
|
||||
- preload/API types expose `resolveDroppedPathsForAgent` and the
|
||||
channel is registered in the preload `contextBridge` / IPC
|
||||
allowlist (regression guard — easy to forget).
|
||||
- main IPC covers: local passthrough, SSH success, partial failure
|
||||
(order preserved), fresh-worktree staging bootstrap, bootstrap
|
||||
preserves existing `.gitignore`, and disconnected SSH.
|
||||
- terminal-pane coverage verifies the resolver is called once per
|
||||
gesture, no path is injected until the promise resolves, and
|
||||
`shellEscapePath` is called with `'posix'` for SSH drops regardless
|
||||
of client userAgent.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
File each of these as a GitHub issue before merging the implementation PR
|
||||
so "tracked as follow-up" is actually locatable. Inline the issue number
|
||||
next to each item once filed (e.g. `- GC drops dir (#1301)`), and update
|
||||
the GC paragraph in Non-goals to point to that issue directly.
|
||||
|
||||
- GC `${worktreePath}/.orca/drops/` on worktree delete / disconnect.
|
||||
- `AbortController` plumbing so unmounting the terminal pane cancels the
|
||||
in-flight SFTP upload (related to the pane-unmount guard added in the
|
||||
renderer handler: today we no-op the injection, but the bytes still
|
||||
transfer).
|
||||
- Drag-over affordance on the terminal pane (it has the
|
||||
`data-native-file-drop-target="terminal"` marker but no hover style),
|
||||
so users get feedback that dropping into the terminal is supported.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Extended Key Chords in the Terminal (Shift+Enter, etc.)
|
||||
|
||||
## What Orca sends
|
||||
|
||||
Orca's built‑in terminal already encodes extended key chords using the
|
||||
[kitty keyboard protocol][kitty] (CSI‑u). For example, **Shift+Enter** is sent
|
||||
as the byte sequence:
|
||||
|
||||
```
|
||||
ESC [ 1 3 ; 2 u (i.e. \x1b[13;2u)
|
||||
```
|
||||
|
||||
See `src/renderer/src/components/terminal-pane/terminal-shortcut-policy.ts`
|
||||
for the full table.
|
||||
|
||||
Orca also advertises kitty‑protocol support to the running program via
|
||||
`vtExtensions.kittyKeyboard` on xterm.js (see
|
||||
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts`), so CLIs that
|
||||
probe with `CSI ? u` learn that the terminal speaks CSI‑u and enable their
|
||||
enhanced input handlers.
|
||||
|
||||
## Why Shift+Enter may not reach your CLI inside tmux
|
||||
|
||||
tmux, by default, strips both extended‑key encodings (modifyOtherKeys
|
||||
`CSI 27 ; 2 ; 13 ~` *and* kitty‑style `CSI 13 ; 2 u`). If you run Claude
|
||||
Code, Codex, or any other CLI under tmux, Shift+Enter will look like a
|
||||
plain `Enter` unless tmux is told to pass those bytes through.
|
||||
|
||||
Add this to `~/.tmux.conf` (tmux 3.2+):
|
||||
|
||||
```tmux
|
||||
set -s extended-keys on
|
||||
set -as terminal-features 'xterm*:extkeys'
|
||||
```
|
||||
|
||||
Then reload: `tmux source-file ~/.tmux.conf` (or restart the tmux server).
|
||||
|
||||
- `extended-keys on` — tell tmux to accept and forward the extended
|
||||
encodings instead of collapsing them to the unshifted key.
|
||||
- `terminal-features 'xterm*:extkeys'` — tell tmux that the surrounding
|
||||
terminal (Orca, in this case) understands those encodings, so tmux is
|
||||
willing to emit them.
|
||||
|
||||
## Verifying end‑to‑end
|
||||
|
||||
Inside an Orca terminal (no tmux), run:
|
||||
|
||||
```
|
||||
cat -v
|
||||
```
|
||||
|
||||
Press **Shift+Enter**. You should see:
|
||||
|
||||
```
|
||||
^[[13;2u
|
||||
```
|
||||
|
||||
That's caret notation for `\x1b[13;2u` — the expected CSI‑u encoding. If
|
||||
you see `^M` (or a blank newline) instead, either the chord isn't reaching
|
||||
the terminal (check `keyboard-handlers.ts` / `terminal-shortcut-policy.ts`)
|
||||
or you're inside tmux without the config above.
|
||||
|
||||
Inside tmux after the config, the same `cat -v` test should print the same
|
||||
`^[[13;2u`.
|
||||
|
||||
[kitty]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
||||
@@ -41,7 +41,8 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve('src/main/index.ts'),
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts')
|
||||
'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'),
|
||||
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
android/
|
||||
ios/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"rules": {}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
# Orca Mobile
|
||||
|
||||
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
|
||||
|
||||
Local development uses two processes:
|
||||
|
||||
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port `6768`.
|
||||
- Expo Metro from `mobile/`. This serves the React Native app on port `8081`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 24+
|
||||
- pnpm
|
||||
- Xcode and/or Android Studio tooling for simulator or device builds
|
||||
- Expo Go on your phone, or a development client build when native modules are needed
|
||||
- Phone and desktop on the same LAN when testing a physical phone
|
||||
|
||||
## Start Desktop Orca
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
Confirm the mobile RPC server is listening:
|
||||
|
||||
```bash
|
||||
lsof -nP -iTCP:6768 -sTCP:LISTEN
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.
|
||||
|
||||
## Start The Mobile App
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
pnpm install
|
||||
pnpm start
|
||||
```
|
||||
|
||||
Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.
|
||||
|
||||
For a native dev-client build:
|
||||
|
||||
```bash
|
||||
pnpm exec expo run:android
|
||||
pnpm exec expo run:ios
|
||||
pnpm start --dev-client
|
||||
```
|
||||
|
||||
## Pair With Desktop Orca
|
||||
|
||||
1. Open Orca desktop.
|
||||
2. Go to Settings > Mobile.
|
||||
3. Scan the pairing QR code from the mobile app.
|
||||
4. Confirm the mobile host endpoint is `ws://<desktop-ip>:6768`.
|
||||
|
||||
For the Android emulator, use `ws://10.0.2.2:6768`. For a physical phone, use the desktop LAN IP, for example `ws://192.168.0.179:6768`.
|
||||
|
||||
If the phone has a stale host entry, remove it from the app and pair again.
|
||||
|
||||
## Development Paths
|
||||
|
||||
### Android Phone
|
||||
|
||||
1. Install Expo Go from Google Play
|
||||
2. Run `pnpm start`, scan QR with Expo Go
|
||||
3. For native modules: `pnpm exec expo run:android`
|
||||
4. Run with `pnpm start --dev-client`
|
||||
|
||||
### iOS Simulator
|
||||
|
||||
1. Install Xcode from the App Store
|
||||
2. Run `pnpm start --ios` to open in iOS Simulator
|
||||
|
||||
## Physical Phone Debugging
|
||||
|
||||
The phone can be inspected through the connected device tooling:
|
||||
|
||||
```bash
|
||||
orca snapshot --json
|
||||
orca click --element @e3 --json
|
||||
orca fill --element @e1 --value "ls" --json
|
||||
orca screenshot --json
|
||||
```
|
||||
|
||||
Use `snapshot` first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs `terminal.subscribe`.
|
||||
|
||||
## Terminal Streaming Repro Without A Phone
|
||||
|
||||
Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>
|
||||
```
|
||||
|
||||
You can pass a worktree selector as the third argument:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
|
||||
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"
|
||||
```
|
||||
|
||||
The expected result includes:
|
||||
|
||||
```text
|
||||
streamSawMarker: true
|
||||
readSawMarker: true
|
||||
```
|
||||
|
||||
If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or `TerminalWebView` readiness/queueing path.
|
||||
|
||||
## Terminal Color Repro Without A Phone
|
||||
|
||||
Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
|
||||
<deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
|
||||
```
|
||||
|
||||
The script captures `terminal.subscribe` snapshots in an A → B → A sequence and writes raw snapshots to `mobile/terminal-color-repro/`. If the two A snapshots have different `sgrColor` counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.
|
||||
|
||||
## Validation
|
||||
|
||||
Run these checks before committing mobile terminal changes:
|
||||
|
||||
```bash
|
||||
cd mobile
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm lint
|
||||
cd ..
|
||||
pnpm typecheck:node
|
||||
```
|
||||
|
||||
## Protocol Version Compatibility
|
||||
|
||||
Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on `status.get` so a genuinely incompatible combo can hard-block instead of silently misbehaving.
|
||||
|
||||
Constants live in two files (Metro can't resolve outside `mobile/`):
|
||||
|
||||
- `src/shared/protocol-version.ts` — `DESKTOP_PROTOCOL_VERSION`, `MIN_COMPATIBLE_MOBILE_VERSION`
|
||||
- `mobile/src/transport/protocol-version.ts` — `MOBILE_PROTOCOL_VERSION`, `MIN_COMPATIBLE_DESKTOP_VERSION`
|
||||
|
||||
Today all four are set so `evaluateCompat` always returns `{ kind: 'ok' }` — nothing blocks. The wire format is in place to flip a switch when needed.
|
||||
|
||||
### When to bump
|
||||
|
||||
Bump `DESKTOP_PROTOCOL_VERSION` (and the mobile mirror `MOBILE_PROTOCOL_VERSION` when relevant) for **breaking** changes:
|
||||
|
||||
- Removed RPC method or required parameter that mobile uses
|
||||
- Changed meaning (units, nullability) of an existing field mobile reads
|
||||
- Changed encryption, framing, or auth handshake
|
||||
|
||||
Do **not** bump for additive changes:
|
||||
|
||||
- New RPC methods
|
||||
- New optional fields on existing methods
|
||||
- New event types in `terminal.subscribe`
|
||||
|
||||
Set `MIN_COMPATIBLE_MOBILE_VERSION` (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for `MIN_COMPATIBLE_DESKTOP_VERSION` from the mobile side.
|
||||
|
||||
When a verdict is `blocked`, `mobile/src/components/ProtocolBlockScreen.tsx` renders a screen pointing the user at either the App Store (mobile too old) or GitHub Releases (desktop too old).
|
||||
|
||||
To exercise the block screen locally: set `MIN_COMPATIBLE_DESKTOP_VERSION = 999` in `mobile/src/transport/protocol-version.ts`, rebuild, pair to any desktop. Revert before merging.
|
||||
|
||||
## Mock Server
|
||||
|
||||
Develop the mobile app without a running Orca desktop instance:
|
||||
|
||||
```bash
|
||||
pnpm mock-server # starts mock WebSocket server on port 6768
|
||||
```
|
||||
|
||||
Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`.
|
||||
|
||||
## Connecting to Real Orca
|
||||
|
||||
1. Start Orca desktop with WebSocket transport enabled
|
||||
2. In Orca, go to Settings > Mobile and scan the QR code with this app
|
||||
3. The QR encodes the connection endpoint, device token, and TLS fingerprint
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mobile/
|
||||
├── app/ # Expo Router screens (file-based routing)
|
||||
│ ├── _layout.tsx # Root layout with navigation stack
|
||||
│ ├── index.tsx # Home screen — paired hosts list
|
||||
│ └── pair-scan.tsx # QR code scanning screen
|
||||
├── src/
|
||||
│ ├── terminal/ # Terminal WebView and xterm bridge
|
||||
│ └── transport/ # WebSocket RPC client
|
||||
├── scripts/
|
||||
│ ├── test-subscribe.ts # Desktop streaming repro without a phone
|
||||
│ └── mock-server.ts # Standalone mock WebSocket server
|
||||
└── assets/ # App icons and splash screen
|
||||
```
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.6",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "orca",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.stably.orca.mobile",
|
||||
"buildNumber": "1",
|
||||
"infoPlist": {
|
||||
"NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsLocalNetworking": true
|
||||
},
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"privacyManifests": {
|
||||
"NSPrivacyTracking": false,
|
||||
"NSPrivacyTrackingDomains": [],
|
||||
"NSPrivacyCollectedDataTypes": [],
|
||||
"NSPrivacyAccessedAPITypes": [
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryUserDefaults",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["CA92.1"]
|
||||
},
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategoryFileTimestamp",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["C617.1"]
|
||||
},
|
||||
{
|
||||
"NSPrivacyAccessedAPIType": "NSPrivacyAccessedAPICategorySystemBootTime",
|
||||
"NSPrivacyAccessedAPITypeReasons": ["35F9.1"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
},
|
||||
"usesCleartextTraffic": true,
|
||||
"allowBackup": false,
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 1
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/splash-icon.png",
|
||||
"backgroundColor": "#111111"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "Allow Orca to use the camera for QR code scanning.",
|
||||
"microphonePermission": false,
|
||||
"recordAudioAndroid": false
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"usesCleartextTraffic": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack, useRouter } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
|
||||
// Why: keeps the native splash screen visible until the React tree is mounted
|
||||
// and ready to render. Without this the user sees a blank white/black frame
|
||||
// between the native splash and the first React paint.
|
||||
SplashScreen.preventAutoHideAsync()
|
||||
|
||||
// Why: without this, expo-notifications silently drops notifications when
|
||||
// the app is in the foreground. Setting all three to true makes iOS/Android
|
||||
// display the banner, play the sound, and show the badge even while the
|
||||
// app is active. This runs once at module load time before any notification
|
||||
// is scheduled.
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false
|
||||
})
|
||||
})
|
||||
|
||||
// Why: extract the path+payload that follows the orca://pair anchor so we
|
||||
// can route it to the confirm screen. Accept either a hash payload
|
||||
// (`orca://pair#<base64>`, the QR / shared form) or a query param
|
||||
// (`orca://pair?code=<...>`, future-proof for share sheets that strip
|
||||
// fragments).
|
||||
function extractPairCode(url: string): string | null {
|
||||
if (!url.startsWith('orca://pair')) return null
|
||||
const hashIndex = url.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
return url.slice(hashIndex + 1) || null
|
||||
}
|
||||
const queryIndex = url.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
const params = new URLSearchParams(url.slice(queryIndex + 1))
|
||||
return params.get('code')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
|
||||
// Why: route `orca://pair#<code>` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
// covers cold-start (link tapped while app was closed); the listener
|
||||
// covers warm-start (link tapped while app is in memory).
|
||||
useEffect(() => {
|
||||
function handleUrl(url: string) {
|
||||
const code = extractPairCode(url)
|
||||
if (code) {
|
||||
router.push({ pathname: '/pair-confirm', params: { code } })
|
||||
}
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) handleUrl(url)
|
||||
})
|
||||
|
||||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// Why: hide the native splash only once the navigation Stack has been laid
|
||||
// out — this is the earliest moment the user will see actual app content.
|
||||
// Previously the splash hid when a placeholder View rendered, leaving a
|
||||
// grey gap before the real screen appeared.
|
||||
const onNavigatorLayout = useCallback(async () => {
|
||||
await SplashScreen.hideAsync()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<RpcClientProvider>
|
||||
<View style={styles.root} onLayout={onNavigatorLayout}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: { backgroundColor: colors.bgPanel },
|
||||
headerTintColor: colors.textPrimary,
|
||||
headerTitleStyle: { fontSize: 16, fontWeight: '600' },
|
||||
contentStyle: { backgroundColor: colors.bgBase },
|
||||
headerShadowVisible: false
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerShown: false,
|
||||
headerTitle: () => <OrcaLogo size={22} />
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="notifications" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="about" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="h" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</View>
|
||||
</RpcClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking, Platform } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Globe } from 'lucide-react-native'
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
import Constants from 'expo-constants'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
// Why: read version + native build identifier from expo-constants at
|
||||
// runtime so the About screen never drifts out of sync with app.json.
|
||||
// nativeBuildVersion is iOS buildNumber on iOS and versionCode on
|
||||
// Android — different concepts, same role (monotonic native build id).
|
||||
function getVersionLabel(): string {
|
||||
const version = Constants.expoConfig?.version ?? '?.?.?'
|
||||
const build =
|
||||
Platform.OS === 'ios'
|
||||
? Constants.expoConfig?.ios?.buildNumber
|
||||
: String(Constants.expoConfig?.android?.versionCode ?? '')
|
||||
return build ? `v${version} (${build})` : `v${version}`
|
||||
}
|
||||
|
||||
function GithubIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
function XIcon({ size = 16, color = colors.textSecondary }) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AboutScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>About</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.brand}>
|
||||
<OrcaLogo size={28} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
<Text style={styles.brandSub}>Open-source agent IDE for 100x builders</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://onOrca.dev')}
|
||||
>
|
||||
<Globe size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowValue}>onOrca.dev</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca')}
|
||||
>
|
||||
<GithubIcon />
|
||||
<Text style={styles.rowValue}>stablyai/orca</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://x.com/orca_build')}
|
||||
>
|
||||
<XIcon />
|
||||
<Text style={styles.rowValue}>@orca_build</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={styles.versionText}>{getVersionLabel()}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
brand: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
brandName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '800',
|
||||
color: colors.textPrimary,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
brandSub: {
|
||||
fontSize: 13,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowValue: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
versionText: {
|
||||
marginTop: spacing.lg,
|
||||
textAlign: 'center',
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
RefreshControl,
|
||||
Alert
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Check, RefreshCw, User } from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../src/transport/rpc-client'
|
||||
import { loadHosts } from '../../../src/transport/host-store'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { colors, spacing, typography, radii } from '../../../src/theme/mobile-theme'
|
||||
import { ClaudeIcon, OpenAIIcon } from '../../../src/components/AgentIcons'
|
||||
import {
|
||||
type AccountsSnapshot,
|
||||
type ProviderKey,
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
|
||||
export default function AccountsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { hostId } = useLocalSearchParams<{ hostId: string }>()
|
||||
|
||||
// Why: shared client per host. See docs/mobile-shared-client-per-host.md.
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const [hostName, setHostName] = useState<string>('')
|
||||
const [snapshot, setSnapshot] = useState<AccountsSnapshot | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
clientRef.current = client
|
||||
}, [client])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) return
|
||||
let stale = false
|
||||
void loadHosts().then((hosts) => {
|
||||
if (stale) return
|
||||
const host = hosts.find((h) => h.id === hostId)
|
||||
if (!host) {
|
||||
setError('Host not found')
|
||||
return
|
||||
}
|
||||
setHostName(host.name)
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [hostId])
|
||||
|
||||
// Why: subscribe to streaming snapshot updates so usage bars refresh in
|
||||
// place when the desktop's rate-limit poll completes (every 5 min) or
|
||||
// when the user switches accounts. Falls back to a one-shot accounts.list
|
||||
// if the subscription stream errors.
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') return
|
||||
const unsubscribe = client.subscribe('accounts.subscribe', null, (payload) => {
|
||||
if (!payload || typeof payload !== 'object') return
|
||||
const evt = payload as { type?: string; snapshot?: AccountsSnapshot }
|
||||
if ((evt.type === 'ready' || evt.type === 'snapshot') && evt.snapshot) {
|
||||
setSnapshot(evt.snapshot)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
return unsubscribe
|
||||
}, [client, connState])
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await client.sendRequest('accounts.list')
|
||||
if (res.ok) {
|
||||
setSnapshot((res as RpcSuccess).result as AccountsSnapshot)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(res.error.message)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [client])
|
||||
|
||||
const selectAccount = useCallback(
|
||||
async (provider: ProviderKey, accountId: string | null) => {
|
||||
if (!client) return
|
||||
setBusyAccountId(accountId ?? `${provider}:default`)
|
||||
const method = provider === 'claude' ? 'accounts.selectClaude' : 'accounts.selectCodex'
|
||||
try {
|
||||
const res = await client.sendRequest(method, { accountId })
|
||||
if (!res.ok) {
|
||||
Alert.alert('Could not switch account', res.error.message)
|
||||
} else {
|
||||
// Why: optimistic refresh — the streaming subscription will also
|
||||
// emit, but a one-shot keeps the UI responsive even if the stream
|
||||
// is temporarily disconnected.
|
||||
await refresh()
|
||||
}
|
||||
} catch (e) {
|
||||
Alert.alert('Could not switch account', e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setBusyAccountId(null)
|
||||
}
|
||||
},
|
||||
[client, refresh]
|
||||
)
|
||||
|
||||
const renderProviderSection = (provider: ProviderKey, title: string) => {
|
||||
if (!snapshot) return null
|
||||
const state = provider === 'claude' ? snapshot.claude : snapshot.codex
|
||||
const activeUsage = getActiveProviderRateLimits(snapshot, provider)
|
||||
const Icon = provider === 'claude' ? ClaudeIcon : OpenAIIcon
|
||||
return (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Icon size={14} />
|
||||
<Text style={styles.sectionHeading}>{title}</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
{/* System default row */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, null)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected'}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle}>System default</Text>
|
||||
<Text style={styles.rowSubtitle}>Use the agent's own login</Text>
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{state.activeAccountId === null ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === `${provider}:default` ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
{state.accounts.map((account) => {
|
||||
const isActive = state.activeAccountId === account.id
|
||||
const inactiveEntry = !isActive
|
||||
? getInactiveProviderUsage(snapshot, provider, account.id)
|
||||
: null
|
||||
const usage = isActive ? activeUsage : (inactiveEntry?.claude ?? null)
|
||||
const isFetching =
|
||||
(isActive && usage?.status === 'fetching') ||
|
||||
(!isActive && inactiveEntry?.isFetching === true)
|
||||
const session = usage?.session
|
||||
const weekly = usage?.weekly
|
||||
return (
|
||||
<View key={account.id}>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => selectAccount(provider, account.id)}
|
||||
disabled={busyAccountId !== null || connState !== 'connected' || isActive}
|
||||
>
|
||||
<View style={styles.rowMain}>
|
||||
<Text style={styles.rowTitle} numberOfLines={1}>
|
||||
{account.email}
|
||||
</Text>
|
||||
<View style={styles.usageRow}>
|
||||
<UsageBar
|
||||
label="5h"
|
||||
usedPercent={session?.usedPercent ?? null}
|
||||
unavailable={!session && !isFetching}
|
||||
loading={isFetching && !session}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weekly?.usedPercent ?? null}
|
||||
unavailable={!weekly && !isFetching}
|
||||
loading={isFetching && !weekly}
|
||||
/>
|
||||
</View>
|
||||
{usage?.error ? (
|
||||
<Text style={styles.errorText} numberOfLines={1}>
|
||||
{usage.error}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<View style={styles.rowTrailing}>
|
||||
{isActive ? (
|
||||
<Check size={16} color={colors.accentBlue} />
|
||||
) : busyAccountId === account.id ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<View style={styles.titleWrap}>
|
||||
<Text style={styles.heading}>Accounts</Text>
|
||||
{hostName ? (
|
||||
<Text style={styles.subheading} numberOfLines={1}>
|
||||
{hostName}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.iconButton}
|
||||
onPress={refresh}
|
||||
disabled={!client || refreshing || connState !== 'connected'}
|
||||
>
|
||||
{refreshing ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<RefreshCw size={18} color={colors.textSecondary} />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scroll, { paddingBottom: insets.bottom + spacing.xl }]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={refresh}
|
||||
tintColor={colors.textSecondary}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{connState !== 'connected' && !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.placeholderText}>Connecting to {hostName || 'host'}…</Text>
|
||||
</View>
|
||||
) : error && !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
) : !snapshot ? (
|
||||
<View style={styles.placeholder}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
<Text style={styles.placeholderText}>Loading accounts…</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{renderProviderSection('claude', 'Claude')}
|
||||
{renderProviderSection('codex', 'Codex')}
|
||||
<View style={styles.footerHint}>
|
||||
<User size={14} color={colors.textMuted} />
|
||||
<Text style={styles.footerHintText}>
|
||||
Add or re-authenticate accounts from desktop Settings → Accounts.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.sm,
|
||||
gap: spacing.sm
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
titleWrap: {
|
||||
flex: 1
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
subheading: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 1
|
||||
},
|
||||
scroll: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowMain: {
|
||||
flex: 1,
|
||||
gap: 4
|
||||
},
|
||||
// Why: fixed-width trailing slot so the usage bars in `rowMain` keep the
|
||||
// same width whether or not the row is currently selected (otherwise the
|
||||
// checkmark on the active account squeezes the bars narrower than the
|
||||
// inactive rows above/below it).
|
||||
rowTrailing: {
|
||||
width: 24,
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm
|
||||
},
|
||||
rowTitle: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSubtitle: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
usageRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
marginTop: 4
|
||||
},
|
||||
errorText: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.statusRed
|
||||
},
|
||||
placeholder: {
|
||||
paddingVertical: spacing.xl * 2,
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
placeholderText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
footerHint: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
footerHintText: {
|
||||
flex: 1,
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
}
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
import { Stack } from 'expo-router'
|
||||
import { colors } from '../../src/theme/mobile-theme'
|
||||
|
||||
export default function HostGroupLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.bgBase }
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
|
||||
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
|
||||
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,177 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { AppState, Linking, View, Text, StyleSheet, Pressable, Switch } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter, useFocusEffect } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import {
|
||||
loadPushNotificationsEnabled,
|
||||
savePushNotificationsEnabled
|
||||
} from '../src/storage/preferences'
|
||||
import {
|
||||
ensureNotificationPermissions,
|
||||
getNotificationPermissionState,
|
||||
type NotificationPermissionState
|
||||
} from '../src/notifications/mobile-notifications'
|
||||
|
||||
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
|
||||
granted: false,
|
||||
status: 'undetermined',
|
||||
canAskAgain: true
|
||||
}
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [pushEnabled, setPushEnabled] = useState(false)
|
||||
const [permissionState, setPermissionState] = useState(DEFAULT_PERMISSION_STATE)
|
||||
|
||||
const refreshSettings = useCallback(async () => {
|
||||
const [enabled, permission] = await Promise.all([
|
||||
loadPushNotificationsEnabled(),
|
||||
getNotificationPermissionState()
|
||||
])
|
||||
setPushEnabled(enabled)
|
||||
setPermissionState(permission)
|
||||
}, [])
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refreshSettings()
|
||||
}, [refreshSettings])
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
void refreshSettings()
|
||||
}
|
||||
})
|
||||
return () => subscription.remove()
|
||||
}, [refreshSettings])
|
||||
|
||||
const togglePush = async (value: boolean) => {
|
||||
if (value) {
|
||||
const granted = await ensureNotificationPermissions()
|
||||
const permission = await getNotificationPermissionState()
|
||||
setPermissionState(permission)
|
||||
if (!granted) {
|
||||
setPushEnabled(false)
|
||||
await savePushNotificationsEnabled(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
setPushEnabled(value)
|
||||
await savePushNotificationsEnabled(value)
|
||||
}
|
||||
|
||||
const switchEnabled = pushEnabled && permissionState.granted
|
||||
const notificationsBlocked = permissionState.status === 'denied'
|
||||
const hint = notificationsBlocked
|
||||
? 'Notifications are disabled in system settings.'
|
||||
: 'Receive notifications when an agent task completes on your desktop.'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Notifications</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Push Notifications</Text>
|
||||
<Switch
|
||||
value={switchEnabled}
|
||||
disabled={notificationsBlocked}
|
||||
onValueChange={(v) => void togglePush(v)}
|
||||
trackColor={{ false: colors.bgRaised, true: colors.textSecondary }}
|
||||
thumbColor={colors.textPrimary}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.hint}>{hint}</Text>
|
||||
{notificationsBlocked && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.settingsButton,
|
||||
pressed && styles.settingsButtonPressed
|
||||
]}
|
||||
onPress={() => void Linking.openSettings()}
|
||||
>
|
||||
<Text style={styles.settingsButtonText}>Open Settings</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
hint: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18,
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
settingsButton: {
|
||||
alignSelf: 'flex-start',
|
||||
marginHorizontal: spacing.md + 2,
|
||||
marginBottom: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
settingsButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
settingsButtonText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { parsePairingCode } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
|
||||
type Status = 'awaiting-confirm' | 'connecting' | 'error'
|
||||
|
||||
// Why: cap how long the user stares at "Connecting…" during pairing.
|
||||
// rpc-client retries forever by design (good for live sessions), but for
|
||||
// the *initial* pair we want a hard ceiling so a half-broken Tailscale
|
||||
// route surfaces an actionable error with the log visible, instead of
|
||||
// spinning silently. ~25s allows for one full connect-timeout + a retry.
|
||||
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
|
||||
export default function PairConfirmScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [offer, setOffer] = useState<PairingOffer | null>(null)
|
||||
const [status, setStatus] = useState<Status>('awaiting-confirm')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
|
||||
// Why: collect logs in a ref so the rpc-client callback (which closures
|
||||
// over the initial state setter) always sees the freshest list and we
|
||||
// batch fewer setState calls when entries arrive in bursts.
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.code) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Missing pairing code')
|
||||
return
|
||||
}
|
||||
const parsed = parsePairingCode(params.code)
|
||||
if (!parsed) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid pairing code')
|
||||
return
|
||||
}
|
||||
setOffer(parsed)
|
||||
}, [params.code])
|
||||
|
||||
async function confirm() {
|
||||
if (!offer) return
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect" error.
|
||||
let response: RpcResponse
|
||||
let timedOut = false
|
||||
const overallTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
client?.close()
|
||||
}, PAIRING_OVERALL_TIMEOUT_MS)
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
onLog: (entry) => {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
clearTimeout(overallTimer)
|
||||
client.close()
|
||||
client = null
|
||||
} catch (err) {
|
||||
clearTimeout(overallTimer)
|
||||
console.warn('[pair-confirm] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
client?.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
? 'Authentication failed — token may be expired'
|
||||
: `Server error: ${response.error.message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
const containerPadding = { paddingTop: insets.top + spacing.sm }
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={cancel}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.content}>
|
||||
{offer && status === 'awaiting-confirm' && (
|
||||
<>
|
||||
<Text style={styles.title}>Pair with this desktop?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
You opened a pairing link from your desktop. Confirm to add it to your hosts.
|
||||
</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
|
||||
<Text style={styles.primaryButtonText}>Pair</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.secondaryButton} onPress={cancel}>
|
||||
<Text style={styles.secondaryButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<>
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
<Text style={styles.connectingText}>Connecting…</Text>
|
||||
<View style={styles.logSlot}>
|
||||
<ConnectionLog entries={logs} title="Pairing log" />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
{logs.length > 0 && (
|
||||
<View style={styles.logSlot}>
|
||||
<ConnectionLog entries={logs} title="Pairing log" />
|
||||
</View>
|
||||
)}
|
||||
<Pressable style={styles.primaryButton} onPress={cancel}>
|
||||
<Text style={styles.primaryButtonText}>Back to home</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
// Why: nudges the centered group slightly above the geometric
|
||||
// middle so the eye reads it as visually centered above the home
|
||||
// indicator / nav bar.
|
||||
paddingBottom: spacing.xl * 2
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm,
|
||||
textAlign: 'center'
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center'
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button,
|
||||
alignItems: 'center'
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg,
|
||||
textAlign: 'center'
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.md
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,495 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ActivityIndicator, Linking } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
// silent infinite spinner.
|
||||
const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
|
||||
function Step({ number, text }: { number: number; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
<View style={styles.stepBadge}>
|
||||
<Text style={styles.stepNumber}>{number}</Text>
|
||||
</View>
|
||||
<Text style={styles.stepText}>{text}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [pasteVisible, setPasteVisible] = useState(false)
|
||||
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const processingRef = useRef(false)
|
||||
|
||||
const handleBarCodeScanned = useCallback(
|
||||
({ data }: { data: string }) => {
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const offer = decodePairingUrl(data)
|
||||
if (!offer) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid Orca QR code')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void testAndSave(offer)
|
||||
},
|
||||
[router]
|
||||
)
|
||||
|
||||
const handlePasteSubmit = useCallback((input: string) => {
|
||||
setPasteVisible(false)
|
||||
if (processingRef.current) return
|
||||
processingRef.current = true
|
||||
|
||||
const offer = parsePairingCode(input)
|
||||
if (!offer) {
|
||||
setStatus('error')
|
||||
setErrorMessage('Not a valid pairing code — copy it from your computer and paste again')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
void testAndSave(offer)
|
||||
}, [])
|
||||
|
||||
async function testAndSave(offer: PairingOffer) {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect — same network?" error. Pairing reached the
|
||||
// desktop fine; the failure is local persistence.
|
||||
let response: RpcResponse
|
||||
let timedOut = false
|
||||
const overallTimer = setTimeout(() => {
|
||||
timedOut = true
|
||||
client?.close()
|
||||
}, PAIRING_OVERALL_TIMEOUT_MS)
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
onLog: (entry) => {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
clearTimeout(overallTimer)
|
||||
client.close()
|
||||
client = null
|
||||
} catch (err) {
|
||||
clearTimeout(overallTimer)
|
||||
console.warn('[pair] connect failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
processingRef.current = false
|
||||
client?.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
processingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
setStatus('scanning')
|
||||
setErrorMessage('')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
// Why: bottom inset accounts for Android 3-button nav bars and iOS
|
||||
// home-indicator areas that would otherwise overlap the 'Or paste
|
||||
// pairing code' button at the bottom of the scan screen.
|
||||
const containerPadding = {
|
||||
paddingTop: insets.top + spacing.sm,
|
||||
paddingBottom: insets.bottom + spacing.sm
|
||||
}
|
||||
|
||||
if (!permission) {
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (!permission.granted) {
|
||||
const canAskAgain = permission.canAskAgain !== false
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.title}>
|
||||
{canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'}
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{canAskAgain
|
||||
? 'Scan the QR code from Orca on your desktop, or paste the pairing code instead.'
|
||||
: 'Enable camera access in Settings, or paste the pairing code instead.'}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.primaryButton}
|
||||
onPress={canAskAgain ? requestPermission : () => void Linking.openSettings()}
|
||||
>
|
||||
{canAskAgain && <QrCode size={16} color={colors.bgBase} />}
|
||||
<Text style={styles.primaryButtonText}>
|
||||
{canAskAgain ? 'Continue' : 'Open Settings'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
|
||||
onPress={() => setPasteVisible(true)}
|
||||
>
|
||||
<ClipboardIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.pasteButtonText}>Paste code instead</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<TextInputModal
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair#... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.steps}>
|
||||
<Step number={1} text="Open Orca on your computer" />
|
||||
<Step number={2} text="Go to Settings → Mobile" />
|
||||
<Step number={3} text="Scan the QR code" />
|
||||
</View>
|
||||
|
||||
{status === 'scanning' && (
|
||||
<>
|
||||
{/* Why: unmount the camera while the paste sheet is open. The
|
||||
user has clearly chosen the paste path; keeping the camera
|
||||
streaming behind a sheet wastes power and looks weird if
|
||||
they cancel the sheet and the QR was scanned silently in
|
||||
the meantime. */}
|
||||
{!pasteVisible && (
|
||||
<View style={styles.cameraWrap}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
|
||||
onBarcodeScanned={handleBarCodeScanned}
|
||||
/>
|
||||
<View style={styles.reticle} pointerEvents="none">
|
||||
<View style={[styles.corner, styles.cornerTL]} />
|
||||
<View style={[styles.corner, styles.cornerTR]} />
|
||||
<View style={[styles.corner, styles.cornerBL]} />
|
||||
<View style={[styles.corner, styles.cornerBR]} />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
{pasteVisible && <View style={styles.cameraPlaceholder} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.pasteButton, pressed && styles.pasteButtonPressed]}
|
||||
onPress={() => setPasteVisible(true)}
|
||||
>
|
||||
<ClipboardIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.pasteButtonText}>Or paste pairing code</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'connecting' && (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
<Text style={styles.connectingText}>Connecting…</Text>
|
||||
<View style={styles.logSlot}>
|
||||
<ConnectionLog entries={logs} title="Pairing log" />
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||
{logs.length > 0 && (
|
||||
<View style={styles.logSlot}>
|
||||
<ConnectionLog entries={logs} title="Pairing log" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.errorActions}>
|
||||
<Pressable style={styles.primaryButton} onPress={retry}>
|
||||
<Text style={styles.primaryButtonText}>Try Again</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryButton,
|
||||
pressed && styles.pasteButtonPressed
|
||||
]}
|
||||
onPress={() => {
|
||||
retry()
|
||||
setPasteVisible(true)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.secondaryButtonText}>Paste code instead</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TextInputModal
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair#... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
marginLeft: 7
|
||||
},
|
||||
step: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
stepBadge: {
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: 11,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
stepNumber: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: colors.textSecondary
|
||||
},
|
||||
stepText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
cameraWrap: {
|
||||
flex: 1,
|
||||
borderRadius: radii.camera,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
// Why: holds the layout slot while the camera is unmounted during
|
||||
// paste, so the bottom action button doesn't snap up to fill the
|
||||
// empty space.
|
||||
cameraPlaceholder: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.camera
|
||||
},
|
||||
camera: {
|
||||
...StyleSheet.absoluteFillObject
|
||||
},
|
||||
reticle: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
corner: {
|
||||
position: 'absolute',
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderColor: 'rgba(255,255,255,0.7)'
|
||||
},
|
||||
cornerTL: {
|
||||
top: '30%',
|
||||
left: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderTopLeftRadius: 6
|
||||
},
|
||||
cornerTR: {
|
||||
top: '30%',
|
||||
right: '20%',
|
||||
borderTopWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderTopRightRadius: 6
|
||||
},
|
||||
cornerBL: {
|
||||
bottom: '30%',
|
||||
left: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderLeftWidth: 2.5,
|
||||
borderBottomLeftRadius: 6
|
||||
},
|
||||
cornerBR: {
|
||||
bottom: '30%',
|
||||
right: '20%',
|
||||
borderBottomWidth: 2.5,
|
||||
borderRightWidth: 2.5,
|
||||
borderBottomRightRadius: 6
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
subtitle: {
|
||||
maxWidth: 310,
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
connectingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
marginTop: spacing.lg
|
||||
},
|
||||
logSlot: {
|
||||
width: '100%',
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
lineHeight: 20
|
||||
},
|
||||
primaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
backgroundColor: colors.textPrimary,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
pasteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
pasteButtonPressed: {
|
||||
opacity: 0.6
|
||||
},
|
||||
pasteButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
errorActions: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
secondaryButton: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Bell,
|
||||
Wrench,
|
||||
Shield,
|
||||
LifeBuoy,
|
||||
Terminal as TerminalIcon
|
||||
} from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Settings</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/terminal-settings')}
|
||||
>
|
||||
<TerminalIcon size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Terminal</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/notifications')}
|
||||
>
|
||||
<Bell size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Notifications</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/troubleshoot')}
|
||||
>
|
||||
<Wrench size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Troubleshooting</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => router.push('/about')}
|
||||
>
|
||||
<Info size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>About</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={[styles.section, styles.sectionSpacer]}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
|
||||
>
|
||||
<Shield size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Privacy Policy</Text>
|
||||
</Pressable>
|
||||
<View style={styles.separator} />
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
|
||||
>
|
||||
<LifeBuoy size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.rowLabel}>Support</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionSpacer: {
|
||||
marginTop: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowLabel: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { View, Text, StyleSheet, Pressable, ScrollView } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, ChevronRight, Smartphone } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import type { HostProfile } from '../src/transport/types'
|
||||
import { useAllHostClients } from '../src/transport/client-context'
|
||||
import type { RpcClient } from '../src/transport/rpc-client'
|
||||
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
|
||||
|
||||
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
|
||||
|
||||
const AUTO_RESTORE_FIT_OPTIONS: (PickerOption<RestoreValue> & { ms: number | null })[] = [
|
||||
{ value: 'indefinite', label: 'Keep at phone size (default)', ms: null },
|
||||
{ value: '60s', label: 'After 1 minute', ms: 60_000 },
|
||||
{ value: '5m', label: 'After 5 minutes', ms: 5 * 60_000 },
|
||||
{ value: '30m', label: 'After 30 minutes', ms: 30 * 60_000 }
|
||||
]
|
||||
|
||||
function valueFromMs(ms: number | null | undefined): RestoreValue {
|
||||
if (ms == null) return 'indefinite'
|
||||
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
|
||||
if (exact) return exact.value
|
||||
// Why: server may return a non-preset ms (custom value, future preset,
|
||||
// or server-side clamp). Snap to the closest finite preset so the
|
||||
// picker's selected radio agrees with the row sublabel rendered by
|
||||
// autoRestoreSummary ("After Xs").
|
||||
let closest: (typeof AUTO_RESTORE_FIT_OPTIONS)[number] | null = null
|
||||
let bestDelta = Infinity
|
||||
for (const opt of AUTO_RESTORE_FIT_OPTIONS) {
|
||||
if (opt.ms == null) continue
|
||||
const delta = Math.abs(opt.ms - ms)
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta
|
||||
closest = opt
|
||||
}
|
||||
}
|
||||
return closest ? closest.value : 'indefinite'
|
||||
}
|
||||
|
||||
function autoRestoreSummary(ms: number | null | undefined): string {
|
||||
if (ms === undefined) return '…'
|
||||
if (ms === null) return AUTO_RESTORE_FIT_OPTIONS[0]!.label
|
||||
const exact = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.ms === ms)
|
||||
return exact ? exact.label : `After ${Math.round(ms / 1000)}s`
|
||||
}
|
||||
|
||||
function HostFitRow({
|
||||
client,
|
||||
hostName,
|
||||
ms,
|
||||
onPress
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
hostName: string
|
||||
ms: number | null | undefined
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
|
||||
onPress={onPress}
|
||||
disabled={!client}
|
||||
>
|
||||
<Smartphone size={16} color={colors.textSecondary} />
|
||||
<View style={styles.rowContent}>
|
||||
<Text style={styles.rowLabel}>{hostName}</Text>
|
||||
<Text style={styles.rowSublabel}>{autoRestoreSummary(ms)}</Text>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TerminalSettingsScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [hosts, setHosts] = useState<HostProfile[]>([])
|
||||
useEffect(() => {
|
||||
void loadHosts().then(setHosts)
|
||||
}, [])
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const hostClients = useAllHostClients(hostIds)
|
||||
|
||||
// Why: per-host current value, lazily fetched. We keep state at the
|
||||
// screen level rather than per-row so the picker can render at root
|
||||
// level — embedding PickerModal inside a row clipped its BottomDrawer
|
||||
// absoluteFill backdrop to the ScrollView content frame and made the
|
||||
// drawer appear cut-off.
|
||||
const [hostMs, setHostMs] = useState<Record<string, number | null | undefined>>({})
|
||||
const [pickerHostId, setPickerHostId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
for (const host of hosts) {
|
||||
const entry = hostClients.find((e) => e.hostId === host.id)
|
||||
const client = entry?.client ?? null
|
||||
if (!client) continue
|
||||
void client
|
||||
.sendRequest('terminal.getAutoRestoreFit')
|
||||
.then((resp) => {
|
||||
if (cancelled) return
|
||||
const value = (resp as { ms?: number | null } | null)?.ms
|
||||
setHostMs((prev) => ({ ...prev, [host.id]: value === undefined ? null : value }))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHostMs((prev) => ({ ...prev, [host.id]: null }))
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hosts, hostClients])
|
||||
|
||||
async function selectValue(hostId: string, value: RestoreValue) {
|
||||
const entry = hostClients.find((e) => e.hostId === hostId)
|
||||
const client = entry?.client ?? null
|
||||
if (!client) return
|
||||
const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
|
||||
if (!opt) return
|
||||
setHostMs((prev) => ({ ...prev, [hostId]: opt.ms }))
|
||||
try {
|
||||
const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
|
||||
ms: opt.ms
|
||||
})) as { ms?: number | null } | null
|
||||
const finalMs = resp?.ms === undefined ? null : resp.ms
|
||||
setHostMs((prev) => ({ ...prev, [hostId]: finalMs }))
|
||||
} catch {
|
||||
try {
|
||||
const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as {
|
||||
ms?: number | null
|
||||
} | null
|
||||
setHostMs((prev) => ({
|
||||
...prev,
|
||||
[hostId]: resp?.ms === undefined ? null : resp.ms
|
||||
}))
|
||||
} catch {
|
||||
// give up silently — the next mount retries
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pickerHost = pickerHostId ? hosts.find((h) => h.id === pickerHostId) : null
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Terminal</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<Text style={styles.groupHeading}>WHEN YOU LEAVE THE APP</Text>
|
||||
<Text style={styles.groupDescription}>
|
||||
While you're using a terminal on your phone, Orca shrinks it to fit your screen. When
|
||||
you close the app or switch away, this controls whether it stays at phone size (so
|
||||
interactive CLI tools don't reflow) or resizes back to your desktop. You can always
|
||||
tap Restore on the terminal banner to resize it manually.
|
||||
</Text>
|
||||
|
||||
{hosts.length === 0 ? (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
<Text style={styles.emptyText}>
|
||||
No paired desktops yet. Pair one to control terminal behavior.
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.section, styles.sectionTopGap]}>
|
||||
{hosts.map((host, idx) => {
|
||||
const entry = hostClients.find((e) => e.hostId === host.id)
|
||||
return (
|
||||
<View key={host.id}>
|
||||
{idx > 0 && <View style={styles.separator} />}
|
||||
<HostFitRow
|
||||
client={entry?.client ?? null}
|
||||
hostName={host.name}
|
||||
ms={hostMs[host.id]}
|
||||
onPress={() => setPickerHostId(host.id)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<PickerModal<RestoreValue>
|
||||
visible={pickerHost != null}
|
||||
title={pickerHost ? `Restore ${pickerHost.name}` : ''}
|
||||
options={AUTO_RESTORE_FIT_OPTIONS}
|
||||
selected={valueFromMs(pickerHost ? hostMs[pickerHost.id] : null)}
|
||||
onSelect={(v) => {
|
||||
if (pickerHost) void selectValue(pickerHost.id, v)
|
||||
}}
|
||||
onClose={() => setPickerHostId(null)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: 0
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
groupHeading: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.xs,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
groupDescription: {
|
||||
fontSize: typography.bodySize - 1,
|
||||
color: colors.textSecondary,
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
sectionTopGap: {
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textSecondary,
|
||||
padding: spacing.md
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
rowContent: {
|
||||
flex: 1
|
||||
},
|
||||
rowLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
rowSublabel: {
|
||||
fontSize: typography.bodySize - 2,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,455 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Platform
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
WifiOff,
|
||||
Shield,
|
||||
Monitor,
|
||||
Clock,
|
||||
Globe,
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle
|
||||
} from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
|
||||
type DiagnosticStatus = 'idle' | 'running' | 'done'
|
||||
|
||||
type CheckResult = {
|
||||
label: string
|
||||
status: 'pass' | 'fail' | 'warn'
|
||||
detail: string
|
||||
}
|
||||
|
||||
type TroubleshootSection = {
|
||||
id: string
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
steps: string[]
|
||||
}
|
||||
|
||||
const sections: TroubleshootSection[] = [
|
||||
{
|
||||
id: 'wifi',
|
||||
icon: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
title: 'Different WiFi Networks',
|
||||
steps: [
|
||||
'Both devices must be on the same local network.',
|
||||
'Ethernet and WiFi must share the same subnet.',
|
||||
'Try reconnecting WiFi on both devices.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'firewall',
|
||||
icon: <Shield size={16} color={colors.textSecondary} />,
|
||||
title: 'Firewall Blocking Port 6768',
|
||||
steps: [
|
||||
'macOS: System Settings → Network → Firewall — allow Orca.',
|
||||
'Windows: Defender Firewall → Allow app — enable Orca for Private networks.',
|
||||
'Linux: sudo ufw allow 6768',
|
||||
'Corporate/school networks may block P2P — try a personal hotspot.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'desktop',
|
||||
icon: <Monitor size={16} color={colors.textSecondary} />,
|
||||
title: 'Desktop App Not Running',
|
||||
steps: [
|
||||
'Orca must be open on your desktop to accept connections.',
|
||||
'Try restarting Orca — the companion server starts on launch.',
|
||||
'After an update, you may need to re-pair via QR code.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'timeout',
|
||||
icon: <Clock size={16} color={colors.textSecondary} />,
|
||||
title: 'Connection Timeout',
|
||||
steps: [
|
||||
'Check WiFi signal strength on your phone.',
|
||||
'Go back to the host list and tap your host to retry.',
|
||||
'Restart both apps if timeouts persist.'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vpn',
|
||||
icon: <Globe size={16} color={colors.textSecondary} />,
|
||||
title: 'VPN Interference',
|
||||
steps: [
|
||||
'VPNs can route local traffic through a remote server.',
|
||||
'Disable the VPN or enable split tunneling / "Allow LAN".'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function StatusIcon({ status }: { status: CheckResult['status'] }) {
|
||||
switch (status) {
|
||||
case 'pass':
|
||||
return <CheckCircle2 size={14} color={colors.statusGreen} />
|
||||
case 'fail':
|
||||
return <XCircle size={14} color={colors.statusRed} />
|
||||
case 'warn':
|
||||
return <AlertTriangle size={14} color={colors.textMuted} />
|
||||
}
|
||||
}
|
||||
|
||||
export default function TroubleshootScreen() {
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
|
||||
const [checks, setChecks] = useState<CheckResult[]>([])
|
||||
const abortRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleSection = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const runDiagnostics = useCallback(async () => {
|
||||
abortRef.current = false
|
||||
setDiagnosticStatus('running')
|
||||
setChecks([])
|
||||
|
||||
const results: CheckResult[] = []
|
||||
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
results.push(
|
||||
hosts.length > 0
|
||||
? { label: 'Paired hosts', status: 'pass', detail: `${hosts.length} paired` }
|
||||
: { label: 'Paired hosts', status: 'fail', detail: 'None — scan a QR to pair' }
|
||||
)
|
||||
} catch {
|
||||
results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
setChecks([...results])
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 5000)
|
||||
const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', {
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeout)
|
||||
results.push(
|
||||
resp.ok
|
||||
? { label: 'Internet', status: 'pass', detail: 'Connected' }
|
||||
: { label: 'Internet', status: 'warn', detail: 'Unexpected response' }
|
||||
)
|
||||
} catch {
|
||||
results.push({ label: 'Internet', status: 'fail', detail: 'No connection' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
setChecks([...results])
|
||||
|
||||
try {
|
||||
const hosts = await loadHosts()
|
||||
for (const host of hosts) {
|
||||
if (abortRef.current) return
|
||||
const reachable = await testHostReachability(host.endpoint)
|
||||
results.push({
|
||||
label: host.name,
|
||||
status: reachable ? 'pass' : 'fail',
|
||||
detail: reachable
|
||||
? `Reachable at ${formatEndpoint(host.endpoint)}`
|
||||
: `Cannot reach ${formatEndpoint(host.endpoint)}`
|
||||
})
|
||||
setChecks([...results])
|
||||
}
|
||||
} catch {
|
||||
results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' })
|
||||
}
|
||||
|
||||
if (abortRef.current) return
|
||||
|
||||
results.push({
|
||||
label: 'Platform',
|
||||
status: 'pass',
|
||||
detail: `${Platform.OS} ${Platform.Version ?? ''}`
|
||||
})
|
||||
|
||||
setChecks([...results])
|
||||
setDiagnosticStatus('done')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
<View style={styles.topRow}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<Text style={styles.heading}>Troubleshooting</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.diagnosticButton,
|
||||
pressed && styles.diagnosticButtonPressed,
|
||||
diagnosticStatus === 'running' && styles.diagnosticButtonDisabled
|
||||
]}
|
||||
onPress={runDiagnostics}
|
||||
disabled={diagnosticStatus === 'running'}
|
||||
>
|
||||
{diagnosticStatus === 'running' ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Activity size={16} color={colors.textPrimary} />
|
||||
)}
|
||||
<Text style={styles.diagnosticButtonLabel}>
|
||||
{diagnosticStatus === 'running'
|
||||
? 'Running…'
|
||||
: diagnosticStatus === 'done'
|
||||
? 'Run again'
|
||||
: 'Run diagnostics'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
{checks.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
{checks.map((check, i) => (
|
||||
<View key={i}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<View style={styles.checkRow}>
|
||||
<StatusIcon status={check.status} />
|
||||
<Text style={styles.checkLabel}>{check.label}</Text>
|
||||
<Text
|
||||
style={[styles.checkDetail, check.status === 'fail' && styles.checkDetailFail]}
|
||||
>
|
||||
{check.detail}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionHeading}>Common issues</Text>
|
||||
|
||||
<View style={styles.section}>
|
||||
{sections.map((section, i) => (
|
||||
<View key={section.id}>
|
||||
{i > 0 && <View style={styles.separator} />}
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.accordionHeader, pressed && styles.rowPressed]}
|
||||
onPress={() => toggleSection(section.id)}
|
||||
>
|
||||
{section.icon}
|
||||
<Text style={styles.accordionTitle}>{section.title}</Text>
|
||||
{expandedId === section.id ? (
|
||||
<ChevronUp size={16} color={colors.textMuted} />
|
||||
) : (
|
||||
<ChevronDown size={16} color={colors.textMuted} />
|
||||
)}
|
||||
</Pressable>
|
||||
{expandedId === section.id && (
|
||||
<View style={styles.accordionBody}>
|
||||
{section.steps.map((step, j) => (
|
||||
<View key={j} style={styles.stepRow}>
|
||||
<Text style={styles.bullet}>•</Text>
|
||||
<Text style={styles.stepText}>{step}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={{ height: spacing.xl }} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Why: WebSocket reachability is tested by opening a connection and waiting for
|
||||
// the server to respond with any frame, or timing out after 4 seconds.
|
||||
// We don't complete the E2EE handshake — just verify the endpoint is listening.
|
||||
async function testHostReachability(endpoint: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
resolve(false)
|
||||
}, 4000)
|
||||
|
||||
const ws = new WebSocket(endpoint)
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve(true)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatEndpoint(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
return url.host
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
backButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
heading: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
scroll: {
|
||||
flex: 1
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
diagnosticButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: 10,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
diagnosticButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
diagnosticButtonDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
diagnosticButtonLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
checkRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
checkLabel: {
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
checkDetail: {
|
||||
flex: 1,
|
||||
textAlign: 'right',
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted
|
||||
},
|
||||
checkDetailFail: {
|
||||
color: colors.statusRed
|
||||
},
|
||||
sectionHeading: {
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
color: colors.textMuted,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.xs
|
||||
},
|
||||
section: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
separator: {
|
||||
height: StyleSheet.hairlineWidth,
|
||||
backgroundColor: colors.borderSubtle,
|
||||
marginHorizontal: spacing.md
|
||||
},
|
||||
rowPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
accordionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm + 2,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md + 2
|
||||
},
|
||||
accordionTitle: {
|
||||
flex: 1,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500',
|
||||
color: colors.textPrimary
|
||||
},
|
||||
accordionBody: {
|
||||
paddingHorizontal: spacing.md + 2,
|
||||
paddingBottom: spacing.md,
|
||||
gap: spacing.xs + 2
|
||||
},
|
||||
stepRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm
|
||||
},
|
||||
bullet: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
},
|
||||
stepText: {
|
||||
flex: 1,
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
lineHeight: 18
|
||||
}
|
||||
})
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user