Merge branch 'main' into nwparker/perf-skip-remote-local-repo-events

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-12 18:15:16 -07:00
co-authored by Orca
1725 changed files with 105686 additions and 36612 deletions
+5 -7
View File
@@ -56,17 +56,15 @@ CI enforces this for `src/preload/` and `src/shared/` — see `docs/preload-type
## Pull Requests
Each pull request should:
Each pull request should follow [`.github/pull_request_template.md`](./pull_request_template.md). In particular:
- explain the user-visible change
- stay focused on a single topic when possible
- include screenshots or screen recordings for new UI or behavior changes
- open with an ELI5 of the change (plain language paragraph; the PR title is the one-liner)
- explain what changed and why, and stay focused on a single topic when possible
- for any UI or interaction change, attach **before and after** screenshots (or short videos); if there is no visual change, say `No visual change` and why
- include high-quality tests when behavior changes or bug fixes warrant them
- include a brief code review summary from your AI coding agent that explicitly checks cross-platform compatibility, SSH/remote/local compatibility, supported agent and integration compatibility, performance risk, UI quality when applicable, and basic security risk
- mention any platform-specific, remote/SSH-specific, agent-specific, integration-specific, or git-provider-specific behavior and testing notes
- **Include your X (Twitter) handle!** We love giving shoutouts to our contributors when we merge features on [@orca_build](https://x.com/orca_build).
If there is no visual change, say that explicitly in the PR description.
- **Include your X (Twitter) handle** in the PR template Author section — we shout out contributors when we merge features on [@orca_build](https://x.com/orca_build).
## Release Process
+39 -17
View File
@@ -1,29 +1,51 @@
## Summary
## ELI5
Describe the user-visible change.
<!-- Simple high-level explanation -->
## Screenshots
## What Changed
- Add screenshots or a screen recording for any new or changed UI behavior.
- If there is no visual change, say `No visual change`.
<!-- Describe the change clearly and keep scope tight. -->
## Why
<!-- What problem does this solve, and why is this approach right? -->
## Linked Issue
<!-- Link the issue this PR addresses, there should ALWAYS be one -->
Fixes #
## Visual Proof
<!-- REQUIRED for UI / behavior changes. Please attach a BEFORE and AFTER that can easily tabbed/switched. Use videos for when appropriate over screenshots -->
<!-- If there is truly no visual or interaction change, write exactly: `N/A` and briefly say why. -->
<!-- For attachments NEVER add directly to the PR files (do not commit to files), use `gh image` extension or drag + drop (works for any attachment) -->
## Testing
- [ ] `pnpm lint`
- [ ] `pnpm typecheck`
- [ ] `pnpm test`
- [ ] `pnpm build`
- [ ] Added or updated high-quality tests that would catch regressions, or explained why tests were not needed
<!-- How did you verify this? Steps a reviewer can follow. Which platforms did you actually test (macOS / Linux / Windows / SSH)? -->
## AI Review Report
- [ ] I manually tested these changes locally
- [ ] Automated tests added/updated, or explained why not below
Summarize the code review you ran with your AI coding agent. Include the main risks it checked, what it flagged, and what you changed or verified as a result.
Confirm that the review explicitly checked cross-platform compatibility for macOS, Linux, and Windows, including shortcuts, labels, paths, shell behavior, and any Electron-specific platform differences touched by this PR.
## Security Audit
## AI Disclosure
<!-- For all external contributors only: Which AI model if anyone was used, please state the details -->
Provide a basic security audit summary from your AI coding agent. Call out any input handling, command execution, path handling, auth, secrets, dependency, or IPC risks that were reviewed, plus any follow-up needed.
## Review
## Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
Call out any platform-specific behavior, risks, or follow-up work.
## Checklist
- [ ] This PR is small and focused
- [ ] I explained what changed and why (including ELI5)
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
- [ ] Self-reviewed for correctness, security, and performance
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
- [ ] `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` pass (or CI will cover; local preferred)
## Author
- X / Twitter: @your_handle
<!-- Optional but appreciated -->
+53 -2
View File
@@ -176,6 +176,12 @@ jobs:
shell_contracts:
name: shell contracts
runs-on: ubuntu-latest
env:
# Why: the suites below gate their live fish tests on the binary, which is
# right on a developer machine and wrong here — this job is a required check
# and its fish lane is the only end-to-end guard for #9993, so a skip would
# report green with nothing exercised. Turns those skips into failures.
ORCA_REQUIRE_FISH: '1'
steps:
- name: Checkout
@@ -186,8 +192,46 @@ jobs:
# Why fish: shell-ready.test.ts gates its live fish test on the binary being
# present, so without this the fish barrier is only covered by config-shape
# assertions and never actually exercised.
# Why release-4: DECSET 2031 arming lives in the fish 4.0 Rust tty_handoff, and
# fish-color-scheme-child-stdin.node-pty.test.ts (#9993) needs it. Noble ships
# 3.7, so the PPA is what makes that lane real.
- name: Install zsh and fish
run: sudo apt-get update && sudo apt-get install -y zsh fish
run: |
# Why the update/PPA/fish steps are tolerant: a repo the runner image already
# ships can lack a Release file for this suite, and a failed add-apt-repository
# still leaves its list entry behind — either makes `apt-get update` exit
# non-zero and would red this required check over something unrelated to the
# PR. Every fish outcome is judged by the version gate below instead, so only
# the zsh install (which has no such gate) stays fatal here.
sudo apt-get update || true
# Why retry only here: adding the PPA is the network-flaky step, and the
# version gate below is fatal, so a transient Launchpad blip would
# otherwise red a required check on PRs unrelated to shells.
for attempt in 1 2 3; do
sudo add-apt-repository -y ppa:fish-shell/release-4 && break
echo "add-apt-repository attempt ${attempt} failed; retrying" >&2
sudo add-apt-repository -y -r ppa:fish-shell/release-4 || true
sleep 5
done
sudo apt-get update || true
# Why both shells on one line: pr-workflow-parallelism.test.mjs parses only the
# first install command in this step to prove the lane really installs them.
sudo apt-get install -y zsh fish
# Separate from the install so the failure names the contract, not an apt error.
# ORCA_REQUIRE_FISH re-checks this at test time; this step just fails in seconds
# instead of after a full dependency install.
- name: Require fish 4+
run: |
version="$(fish --version 2>/dev/null || true)"
major="${version##*version }"
major="${major%%.*}"
case "$major" in '' | *[!0-9]*) major=0 ;; esac
echo "${version:-<fish not installed>}"
if [ "$major" -lt 4 ]; then
echo "::error::shell contracts needs fish 4+ (DECSET 2031 arming, #9993) but got '${version:-none}'. Fix the ppa:fish-shell/release-4 install rather than letting the fish lane skip." >&2
exit 1
fi
- uses: ./.github/actions/install-node-dependencies
with:
@@ -195,12 +239,14 @@ jobs:
- name: Test real shell contracts
run: |
pnpm exec vitest run --config config/vitest.config.ts \
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=1 \
src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
src/main/daemon/shell-ready.test.ts \
src/main/daemon/node-pty-fd-leak.test.ts \
src/main/providers/local-pty-shell-ready.test.ts \
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/posix-command-path-lookup.test.ts
test:
@@ -230,11 +276,13 @@ jobs:
- name: Test shard
run: |
pnpm exec vitest run --config config/vitest.config.ts \
--exclude=src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts \
--exclude=src/main/daemon/shell-ready.test.ts \
--exclude=src/main/daemon/node-pty-fd-leak.test.ts \
--exclude=src/main/providers/local-pty-shell-ready.test.ts \
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--exclude=tests/e2e/cross-version-wire/** \
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
@@ -378,6 +426,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test Windows PowerShell CLI boundary
run: pnpm exec vitest run --config config/vitest.config.ts src/main/cli/wsl-cli-powershell-boundary.test.ts
- name: Build package inputs
run: pnpm run build:release
+98
View File
@@ -0,0 +1,98 @@
name: Release Policy
on:
release:
types:
- published
- edited
permissions:
contents: write
concurrency:
group: release-policy
cancel-in-progress: false
jobs:
enforce:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Enforce release policy
uses: actions/github-script@v8
with:
script: |
const release = context.payload.release;
const tag = release.tag_name;
const author = release.author?.login;
const number = "(?:0|[1-9][0-9]*)";
const version = `${number}\\.${number}\\.${number}`;
const stableTag = new RegExp(`^v${version}$`);
const prereleaseTag = new RegExp(
`^(?:v${version}-rc\\.${number}(?:\\.[0-9A-Za-z]+)?|mobile(?:-android)?-v${version})$`,
);
const expectedPrerelease = prereleaseTag.test(tag);
const allowed = author === "github-actions[bot]" &&
(stableTag.test(tag) || expectedPrerelease);
const { owner, repo } = context.repo;
async function restoreLatestStable() {
const releases = await github.paginate(github.rest.repos.listReleases, {
owner,
repo,
per_page: 100,
});
const stable = releases
.filter((candidate) =>
!candidate.draft &&
!candidate.prerelease &&
candidate.author?.login === "github-actions[bot]" &&
stableTag.test(candidate.tag_name),
)
.sort((left, right) => {
const a = left.tag_name.slice(1).split(".").map(Number);
const b = right.tag_name.slice(1).split(".").map(Number);
return a.reduce((result, part, index) => result || part - b[index], 0);
})
.at(-1);
if (stable) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: stable.id,
make_latest: "true",
});
}
}
if (allowed) {
if (release.prerelease !== expectedPrerelease) {
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
prerelease: expectedPrerelease,
make_latest: expectedPrerelease ? "false" : "legacy",
});
}
await restoreLatestStable();
return;
}
await github.rest.repos.updateRelease({
owner,
repo,
release_id: release.id,
draft: true,
prerelease: true,
make_latest: "false",
});
await restoreLatestStable();
await github.rest.repos.deleteRelease({ owner, repo, release_id: release.id });
try {
await github.rest.git.deleteRef({ owner, repo, ref: `tags/${tag}` });
} catch (error) {
if (error.status !== 404) throw error;
}
core.warning(`Deleted unauthorized release ${tag} created by ${author || "unknown"}.`);
@@ -77,7 +77,6 @@ jobs:
'!config/reliability-gates.jsonc',
'!config/max-lines-baseline.txt',
'!config/vitest.config.ts',
'config/build-plugins/**',
'native/**',
'resources/**',
'electron.vite.config.ts',
+2
View File
@@ -94,6 +94,7 @@ docs/**
!docs/readme/
!docs/readme/**
!docs/STYLEGUIDE.md
!docs/ai-vault-process-isolation-plan.md
!docs/mobile-terminal-shortcut-bar.md
!docs/reference/
!docs/reference/git-compatibility.md
@@ -101,6 +102,7 @@ docs/**
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/remote-wire-compatibility.md
!docs/reference/renderer-agent-status-performance.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
+10
View File
@@ -56,6 +56,7 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"typescript/array-type": "error",
"typescript/consistent-generic-constructors": "error",
"typescript/consistent-indexed-object-style": "error",
"typescript/consistent-type-assertions": "error",
"typescript/consistent-type-definitions": ["error", "type"],
@@ -68,12 +69,16 @@
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-optional-chain": "error",
"typescript/prefer-ts-expect-error": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-else-return": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-has-own": "error",
"no-restricted-imports": [
"error",
{
@@ -90,11 +95,16 @@
"no-useless-return": "error",
"prefer-template": "error",
"unicorn/consistent-empty-array-spread": "error",
"unicorn/consistent-existence-index-check": "error",
"unicorn/empty-brace-spaces": "error",
"unicorn/error-message": "error",
"unicorn/no-array-fill-with-reference-type": "warn",
"unicorn/no-array-reverse": "error",
"unicorn/no-instanceof-builtins": "error",
"unicorn/no-typeof-undefined": "error",
"unicorn/no-unnecessary-array-splice-count": "error",
"unicorn/no-useless-promise-resolve-reject": "error",
"unicorn/no-zero-fractions": "error",
"unicorn/prefer-array-find": "error",
"unicorn/prefer-array-flat-map": "warn",
"unicorn/prefer-array-index-of": "error",
+4
View File
@@ -2,6 +2,10 @@
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.
## Electron UI Validation
Use the `$electron` skill and Playwright CDP for rendered Orca UI checks. Do not use computer-use for Orca UI validation.
# Style
## Concise/Brief Non-obviosu comments ONLY
* DO NOT: be verbose, explain the obvious, walk through the code ("WHY not HOW")
+2 -2
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **Android:** [Download APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
---
+124 -14
View File
@@ -1,7 +1,8 @@
import { spawnSync } from 'node:child_process'
import { spawn } from 'node:child_process'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
type NormalizedInputOptions = Rollup.NormalizedInputOptions
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
type OutputBundle = Rollup.OutputBundle
type OutputChunk = Rollup.OutputChunk
@@ -42,9 +43,34 @@ const WORKER_THREAD_ENTRY_NAMES = [
'port-scan-command-worker-entry'
] as const
export const GUARDED_ENTRY_NAMES = [
...PLAIN_NODE_ENTRY_NAMES,
...WORKER_THREAD_ENTRY_NAMES
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
// Why: writeBundle skips any name missing from the bundle, so a renamed or
// removed rollup input would silently drop that entry from the guard and let the
// regression back in. Pin the lists to the input keys at build start instead.
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
if (typeof input === 'string' || Array.isArray(input)) {
return
}
const inputNames = new Set(Object.keys(input))
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
if (missing.length > 0) {
throw new Error(
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
`the current entry names — a stale name silently stops guarding that entry.`
)
}
}
function collectReachableChunks(
entry: OutputChunk,
@@ -89,41 +115,125 @@ function assertNoElectronRequire(
}
}
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
export type SmokeTimings = {
timeoutMs: number
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
// needs an uncatchable follow-up to stay a deadline.
killGraceMs: number
}
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
// can hold the pipes open long after the child is gone.
const SMOKE_STDERR_DRAIN_MS = 250
type SmokeResult = {
status: number | null
signal: NodeJS.Signals | null
stderr: string
error?: Error
timedOut: boolean
}
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
// build forever. Escalate to SIGKILL instead.
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
let stderr = ''
let timedOut = false
let settled = false
let forceKillTimer: NodeJS.Timeout | undefined
let drainTimer: NodeJS.Timeout | undefined
child.stderr.setEncoding('utf8')
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
const deadlineTimer = setTimeout(() => {
timedOut = true
child.kill('SIGTERM')
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
}, timings.timeoutMs)
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadlineTimer)
clearTimeout(forceKillTimer)
clearTimeout(drainTimer)
resolve({ status, signal, stderr, error, timedOut })
}
child.on('error', (error: Error) => finish(null, null, error))
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
// cannot outlast the process itself.
child.on('close', (status, signal) => finish(status, signal))
child.on('exit', (status, signal) => {
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
})
})
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
const result = await runDaemonEntry(entryPath, timings)
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
// Almost always means the daemon stopped rejecting an empty argv and started
// listening instead.
if (result.timedOut) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
`empty argv under plain Node, so the smoke killed it.`
)
}
if (result.signal) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
)
}
const stderr = result.stderr
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
`${result.status}). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
export function createPlainNodeEntryGuardPlugin(
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
): Plugin {
let daemonOutputDir: string | undefined
return {
name: 'orca-plain-node-entry-guard',
buildStart(options: NormalizedInputOptions) {
assertEntryNamesAreRollupInputs(options.input)
},
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
@@ -159,11 +269,11 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
daemonOutputDir = options.dir
}
},
closeBundle() {
async closeBundle() {
if (daemonOutputDir) {
const outputDir = daemonOutputDir
daemonOutputDir = undefined
smokeLoadDaemonEntry(outputDir)
await smokeLoadDaemonEntry(outputDir, smokeTimings)
}
}
}
+8
View File
@@ -166,6 +166,12 @@ module.exports = {
// Why: sherpa-onnx native bindings (platform-specific subpackages) must be
// unpacked because they ship .node addons + .dylib/.so files that cannot be
// dlopen()'d from inside the asar archive.
// Why: the OpenCode SQLite worker entry is also spawned by the scanner
// service, which runs under ELECTRON_RUN_AS_NODE and so cannot see into
// app.asar. Left packed, that spawn fails closed and every OpenCode session
// disappears from Agent Session History in packaged builds only. Worker
// entries reached solely from the Electron main process stay packed, since
// asar redirects their app.asar paths.
asarUnpack: [
'out/package.json',
'out/cli/**',
@@ -182,6 +188,8 @@ module.exports = {
'out/main/grok/**',
'out/main/hermes/**',
'out/main/daemon-entry.js',
'out/main/session-scanner-service-entry.js',
'out/main/session-scanner-opencode-sqlite-worker-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
+1 -1
View File
@@ -8,7 +8,7 @@ const configByTarget = {
renderer: { renderer: electronViteConfig.renderer }
}
if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) {
if (!target || !Object.hasOwn(configByTarget, target)) {
throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? '<unset>'}`)
}
+1 -8
View File
@@ -1,13 +1,6 @@
[
{
"filePath": "src/renderer/src/lib/agent-background-session-test-state.ts",
"kind": "object-property:title",
"text": "Terminal 1",
"dynamic": false,
"count": 1
},
{
"filePath": "src/renderer/src/components/sidebar/WorktreeCard.tsx",
"filePath": "src/renderer/src/components/sidebar/worktree-card-meta-row.tsx",
"kind": "jsx-attribute:label",
"text": "sidebar",
"dynamic": false,
-5
View File
@@ -232,10 +232,8 @@ inline src/renderer/src/components/settings/AgentsPane.tsx
inline src/renderer/src/components/settings/RepositoryHooksSection.tsx
inline src/renderer/src/components/settings/RuntimeEnvironmentsPane.tsx
inline src/renderer/src/components/settings/Settings.tsx
inline src/renderer/src/components/settings/SettingsFormControls.tsx
inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeCard.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/WorktreeList.lineage-child-card.test.ts
inline src/renderer/src/components/sidebar/WorktreeList.tsx
@@ -246,7 +244,6 @@ inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
@@ -270,7 +267,6 @@ inline src/renderer/src/hooks/useComposerState.ts
inline src/renderer/src/hooks/useEditorExternalWatch.ts
inline src/renderer/src/hooks/useIpcEvents.test.ts
inline src/renderer/src/hooks/useIpcEvents.ts
inline src/renderer/src/hooks/useIssueMetadata.ts
inline src/renderer/src/hooks/useSettingsNavigationMetadata.ts
inline src/renderer/src/lib/active-agent-note-send.test.ts
inline src/renderer/src/lib/file-type-icons.ts
@@ -342,7 +338,6 @@ mobile-config app/h/*/source-control/*.tsx
mobile-config app/h/*/tasks.tsx
mobile-config app/index.tsx
mobile-config app/pair-scan.tsx
mobile-config app/terminal-settings.tsx
mobile-config app/troubleshoot.tsx
mobile-config scripts/mock-server.ts
mobile-config scripts/repro-terminal-colors.ts
@@ -16,6 +16,7 @@
"import/namespace": "warn",
"import/no-cycle": ["warn", { "maxDepth": 3 }],
"import/no-duplicates": "warn",
"import/no-empty-named-blocks": "warn",
"import/no-self-import": "warn"
},
"overrides": [
@@ -12,6 +12,7 @@
},
"rules": {
"typescript/await-thenable": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/restrict-plus-operands": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/switch-exhaustiveness-check": [
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,9 @@ import process from 'node:process'
import ts from 'typescript-api'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
// Why: test-only modules live beside their spec as `*-test-harness.ts` / `*-fixtures.ts` here, not under `__tests__/`.
const TEST_SUPPORT_FILE_PATTERN =
/[.-](?:test-harness|test-fixtures?|test-state|test-support|fixtures?)\.[cm]?[jt]sx?$/
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_CALL_NAMES = new Set(['t', 'translate'])
const USER_VISIBLE_JSX_ATTRIBUTES = new Set([
@@ -73,13 +76,14 @@ function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSkippedFile(root, filePath) {
export function isSkippedFile(root, filePath) {
const relative = normalizePath(root, filePath)
if (
relative.endsWith('.d.ts') ||
relative.includes('.test.') ||
relative.includes('.spec.') ||
relative.includes('/__tests__/')
relative.includes('/__tests__/') ||
TEST_SUPPORT_FILE_PATTERN.test(relative)
) {
return true
}
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
import { collectLocalizationCandidates, isSkippedFile } from './audit-localization-coverage.mjs'
const ROOT = process.cwd()
@@ -42,3 +42,23 @@ describe('localization coverage candidates', () => {
expect(reports).toEqual([])
})
})
describe('localization coverage file skipping', () => {
function skipped(relativePath) {
return isSkippedFile(ROOT, `${ROOT}/${relativePath}`)
}
it('skips test-only modules that sit beside their spec', () => {
expect(skipped('src/renderer/src/components/browser-pane/stream-test-harness.ts')).toBe(true)
expect(skipped('src/renderer/src/hooks/ipc-events-test-fixtures.ts')).toBe(true)
expect(skipped('src/renderer/src/lib/session-test-state.ts')).toBe(true)
expect(skipped('src/renderer/src/store/slices/routing-fixture.ts')).toBe(true)
expect(skipped('src/renderer/src/components/tab-bar/icon-stub.fixture.tsx')).toBe(true)
})
it('still scans shipped modules whose names merely mention a fixture concept', () => {
expect(skipped('src/renderer/src/components/browser-pane/fixture-picker.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/settings/fixtures-panel.tsx')).toBe(false)
expect(skipped('src/renderer/src/components/browser-pane/BrowserPane.tsx')).toBe(false)
})
})
@@ -79,7 +79,7 @@ try {
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
return index !== -1 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
+18
View File
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
const MANAGED_HOOK_RUNTIME_ENTRY = join(
ROOT,
'src',
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
}
})
await build({
entryPoints: [AI_VAULT_SERVICE_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-ai-vault-service.js'),
external: ['electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
bundle: true,
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
// so a companion-only change always deploys beside the matching relay host.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
const hash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(aiVaultServiceContent)
.update(managedHookRuntimeContent)
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
if (platform.startsWith('win32-')) {
@@ -56,5 +56,5 @@ function findFrameworkCompiler(env) {
function readArg(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
return index !== -1 ? process.argv[index + 1] : undefined
}
@@ -11,7 +11,7 @@ function source(path) {
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) {
if (start === -1 || end === -1) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
@@ -4,6 +4,9 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = join(import.meta.dirname, '..', '..')
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
@@ -171,6 +174,29 @@ describe('electron-builder config', () => {
)
})
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
// invisible to it and a packed worker entry fails closed — dropping every
// OpenCode session in packaged builds while dev stays green. Three legs must
// agree on the filename, so all three are read rather than hardcoded.
it('unpacks the OpenCode SQLite worker entry the scanner service forks', async () => {
const spawnSource = await readFile(
join(SRC_MAIN_DIR, 'ai-vault', 'session-scanner-opencode-sqlite-worker-spawn.ts'),
'utf8'
)
const entryFilename = spawnSource.match(/WORKER_ENTRY_FILENAME = '([^']+)'/)?.[1]
expect(entryFilename).toBeDefined()
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
// Why: the emitted path comes from the rollup input key under
// entryFileNames '[name].js', not from the source filename — renaming the
// key alone would leave the other two legs agreeing on a file that no
// longer exists.
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
expect(viteConfig).toContain("entryFileNames: '[name].js'")
expect(viteConfig).toMatch(new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`))
})
it('keeps the worker-thread hang watchdog inside app.asar', () => {
expect(electronBuilderConfig.asarUnpack).not.toContain(
'out/main/main-thread-hang-watchdog-entry.js'
@@ -196,7 +196,8 @@ describe('Electron Vite output contract', () => {
})
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
// Own-property check only: an inherited key like `constructor` must not select a build target.
expect(targetConfig).toContain('Object.hasOwn(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
+1 -1
View File
@@ -45,7 +45,7 @@ function readRuntimeArg() {
}
const runtimeIndex = process.argv.indexOf('--runtime')
if (runtimeIndex >= 0) {
if (runtimeIndex !== -1) {
return process.argv[runtimeIndex + 1]
}
@@ -227,7 +227,7 @@ function readGitBlobs(objectShas) {
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
if (headerEnd === -1) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
@@ -648,8 +648,8 @@ async function main() {
const argv = process.argv.slice(2)
const rebuildFromTags = argv.includes('--rebuild-from-tags')
const releaseIndex = argv.indexOf('--release')
const releaseVersion = releaseIndex >= 0 ? argv[releaseIndex + 1] : null
if (releaseIndex >= 0 && !releaseVersion) {
const releaseVersion = releaseIndex !== -1 ? argv[releaseIndex + 1] : null
if (releaseIndex !== -1 && !releaseVersion) {
throw new Error('--release requires a version argument, e.g. --release 1.4.160')
}
@@ -0,0 +1,323 @@
import { execFileSync, spawnSync } from 'node:child_process'
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
function readWindowsProcesses() {
const script =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: null,
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
export function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
export function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
export function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
const DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS = 120_000
export async function sampleProcessTreeUntilWorkloadsComplete({
rootPid,
requestedDurationMs,
intervalMs,
workloadPromise,
maxWorkloadOverrunMs = DEFAULT_WORKLOAD_OVERRUN_LIMIT_MS,
readRows = readProcessRows,
now = Date.now,
wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
}) {
const samplingStartedAt = now()
const requestedDeadline = samplingStartedAt + requestedDurationMs
const hardDeadline = requestedDeadline + maxWorkloadOverrunMs
let workloadSettled = false
let workloadResult
let workloadError
let workloadSettledAt = null
void workloadPromise.then(
(result) => {
workloadResult = result
workloadSettled = true
workloadSettledAt = now()
},
(error) => {
workloadError = error
workloadSettled = true
workloadSettledAt = now()
}
)
const samples = []
let previousSnapshot = null
const needsFinalWorkloadSample = () =>
workloadSettledAt !== null && (previousSnapshot?.at ?? -Infinity) < workloadSettledAt
while (
now() <= requestedDeadline ||
samples.length === 0 ||
!workloadSettled ||
needsFinalWorkloadSample()
) {
const sampledAt = now()
if (workloadError) {
throw workloadError
}
if (
(!workloadSettled && sampledAt >= hardDeadline) ||
(workloadSettledAt !== null && workloadSettledAt > hardDeadline)
) {
throw new Error(
`Benchmark workload exceeded the ${maxWorkloadOverrunMs}ms sampling overrun limit`
)
}
const processRows = descendantsOf(readRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await wait(intervalMs)
}
if (workloadError) {
throw workloadError
}
const measuredDurationMs = Math.max(
0,
(previousSnapshot?.at ?? samplingStartedAt) - samplingStartedAt
)
return {
samples,
workloadResult,
samplingWindow: {
requestedDurationMs,
measuredDurationMs,
maxWorkloadOverrunMs,
extendedForWorkload: workloadSettledAt !== null && workloadSettledAt > requestedDeadline,
workloadSettledElapsedMs:
workloadSettledAt === null ? null : Math.max(0, workloadSettledAt - samplingStartedAt),
workloadSettledBeforeStop: workloadSettled
}
}
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
export function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
export function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
export function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import { sampleProcessTreeUntilWorkloadsComplete } from './idle-cpu-process-sampling.mjs'
function createClock(workloadCompletesAt) {
let currentMs = 0
let resolveWorkload
const workloadPromise = new Promise((resolve) => {
resolveWorkload = resolve
})
const wait = async (durationMs) => {
currentMs += durationMs
if (currentMs >= workloadCompletesAt) {
resolveWorkload('complete')
}
await Promise.resolve()
}
const readRows = () => [
{
pid: 10,
ppid: 0,
percentCpu: 0,
rssBytes: 1_024,
cpuTimeSeconds: currentMs / 2_000,
command: 'electron'
}
]
return { now: () => currentMs, readRows, wait, workloadPromise }
}
describe('idle CPU process sampling window', () => {
it('extends through a slow workload and captures a final CPU delta', async () => {
const clock = createClock(40)
const result = await sampleProcessTreeUntilWorkloadsComplete({
rootPid: 10,
requestedDurationMs: 20,
intervalMs: 10,
maxWorkloadOverrunMs: 100,
...clock
})
expect(result.workloadResult).toBe('complete')
expect(result.samples.map((sample) => sample.at)).toEqual([10, 20, 30, 40])
expect(result.samplingWindow).toEqual({
requestedDurationMs: 20,
measuredDurationMs: 40,
maxWorkloadOverrunMs: 100,
extendedForWorkload: true,
workloadSettledElapsedMs: 40,
workloadSettledBeforeStop: true
})
})
it('invalidates a run that reaches the workload overrun guard', async () => {
const clock = createClock(Infinity)
await expect(
sampleProcessTreeUntilWorkloadsComplete({
rootPid: 10,
requestedDurationMs: 20,
intervalMs: 10,
maxWorkloadOverrunMs: 20,
...clock
})
).rejects.toThrow('exceeded the 20ms sampling overrun limit')
})
})
@@ -0,0 +1,202 @@
export async function configureRendererScaleFixture(page, options, repoPath) {
return page.evaluate(
({ agentsPerWorktree, lineageDepth, repoPath }) => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available')
}
const normalizePath = (value) =>
String(value ?? '')
.replaceAll('\\', '/')
.toLowerCase()
const primaryPath = normalizePath(repoPath)
const compare = (left, right) => (left < right ? -1 : left > right ? 1 : 0)
const state = store.getState()
const worktrees = Object.values(state.worktreesByRepo)
.flat()
.filter((worktree) => !worktree.isArchived)
.sort((left, right) => {
const primaryOrder =
Number(normalizePath(right.path) === primaryPath) -
Number(normalizePath(left.path) === primaryPath)
return (
primaryOrder ||
compare(normalizePath(left.path), normalizePath(right.path)) ||
compare(left.id, right.id)
)
})
const appliedLineageDepth = Math.min(lineageDepth, Math.max(0, worktrees.length - 1))
if (lineageDepth === 0 && agentsPerWorktree === 0) {
return {
applied: false,
requestedLineageDepth: lineageDepth,
appliedLineageDepth,
agentsPerWorktree,
seededAgentRows: 0
}
}
state.setActiveView('terminal')
state.setSidebarOpen(true)
state.setGroupBy('none')
state.setSortBy('recent')
state.setShowActiveOnly(false)
state.setShowSleepingWorkspaces(true)
state.setHideDefaultBranchWorkspace(false)
state.setFilterRepoIds([])
const lineageById = { ...store.getState().worktreeLineageById }
const lineageParentIds = new Set()
if (appliedLineageDepth > 0) {
for (const worktree of worktrees) {
delete lineageById[worktree.id]
if (!worktree.instanceId) {
throw new Error(`Worktree ${worktree.id} has no instanceId for lineage seeding`)
}
}
for (let index = 1; index < worktrees.length; index += 1) {
const child = worktrees[index]
const parent = worktrees[Math.min(index - 1, appliedLineageDepth - 1)]
lineageParentIds.add(parent.id)
lineageById[child.id] = {
worktreeId: child.id,
worktreeInstanceId: child.instanceId,
parentWorktreeId: parent.id,
parentWorktreeInstanceId: parent.instanceId,
origin: 'manual',
capture: { source: 'manual-action', confidence: 'explicit' },
createdAt: 1_700_000_000_000 + index
}
}
}
const collapsedGroups = new Set(store.getState().collapsedGroups)
for (const parentId of lineageParentIds) {
collapsedGroups.delete(`lineage:${parentId}`)
}
store.setState({ worktreeLineageById: lineageById, collapsedGroups })
let seededAgentRows = 0
if (agentsPerWorktree > 0) {
store.getState().setWorktreeCardMode('Default')
store.getState().setAgentActivityDisplayMode('full')
const fixtureNow = Date.now()
worktrees.forEach((worktree, worktreeIndex) => {
const next = store.getState()
const tab =
next.tabsByWorktree[worktree.id]?.[0] ??
next.createTab(worktree.id, undefined, undefined, {
activate: false,
id: `idle-cpu-tab-${worktreeIndex}`
})
for (let agentIndex = 0; agentIndex < agentsPerWorktree; agentIndex += 1) {
const agentType = agentIndex % 2 === 0 ? 'codex' : 'claude'
const leafSequence =
BigInt(worktreeIndex) * BigInt(agentsPerWorktree) + BigInt(agentIndex + 1)
const leafId = `00000000-0000-4000-8000-${leafSequence.toString(16).padStart(12, '0')}`
store.getState().setAgentStatus(
`${tab.id}:${leafId}`,
{
state: 'working',
prompt: `Idle CPU agent ${worktreeIndex + 1}.${agentIndex + 1}`,
agentType
},
agentType,
{ updatedAt: fixtureNow, stateStartedAt: fixtureNow },
{ tabId: tab.id, worktreeId: worktree.id }
)
seededAgentRows += 1
}
})
}
return {
applied: true,
requestedLineageDepth: lineageDepth,
appliedLineageDepth,
lineageEdges: appliedLineageDepth > 0 ? Math.max(0, worktrees.length - 1) : 0,
expandedLineageGroups: lineageParentIds.size,
agentsPerWorktree,
seededAgentRows,
orderedWorktreeIds: worktrees.map((worktree) => worktree.id)
}
},
{ agentsPerWorktree: options.agentsPerWorktree, lineageDepth: options.lineageDepth, repoPath }
)
}
export async function collectRendererCensus(page, configuredLineageDepth) {
return page.evaluate((configuredDepth) => {
const state = window.__store?.getState()
if (!state) {
throw new Error('window.__store is not available')
}
const worktrees = Object.values(state.worktreesByRepo).flat()
const worktreeIds = new Set(worktrees.map((worktree) => worktree.id))
const lineageDepthById = new Map()
const getDepth = (worktreeId, trail = new Set()) => {
if (lineageDepthById.has(worktreeId)) {
return lineageDepthById.get(worktreeId)
}
const lineage = state.worktreeLineageById[worktreeId]
if (!lineage || !worktreeIds.has(lineage.parentWorktreeId) || trail.has(worktreeId)) {
return 0
}
const nextTrail = new Set(trail)
nextTrail.add(worktreeId)
const depth = 1 + getDepth(lineage.parentWorktreeId, nextTrail)
lineageDepthById.set(worktreeId, depth)
return depth
}
const logicalDepths = worktrees.map((worktree) => getDepth(worktree.id))
const lineageParentIds = new Set(
Object.values(state.worktreeLineageById)
.filter((lineage) => worktreeIds.has(lineage.worktreeId))
.map((lineage) => lineage.parentWorktreeId)
)
const sidebar = document.querySelector('[data-worktree-sidebar]')
const mountedWorktreeIds = [
...new Set(
[...(sidebar?.querySelectorAll('[data-worktree-id]') ?? [])]
.map((element) => element.getAttribute('data-worktree-id'))
.filter(Boolean)
)
]
const mountedAgentRows = [...(sidebar?.querySelectorAll('*') ?? [])].filter(
(element) =>
element.classList.contains('group/agent-row') ||
element.classList.contains('compact-agent-row')
).length
let diagnosticCensus = null
try {
diagnosticCensus = window.__orcaTypingDiagnostic?.report().census ?? null
} catch {}
const collapsedLineageGroups = [...lineageParentIds].filter((parentId) =>
state.collapsedGroups.has(`lineage:${parentId}`)
).length
return {
capturedAt: new Date().toISOString(),
worktrees: {
store: worktrees.length,
mountedCards: sidebar?.querySelectorAll('[data-worktree-card-surface]').length ?? 0,
mountedUnique: mountedWorktreeIds.length,
mountedIds: mountedWorktreeIds.slice(0, 100),
mountedIdsTruncated: Math.max(0, mountedWorktreeIds.length - 100)
},
agentRows: {
storeLive: Object.keys(state.agentStatusByPaneKey ?? {}).length,
storeRetained: Object.keys(state.retainedAgentsByPaneKey ?? {}).length,
mounted: mountedAgentRows,
diagnosticMounted: diagnosticCensus?.agentRows.mountedDom ?? null
},
storeListeners: diagnosticCensus?.storeListeners ?? null,
lineage: {
configuredDepth,
measuredMaxDepth: Math.max(0, ...logicalDepths),
edges: logicalDepths.filter((depth) => depth > 0).length,
groups: lineageParentIds.size,
expandedGroups: lineageParentIds.size - collapsedLineageGroups,
collapsedGroups: collapsedLineageGroups
},
diagnostic: diagnosticCensus
}
}, configuredLineageDepth)
}
@@ -0,0 +1,191 @@
const RENDERER_TIMER_INTERVAL_MS = 100
export async function startRendererTimingProbe(page) {
await page.evaluate((timerIntervalMs) => {
const maxSamples = 5_000
const maxEntries = 80
let phaseStartedAt = performance.now()
let phaseStartedAtIso = new Date().toISOString()
let timerId = null
let driftCount = 0
let driftSamples = []
let longTaskEntries = []
let observer = null
let longTaskSupported = false
const round = (value) => Math.round(value * 100) / 100
const summarize = (values, totalCount = values.length) => {
const sorted = [...values].sort((left, right) => left - right)
const percentile = (fraction) =>
sorted.length === 0
? null
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
return {
count: totalCount,
retainedCount: sorted.length,
mean:
sorted.length === 0
? null
: round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
p50: sorted.length === 0 ? null : round(percentile(0.5)),
p95: sorted.length === 0 ? null : round(percentile(0.95)),
max: sorted.length === 0 ? null : round(sorted.at(-1))
}
}
const recordLongTasks = (entries) => {
for (const entry of entries) {
longTaskEntries.push({
startTime: entry.startTime,
duration: entry.duration,
name: entry.name
})
if (longTaskEntries.length > maxSamples) {
longTaskEntries.shift()
}
}
}
try {
longTaskSupported = PerformanceObserver.supportedEntryTypes?.includes('longtask') === true
if (longTaskSupported) {
observer = new PerformanceObserver((list) => recordLongTasks(list.getEntries()))
observer.observe({ type: 'longtask', buffered: true })
}
} catch {
observer = null
longTaskSupported = false
}
const scheduleTimer = () => {
const expectedAt = performance.now() + timerIntervalMs
timerId = setTimeout(() => {
driftCount += 1
driftSamples.push(Math.max(0, performance.now() - expectedAt))
if (driftSamples.length > maxSamples) {
driftSamples.shift()
}
scheduleTimer()
}, timerIntervalMs)
}
const snapshot = (reset) => {
if (observer) {
recordLongTasks(observer.takeRecords())
}
const capturedAt = performance.now()
const phaseLongTasks = longTaskEntries.filter((entry) => entry.startTime >= phaseStartedAt)
const durations = phaseLongTasks.map((entry) => entry.duration)
const result = {
startedAt: phaseStartedAtIso,
capturedAt: new Date().toISOString(),
durationMs: round(capturedAt - phaseStartedAt),
timerIntervalMs,
timerDriftMs: summarize(driftSamples, driftCount),
longTasks: {
supported: longTaskSupported,
...summarize(durations),
totalDurationMs: round(durations.reduce((sum, value) => sum + value, 0)),
entries: phaseLongTasks.slice(-maxEntries).map((entry) => ({
startMs: round(entry.startTime - phaseStartedAt),
durationMs: round(entry.duration),
name: entry.name
})),
entriesTruncated: Math.max(0, phaseLongTasks.length - maxEntries)
}
}
if (reset) {
phaseStartedAt = capturedAt
phaseStartedAtIso = new Date().toISOString()
driftCount = 0
driftSamples = []
longTaskEntries = []
}
return result
}
scheduleTimer()
window.__orcaIdleCpuTimingProbe = {
snapshot: () => snapshot(true),
stop: () => {
if (timerId !== null) {
clearTimeout(timerId)
}
const result = snapshot(false)
observer?.disconnect()
return result
}
}
}, RENDERER_TIMER_INTERVAL_MS)
}
export async function snapshotRendererTimingProbe(page) {
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.snapshot() ?? null)
}
export async function stopRendererTimingProbe(page) {
return page.evaluate(() => window.__orcaIdleCpuTimingProbe?.stop() ?? null)
}
export async function runZustandPublications(page, count, intervalMs) {
return page.evaluate(
({ count, intervalMs }) =>
new Promise((resolve, reject) => {
const store = window.__store
if (!store) {
reject(new Error('window.__store is not available'))
return
}
const maxSamples = 5_000
const startedAt = performance.now()
const startedAtIso = new Date().toISOString()
const schedulingDriftMs = []
let completed = 0
const finish = () => {
const sorted = [...schedulingDriftMs].sort((left, right) => left - right)
const percentile = (fraction) =>
sorted.length === 0
? null
: sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]
const round = (value) => Math.round(value * 100) / 100
resolve({
requested: count,
completed,
intervalMs,
startedAt: startedAtIso,
completedAt: new Date().toISOString(),
durationMs: round(performance.now() - startedAt),
schedulingDriftMs: {
retainedCount: sorted.length,
p50: sorted.length === 0 ? null : round(percentile(0.5)),
p95: sorted.length === 0 ? null : round(percentile(0.95)),
max: sorted.length === 0 ? null : round(sorted.at(-1))
}
})
}
const publish = () => {
const publishedAt = performance.now()
const scheduledAt = startedAt + completed * intervalMs
schedulingDriftMs.push(Math.max(0, publishedAt - scheduledAt))
if (schedulingDriftMs.length > maxSamples) {
schedulingDriftMs.shift()
}
try {
// Why: an empty partial notifies every subscriber without changing domain state.
store.setState({})
} catch (error) {
reject(error)
return
}
completed += 1
if (completed >= count) {
finish()
return
}
const nextAt = startedAt + completed * intervalMs
setTimeout(publish, Math.max(0, nextAt - performance.now()))
}
if (count === 0) {
finish()
} else {
setTimeout(publish, 0)
}
}),
{ count, intervalMs }
)
}
@@ -28,6 +28,8 @@ const transientDownloadErrorCodes = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
'ECONNRESET',
// GitHub release CDN can refuse HTTP/2 streams under load; retry like socket resets.
'ERR_HTTP2_STREAM_ERROR',
'ENETDOWN',
'ENETRESET',
'ENETUNREACH',
@@ -185,7 +187,9 @@ async function downloadElectronArtifactWithRetry(downloadOptions) {
function getDownloadRetryDelays() {
const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS
if (!configured) {
return [1_000, 3_000]
// Why: GitHub release CDN returns intermittent 503 / HTTP2 stream refusals
// under CI fan-out; a few short attempts still exhaust during outages.
return [1_000, 3_000, 5_000, 10_000]
}
const delays = configured.split(',').map(Number)
@@ -200,7 +204,7 @@ function isTransientDownloadError(error) {
if (transientDownloadErrorCodes.has(candidate?.code)) {
return true
}
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
const statusCode = getDownloadErrorStatusCode(candidate)
if (
statusCode === 408 ||
statusCode === 425 ||
@@ -213,6 +217,13 @@ function isTransientDownloadError(error) {
return false
}
function getDownloadErrorStatusCode(error) {
// @electron/get FetchDownloader uses Fetch Response.status; older got uses statusCode.
return (
error?.statusCode ?? error?.status ?? error?.response?.statusCode ?? error?.response?.status
)
}
function getErrorChain(error) {
const errors = []
let candidate = error
@@ -225,7 +236,7 @@ function getErrorChain(error) {
function formatDownloadError(error) {
for (const candidate of getErrorChain(error)) {
const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode
const statusCode = getDownloadErrorStatusCode(candidate)
if (statusCode) {
return `HTTP ${statusCode}`
}
@@ -118,6 +118,58 @@ describe('install-electron-package-binary', () => {
}
})
it('retries HTTP/2 stream refusal Electron download failures', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadErrorCode: 'ERR_HTTP2_STREAM_ERROR'
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain(
'Transient Electron download failure (ERR_HTTP2_STREAM_ERROR)'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('retries Electron download HTTP 503 failures from Fetch Response.status', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir, {
downloadFailures: 1,
downloadHttpStatus: 503
})
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0'
})
expect(result.status, result.stderr).toBe(0)
expect(
readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n')
).toHaveLength(2)
expect(result.stderr).toContain('Transient Electron download failure (HTTP 503)')
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails after exhausting transient Electron download retries', () => {
const projectDir = mkTempProject()
@@ -279,7 +331,12 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8'))
function writeFakeElectronGet(
projectDir,
{ downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {}
{
downloadNeverSettles = false,
downloadFailures = 0,
downloadErrorCode = 'ECONNRESET',
downloadHttpStatus = null
} = {}
) {
const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get')
mkdirSync(getDir, { recursive: true })
@@ -299,6 +356,11 @@ exports.downloadArtifact = async function downloadArtifact(details) {
return new Promise(() => {})
}
if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) {
if (${JSON.stringify(downloadHttpStatus)} != null) {
const error = new Error('Response code ' + ${JSON.stringify(downloadHttpStatus)})
error.response = { status: ${JSON.stringify(downloadHttpStatus)} }
throw error
}
const cause = Object.assign(new Error('download failed'), {
code: ${JSON.stringify(downloadErrorCode)}
})
@@ -0,0 +1,141 @@
#!/usr/bin/env node
// Run: node config/scripts/legacy-worker-recovery-persistence-benchmark.mjs
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtemp, open, readFile, rename } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { fileURLToPath } from 'node:url'
const repoRoot = fileURLToPath(new URL('../..', import.meta.url))
const runtimePath = join(repoRoot, 'src/main/runtime/orca-runtime.ts')
const args = new Map(
process.argv.slice(2).map((value, index, values) => [value, values[index + 1]])
)
const fixtureMiB = Number(args.get('--fixture-mib') ?? 24)
const trials = Number(args.get('--trials') ?? 3)
const jsonOutput = process.argv.includes('--json')
if (!Number.isInteger(fixtureMiB) || fixtureMiB < 1 || !Number.isInteger(trials) || trials < 1) {
throw new Error('fixture-mib and trials must be positive integers')
}
const runtimeSource = await readFile(runtimePath, 'utf8')
const recoveryStart = runtimeSource.indexOf(
'private async persistLegacyWorkerTerminalRecoveryBatch'
)
const recoveryEnd = runtimeSource.indexOf(
'private reconcileMissingLegacyWorkerTerminal',
recoveryStart
)
const recoverySource = runtimeSource.slice(recoveryStart, recoveryEnd)
if (
recoveryStart === -1 ||
recoveryEnd === -1 ||
!recoverySource.includes('await this.flushWorkspaceSessionOrThrowAsync()') ||
recoverySource.includes('flushOrThrow()')
) {
throw new Error('recovery persistence implementation changed; update this benchmark')
}
const root = await mkdtemp(join(tmpdir(), 'orca-legacy-recovery-benchmark-'))
const filler = 'x'.repeat(fixtureMiB * 1024 * 1024)
function payload(state) {
return JSON.stringify({ state, filler })
}
function writeDurableSync(path, body) {
const tempPath = `${path}.sync.tmp`
writeFileSync(tempPath, body)
const fd = openSync(tempPath, 'r')
try {
fsyncSync(fd)
} finally {
closeSync(fd)
}
renameSync(tempPath, path)
}
async function writeDurableAsync(path, body) {
const tempPath = `${path}.async.tmp`
const handle = await open(tempPath, 'w')
try {
await handle.writeFile(body)
await handle.sync()
} finally {
await handle.close()
}
await rename(tempPath, path)
}
async function measure(run) {
let maxEventLoopDelayMs = 0
let expected = performance.now() + 1
const timer = setInterval(() => {
const now = performance.now()
maxEventLoopDelayMs = Math.max(maxEventLoopDelayMs, now - expected)
expected = now + 1
}, 1)
await new Promise((resolve) => setTimeout(resolve, 5))
const startedAt = performance.now()
await run()
const durationMs = performance.now() - startedAt
await new Promise((resolve) => setTimeout(resolve, 5))
clearInterval(timer)
return { durationMs, maxEventLoopDelayMs }
}
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length / 2)]
}
async function runLegacy(path) {
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
state.fenced = true
writeDurableSync(path, payload(state))
state.surfacePresent = false
writeDurableSync(path, payload(state))
state.recoveryRecordPresent = false
writeDurableSync(path, payload(state))
}
async function runBatched(path) {
const state = { fenced: false, surfacePresent: true, recoveryRecordPresent: true }
state.fenced = true
state.surfacePresent = false
state.recoveryRecordPresent = false
await writeDurableAsync(path, payload(state))
}
try {
const legacy = []
const batched = []
for (let index = 0; index < trials; index += 1) {
legacy.push(await measure(() => runLegacy(join(root, `legacy-${index}.json`))))
batched.push(await measure(() => runBatched(join(root, `batched-${index}.json`))))
}
const result = {
benchmark: 'legacy-worker-recovery-persistence',
fixtureMiB,
trials,
legacy: {
durableWrites: 3,
medianDurationMs: median(legacy.map((sample) => sample.durationMs)),
medianMaxEventLoopDelayMs: median(legacy.map((sample) => sample.maxEventLoopDelayMs))
},
batchedAsync: {
durableWrites: 1,
medianDurationMs: median(batched.map((sample) => sample.durationMs)),
medianMaxEventLoopDelayMs: median(batched.map((sample) => sample.maxEventLoopDelayMs))
}
}
if (jsonOutput) {
console.log(JSON.stringify(result))
} else {
console.log(JSON.stringify(result, null, 2))
}
} finally {
rmSync(root, { recursive: true, force: true })
}
@@ -1058,36 +1058,6 @@
"auto.components.feature.wall.EditorAnimatedVisual.e16479c1c5": {
"ko": "[data-slash-menu] [data-slash-row].slash-active { background: rgba(24,24,27,0.07); box-shadow: inset 0 0 0 1px rgba(24,24,27,0.06); } [data-md-active-line][data-role=\"active\"] { color: rgb(113 113 122); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; } [data-md-active-line][data-role=\"h1\"] { color: inherit; font-family: inherit; font-size: 18px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.2; margin-top: 6px; } [data-md-caret] { display: inline-block; width: 1.5px; height: 1em; background: currentColor; vertical-align: -2px; margin-left: 1px; animation: md-caret-blink 1.05s steps(1) infinite; } @keyframes md-caret-blink { 0%, 50% { opacity: 1 } 51%, 100% { opacity: 0 } } @keyframes md-block-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } } @keyframes md-cursor-ripple { 0% { transform: scale(0.4); opacity: 0.9; } 100% { transform: scale(1.4); opacity: 0; } } [data-clicking=\"1\"] [data-cursor-ripple] { animation: md-cursor-ripple 460ms ease-out forwards; }"
},
"auto.components.feature.wall.FeatureTourPreview.04d54d50ec": {
"ko": "orca · zsh"
},
"auto.components.feature.wall.FeatureTourPreview.304ad0dfc1": {
"ko": "생각 중..."
},
"auto.components.feature.wall.FeatureTourPreview.3822d8d14b": {
"ko": "fix/worktree-picker-truncates"
},
"auto.components.feature.wall.FeatureTourPreview.47f16ecf34": {
"ko": "여러 작업을 동시에 진행하세요. 각 워크스페이스는 브랜치, terminal, agent 활동을 함께 보관합니다."
},
"auto.components.feature.wall.FeatureTourPreview.5d6ee181b6": {
"ko": "워크스페이스를 열어 terminal로 돌아가고, 테스트와 로그, agents 용으로 패널을 나누세요."
},
"auto.components.feature.wall.FeatureTourPreview.6218a9014d": {
"ko": "pnpm playwright test"
},
"auto.components.feature.wall.FeatureTourPreview.6ed43cb0e0": {
"ko": "dashboard.spec.ts"
},
"auto.components.feature.wall.FeatureTourPreview.70aa182266": {
"ko": "목표를 넘겨두고 자리를 비우세요. 코디네이터 agent가 작업을 나누어 병렬 PR을 만듭니다."
},
"auto.components.feature.wall.FeatureTourPreview.f10c14dd9d": {
"ko": "탭을 오갈 필요 없이 GitHub 또는 Linear 백로그에서 선택하고 한 번의 클릭으로 워크스페이스를 시작하세요."
},
"auto.components.feature.wall.FeatureTourWorkspaceCard.9220794f6a": {
"ko": "작업 중"
},
"auto.components.feature.wall.FeatureWallSetupChecklist.713cc529a5": {
"ko": "마일스톤"
},
@@ -1502,12 +1472,6 @@
"auto.components.new.workspace.ProjectCombobox.search": {
"ko": "프로젝트 검색..."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.empty": {
"ko": "이 프로젝트에 준비된 호스트가 없습니다."
},
"auto.components.new.workspace.ProjectHostSetupCombobox.placeholder": {
"ko": "호스트 선택"
},
"auto.components.notification.sound.options.0acd3d384e": {
"ko": "클랙"
},
@@ -1520,9 +1484,6 @@
"auto.components.notification.sound.options.86af8d938c": {
"ko": "봉 소리"
},
"auto.components.onboarding.AgentFeatureSetupStep.97dcdc010f": {
"ko": "기능 활성화"
},
"auto.components.onboarding.AgentStep.1eee1c7bd8": {
"ko": "PATH에서 agents를 찾지 못했습니다. 나중에 설치할 항목을 선택하거나 빈 terminal로 계속 진행하세요."
},
@@ -1538,12 +1499,6 @@
"auto.components.onboarding.AgentStep.yoloPermissionsTooltip": {
"ko": "중단을 줄이기 위해 agents 권한 확인을 건너뜁니다."
},
"auto.components.onboarding.FeatureSetupChecklist.01426f3a23": {
"ko": "Agents가 사이트를 탐색하고, 페이지를 검사하고, 브라우저 작업을 처리할 수 있습니다."
},
"auto.components.onboarding.FeatureSetupChecklist.c5292c409d": {
"ko": "Agents는 요청 시 앱 창을 검사하고 로컬 앱을 작동할 수 있습니다."
},
"auto.components.onboarding.IntegrationsStep.0b4a7d23ab": {
"ko": "로그인 중"
},
@@ -1556,18 +1511,6 @@
"auto.components.onboarding.OnboardingFlow.windowsTerminalSubtitle": {
"ko": "새 창에서 사용할 기본 셸과 terminal에서 오른쪽 클릭이 동작하는 방식을 선택하세요."
},
"auto.components.onboarding.RepoStep.466108ab89": {
"ko": "선택한 호스트에 존재하는 경로를 입력하세요."
},
"auto.components.onboarding.RepoStep.6558d50c69": {
"ko": "한 번에 여러 repos를 가져오고 싶으신가요? 상위 폴더를 선택하세요."
},
"auto.components.onboarding.RepoStep.c33b190ca3": {
"ko": "호스트 경로만"
},
"auto.components.onboarding.RepoStep.cf23006ba7": {
"ko": "선택한 호스트"
},
"auto.components.onboarding.ThemeStep.7ee9234e54": {
"ko": "Ghostty 설정을 찾았습니다."
},
@@ -4976,9 +4919,6 @@
"auto.components.ui.color.picker.faa855a582": {
"ko": "Hex"
},
"auto.components.ui.team.multi.combobox.18ec58881e": {
"ko": "팀 검색..."
},
"auto.components.workspace.cleanup.WorkspaceCleanupDialog.1b18868569": {
"ko": "리뷰 필요"
},
@@ -210,7 +210,7 @@ function connectInvalidPeer(socketPath) {
const onData = (chunk) => {
buffer += chunk
const newline = buffer.indexOf('\n')
if (newline < 0) {
if (newline === -1) {
return
}
let response
@@ -54,7 +54,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
const frames = []
while (cursor.length > 0) {
const frameStart = cursor.indexOf(JPEG_START)
if (frameStart < 0) {
if (frameStart === -1) {
const keepLastByte = cursor.at(-1) === 0xff
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
}
@@ -62,7 +62,7 @@ function extractOld(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
cursor = cursor.subarray(frameStart)
}
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
if (frameEnd < 0) {
if (frameEnd === -1) {
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
return { frames, pending: trackedCopy(tail) }
}
@@ -79,7 +79,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
const frames = []
while (cursor.length > 0) {
const frameStart = cursor.indexOf(JPEG_START)
if (frameStart < 0) {
if (frameStart === -1) {
const keepLastByte = cursor.at(-1) === 0xff
return { frames, pending: keepLastByte ? Buffer.from([0xff]) : Buffer.alloc(0) }
}
@@ -87,7 +87,7 @@ function extractNew(pending, chunk, maxPendingBytes = 2 * 1024 * 1024) {
cursor = cursor.subarray(frameStart)
}
const frameEnd = cursor.indexOf(JPEG_END, JPEG_START.length)
if (frameEnd < 0) {
if (frameEnd === -1) {
const tail = cursor.length <= maxPendingBytes ? cursor : cursor.subarray(-maxPendingBytes)
return { frames, pending: trackedCopy(tail) }
}
@@ -281,7 +281,7 @@ describe('orchestration skill guidance', () => {
expect(workerLoop).toContain('opaque provider model id with `--model`')
expect(workerLoop).toContain('`--effort` requires `--model`')
expect(workerLoop).toContain('neither option can combine with `--terminal`')
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
})
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
expect(remoteCommands).toContain(
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
)
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
+74 -7
View File
@@ -3,7 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { Plugin, Rollup } from 'vite'
import { afterEach, describe, expect, it } from 'vitest'
import { createPlainNodeEntryGuardPlugin } from '../build-plugins/plain-node-entry-guard'
import {
createPlainNodeEntryGuardPlugin,
GUARDED_ENTRY_NAMES
} from '../build-plugins/plain-node-entry-guard'
let outputDir: string | undefined
@@ -45,16 +48,16 @@ function runWriteBundle(plugin: Plugin, dir: string, code = ''): void {
)
}
function runCloseBundle(plugin: Plugin): void {
async function runCloseBundle(plugin: Plugin): Promise<void> {
const hook = plugin.closeBundle
if (typeof hook !== 'function') {
throw new Error('Expected closeBundle hook')
}
hook.call({} as never)
await hook.call({} as never)
}
describe('plain Node entry guard', () => {
it('smoke-loads the daemon after output files are written', () => {
it('smoke-loads the daemon after output files are written', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
@@ -64,17 +67,17 @@ describe('plain Node entry guard', () => {
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n'
)
expect(() => runCloseBundle(plugin)).not.toThrow()
await expect(runCloseBundle(plugin)).resolves.toBeUndefined()
})
it('runs the deferred smoke from closeBundle', () => {
it('runs the deferred smoke from closeBundle', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n")
expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node')
await expect(runCloseBundle(plugin)).rejects.toThrow('failed to load under plain Node')
})
it('rejects Electron imports during the static bundle scan', () => {
@@ -84,6 +87,70 @@ describe('plain Node entry guard', () => {
'requires electron'
)
})
it('rejects Electron subpath requires', () => {
const plugin = createPlainNodeEntryGuardPlugin()
expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron/main")')).toThrow(
'requires electron'
)
})
it('fails the smoke when the daemon exits zero on an empty argv', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin()
runWriteBundle(plugin, dir)
writeFileSync(join(dir, 'daemon-entry.js'), 'console.error("Usage: daemon-entry <socket>")\n')
await expect(runCloseBundle(plugin)).rejects.toThrow('did not reject an empty argv')
})
// daemon-entry installs a SIGTERM handler, so the smoke deadline only holds if
// it escalates to SIGKILL — spawnSync's own timeout would block here forever.
it('kills a daemon that traps SIGTERM and never exits', async () => {
const dir = createOutputDir()
const plugin = createPlainNodeEntryGuardPlugin({ timeoutMs: 250, killGraceMs: 250 })
runWriteBundle(plugin, dir)
writeFileSync(
join(dir, 'daemon-entry.js'),
"process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)\n"
)
await expect(runCloseBundle(plugin)).rejects.toThrow('did not exit within 250ms')
}, 10_000)
})
// Why: writeBundle skips names absent from the bundle, so a renamed rollup input
// would silently stop guarding that entry instead of failing the build.
describe('guarded entry names', () => {
function runBuildStart(plugin: Plugin, input: unknown): void {
const hook = plugin.buildStart
if (typeof hook !== 'function') {
throw new Error('Expected buildStart hook')
}
hook.call({} as never, { input } as Rollup.NormalizedInputOptions)
}
it('rejects a guarded name that is no longer a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(
GUARDED_ENTRY_NAMES.filter((name) => name !== 'stt-worker').map((name) => [
name,
`${name}.ts`
])
)
expect(() => runBuildStart(plugin, input)).toThrow('"stt-worker"')
})
it('passes when every guarded name is a rollup input', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const input = Object.fromEntries(GUARDED_ENTRY_NAMES.map((name) => [name, `${name}.ts`]))
expect(() => runBuildStart(plugin, input)).not.toThrow()
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
@@ -8,6 +8,7 @@ const dependencyAction = parse(
)
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'))
const shellContractFiles = [
'src/main/daemon/repro-13767-shell-ready-marker-lost-to-exec.test.ts',
'src/main/daemon/shell-ready.test.ts',
'src/main/providers/local-pty-shell-ready.test.ts',
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
@@ -75,6 +76,7 @@ describe('PR workflow parallelism', () => {
expect(shellStep).toBeDefined()
expect(shellInstall).toBeDefined()
expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1')
// Why the whole workflow, not just the general shards: any other lane installing
// these shells would silently start running the real-shell tests twice.
expect(jobsInstallingPackages).toEqual(['shell_contracts'])
@@ -94,7 +94,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
if (index === -1) {
return
}
settled = true
@@ -148,7 +148,7 @@ function createRelayClient(entryPath, args, env, protocol) {
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
if (index === -1) {
return
}
ready = true
@@ -5,7 +5,8 @@ import {
resolveEnvironment,
resolveEnvironmentPairingOffer
} from '../../src/shared/runtime-environment-store'
import type { MemorySnapshot, RuntimeStatus } from '../../src/shared/types'
import type { MemorySnapshot } from '../../src/shared/types'
import type { RuntimeStatus } from '../../src/shared/runtime-types'
async function main(): Promise<void> {
const environmentName = process.env.ORCA_PROBE_ENVIRONMENT_NAME
@@ -0,0 +1,50 @@
import { spawn } from 'node:child_process'
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
const knobByFlag = {
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
}
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
const passthroughArgs = []
const argv = process.argv.slice(2)
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === '--') {
continue
}
const knob = knobByFlag[argv[index]]
if (knob) {
env[knob] = argv[++index]
} else {
passthroughArgs.push(argv[index])
}
}
const child = spawn(
npxCommand,
[
'playwright',
'test',
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...passthroughArgs
],
{ stdio: 'inherit', env }
)
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
+152 -262
View File
@@ -1,15 +1,34 @@
#!/usr/bin/env node
import { _electron as electron } from '@stablyai/playwright-test'
import { execFileSync, spawnSync } from 'node:child_process'
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {
descendantsOf,
readProcessRows,
sampleProcessTreeUntilWorkloadsComplete,
summarizeProcessInventory,
summarizeSamples,
terminateProcesses
} from './idle-cpu-process-sampling.mjs'
import {
collectRendererCensus,
configureRendererScaleFixture
} from './idle-cpu-renderer-scale-fixture.mjs'
import {
runZustandPublications,
snapshotRendererTimingProbe,
startRendererTimingProbe,
stopRendererTimingProbe
} from './idle-cpu-renderer-timing-probe.mjs'
import { installSyntheticVisibleSpinners } from './idle-cpu-synthetic-spinners.mjs'
const DEFAULT_WARMUP_MS = 15_000
const DEFAULT_SAMPLE_MS = 30_000
const DEFAULT_INTERVAL_MS = 1_000
const DEFAULT_WORKTREE_COUNT = 1
const DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS = 100
const ONBOARDING_FINAL_STEP = 3
const ONBOARDING_FLOW_VERSION = 2
@@ -19,6 +38,10 @@ function parseArgs(argv) {
sampleMs: DEFAULT_SAMPLE_MS,
intervalMs: DEFAULT_INTERVAL_MS,
worktrees: DEFAULT_WORKTREE_COUNT,
lineageDepth: 0,
agentsPerWorktree: 0,
zustandPublications: 0,
zustandPublicationIntervalMs: DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS,
skipBuild: false,
headful: false,
output: null,
@@ -47,6 +70,14 @@ function parseArgs(argv) {
options.intervalMs = Number(readValue())
} else if (arg === '--worktrees') {
options.worktrees = Number(readValue())
} else if (arg === '--lineage-depth') {
options.lineageDepth = Number(readValue())
} else if (arg === '--agents-per-worktree') {
options.agentsPerWorktree = Number(readValue())
} else if (arg === '--zustand-publications') {
options.zustandPublications = Number(readValue())
} else if (arg === '--zustand-publication-interval-ms') {
options.zustandPublicationIntervalMs = Number(readValue())
} else if (arg === '--output') {
options.output = readValue()
} else if (arg === '--skip-build') {
@@ -73,6 +104,10 @@ function parseArgs(argv) {
'sampleMs',
'intervalMs',
'worktrees',
'lineageDepth',
'agentsPerWorktree',
'zustandPublications',
'zustandPublicationIntervalMs',
'syntheticVisibleSpinners',
'syntheticSpinnerSteps'
]) {
@@ -82,16 +117,33 @@ function parseArgs(argv) {
}
options.worktrees = Math.max(1, Math.floor(options.worktrees))
options.intervalMs = Math.max(250, Math.floor(options.intervalMs))
options.lineageDepth = Math.floor(options.lineageDepth)
options.agentsPerWorktree = Math.floor(options.agentsPerWorktree)
options.zustandPublications = Math.floor(options.zustandPublications)
options.zustandPublicationIntervalMs = Math.max(
1,
Math.floor(options.zustandPublicationIntervalMs)
)
options.syntheticVisibleSpinners = Math.max(0, Math.floor(options.syntheticVisibleSpinners))
options.syntheticSpinnerSteps = Math.max(1, Math.floor(options.syntheticSpinnerSteps))
if (!['smooth', 'steps'].includes(options.syntheticSpinnerAnimation)) {
throw new Error(`Invalid --synthetic-spinner-animation: ${options.syntheticSpinnerAnimation}`)
}
if (options.lineageDepth > 0 && options.worktrees < 2) {
throw new Error('--lineage-depth requires at least two --worktrees')
}
const publicationSpanMs =
Math.max(0, options.zustandPublications - 1) * options.zustandPublicationIntervalMs
if (publicationSpanMs > options.sampleMs) {
throw new Error(
`Zustand publication span ${publicationSpanMs}ms exceeds --sample-ms ${options.sampleMs}`
)
}
return options
}
function printUsage() {
console.log(
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
`Usage: node config/scripts/run-idle-cpu-benchmark.mjs [options]\n\nOptions:\n --warmup-ms <n> Time to wait after app readiness before sampling (default ${DEFAULT_WARMUP_MS})\n --sample-ms <n> Sampling window duration (default ${DEFAULT_SAMPLE_MS})\n --interval-ms <n> Sampling cadence (default ${DEFAULT_INTERVAL_MS})\n --worktrees <n> Seed repo worktree count, including primary (default ${DEFAULT_WORKTREE_COUNT})\n --lineage-depth <n> Nest all worktrees under one expanded lineage, up to this depth\n --agents-per-worktree <n> Seed this many visible inline agent rows per worktree\n --zustand-publications <n> Publish exactly this many store updates during sampling\n --zustand-publication-interval-ms <n> Publication cadence (default ${DEFAULT_ZUSTAND_PUBLICATION_INTERVAL_MS})\n --headful Show the Electron window while measuring\n --skip-build Reuse out/main/index.js instead of building first\n --output <path> Write JSON report to this path\n --disable-renderer-animations Inject measurement-only CSS that disables animations/transitions\n --synthetic-visible-spinners <n> Measurement-only: add visible working spinners\n --synthetic-spinner-animation <smooth|steps> Spinner animation style (default smooth)\n --synthetic-spinner-steps <n> Step count for --synthetic-spinner-animation steps (default 12)\n`
)
}
function run(command, args, options = {}) {
@@ -188,140 +240,6 @@ function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function parseCpuTimeSeconds(value) {
const trimmed = String(value || '').trim()
if (!trimmed) {
return null
}
const [dayOrTime, maybeTime] = trimmed.includes('-') ? trimmed.split('-', 2) : [null, trimmed]
const days = dayOrTime === null ? 0 : Number(dayOrTime)
const parts = maybeTime.split(':').map(Number)
if (!Number.isFinite(days) || parts.some((part) => !Number.isFinite(part))) {
return null
}
if (parts.length === 3) {
return days * 86400 + parts[0] * 3600 + parts[1] * 60 + parts[2]
}
if (parts.length === 2) {
return days * 86400 + parts[0] * 60 + parts[1]
}
if (parts.length === 1) {
return days * 86400 + parts[0]
}
return null
}
function parseUnixProcesses(stdout) {
const rows = []
for (const raw of stdout.split('\n')) {
const line = raw.trim()
if (!line) {
continue
}
const match = line.match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/)
if (!match) {
continue
}
rows.push({
pid: Number(match[1]),
ppid: Number(match[2]),
percentCpu: Number(match[3]),
rssBytes: Number(match[4]) * 1024,
cpuTimeSeconds: parseCpuTimeSeconds(match[5]),
command: match[6]
})
}
return rows
}
function readUnixProcesses() {
const stdout = execFileSync('ps', ['-axo', 'pid=,ppid=,pcpu=,rss=,cputime=,command='], {
encoding: 'utf8',
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
maxBuffer: 20 * 1024 * 1024
})
return parseUnixProcesses(stdout)
}
function readWindowsProcesses() {
const script =
'Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,WorkingSetSize,CommandLine | ConvertTo-Json -Compress'
const result = spawnSync('powershell.exe', ['-NoProfile', '-Command', script], {
encoding: 'utf8',
maxBuffer: 20 * 1024 * 1024
})
if (result.status !== 0) {
throw new Error(result.stderr || 'PowerShell process enumeration failed')
}
const parsed = JSON.parse(result.stdout || '[]')
const entries = Array.isArray(parsed) ? parsed : [parsed]
return entries.map((entry) => ({
pid: Number(entry.ProcessId),
ppid: Number(entry.ParentProcessId),
percentCpu: 0,
cpuTimeSeconds: null,
rssBytes: Number(entry.WorkingSetSize) || 0,
command: String(entry.CommandLine || '')
}))
}
function readProcessRows() {
return process.platform === 'win32' ? readWindowsProcesses() : readUnixProcesses()
}
function descendantsOf(rows, rootPid) {
const children = new Map()
for (const row of rows) {
const list = children.get(row.ppid) ?? []
list.push(row)
children.set(row.ppid, list)
}
const result = []
const stack = [rootPid]
const seen = new Set()
while (stack.length > 0) {
const pid = stack.pop()
if (seen.has(pid)) {
continue
}
seen.add(pid)
const row = rows.find((candidate) => candidate.pid === pid)
if (row) {
result.push(row)
}
for (const child of children.get(pid) ?? []) {
stack.push(child.pid)
}
}
return result
}
function classify(row, rootPid) {
const command = row.command.toLowerCase()
if (row.pid === rootPid) {
return 'main'
}
if (command.includes('daemon-entry')) {
return 'daemon'
}
if (command.includes('--type=gpu-process')) {
return 'gpu'
}
if (command.includes('--type=renderer')) {
return 'renderer'
}
if (command.includes('--type=utility')) {
return 'utility'
}
if (command.includes('--type=')) {
return 'electron-other'
}
if (command.includes('node') || command.includes('/pi') || command.endsWith(' pi')) {
return 'agent-or-node'
}
return 'other-descendant'
}
async function collectRendererIdleState(page) {
return page.evaluate(() => {
const describeElement = (element) => {
@@ -364,93 +282,6 @@ async function collectRendererIdleState(page) {
})
}
function summarizeSamples(samples) {
const byKind = new Map()
for (const sample of samples) {
for (const proc of sample.processes) {
const bucket = byKind.get(proc.kind) ?? { cpuValues: [], rssValues: [], maxProcessCount: 0 }
bucket.cpuValues.push(proc.cpu)
bucket.rssValues.push(proc.rssBytes)
byKind.set(proc.kind, bucket)
}
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
}
for (const [kind, count] of counts) {
byKind.get(kind).maxProcessCount = Math.max(byKind.get(kind).maxProcessCount, count)
}
}
const summary = {}
for (const [kind, values] of byKind) {
const cpuSorted = [...values.cpuValues].sort((a, b) => a - b)
const rssSumBySample = samples.map((sample) =>
sample.processes
.filter((proc) => proc.kind === kind)
.reduce((sum, proc) => sum + proc.rssBytes, 0)
)
summary[kind] = {
meanCpuPercent: mean(values.cpuValues),
p95CpuPercent: percentile(cpuSorted, 0.95),
maxCpuPercent: Math.max(0, ...values.cpuValues),
meanRssBytes: mean(rssSumBySample),
maxProcessCount: values.maxProcessCount
}
}
summary.total = {
meanCpuPercent: mean(samples.map((sample) => sample.totalCpuPercent)),
p95CpuPercent: percentile(
samples.map((sample) => sample.totalCpuPercent).sort((a, b) => a - b),
0.95
),
meanRssBytes: mean(samples.map((sample) => sample.totalRssBytes))
}
return summary
}
function summarizeProcessInventory(samples) {
const inventory = {}
for (const sample of samples) {
const counts = new Map()
for (const proc of sample.processes) {
counts.set(proc.kind, (counts.get(proc.kind) ?? 0) + 1)
const entry = inventory[proc.kind] ?? {
maxProcessCount: 0,
maxCpuPercent: 0,
commandSamples: []
}
entry.maxCpuPercent = Math.max(entry.maxCpuPercent, proc.cpu)
if (!entry.commandSamples.includes(proc.command) && entry.commandSamples.length < 6) {
entry.commandSamples.push(proc.command)
}
inventory[proc.kind] = entry
}
for (const [kind, count] of counts) {
inventory[kind].maxProcessCount = Math.max(inventory[kind].maxProcessCount, count)
}
}
return inventory
}
function mean(values) {
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length
}
function percentile(sorted, fraction) {
if (sorted.length === 0) {
return 0
}
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)
return sorted[index]
}
function terminateProcesses(processes) {
for (const proc of processes) {
try {
process.kill(proc.pid)
} catch {}
}
}
async function main() {
const options = parseArgs(process.argv.slice(2))
const root = path.resolve(import.meta.dirname, '..', '..')
@@ -491,6 +322,11 @@ async function main() {
const page = await app.firstWindow({ timeout: 120_000 })
await page.waitForLoadState('domcontentloaded')
await page.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
null,
{ timeout: 60_000 }
)
const measurementCss = []
if (options.disableRendererAnimations) {
measurementCss.push(
@@ -506,63 +342,111 @@ async function main() {
options.syntheticSpinnerAnimation,
options.syntheticSpinnerSteps
)
await page.evaluate(async (repoPath) => {
await window.api.repos.add({ path: repoPath })
const fixtureState = await page.evaluate(async (repoPath) => {
const added = await window.api.repos.add({ path: repoPath })
if ('error' in added) {
return { error: added.error }
}
const store = window.__store
await store?.getState().fetchRepos()
const repo = store?.getState().repos.find((candidate) => candidate.path === repoPath)
const repo = store?.getState().repos.find((candidate) => candidate.id === added.repo.id)
if (repo) {
await store.getState().updateRepo(repo.id, { externalWorktreeVisibility: 'show' })
await store.getState().fetchWorktrees(repo.id)
const detected = await store
.getState()
.fetchWorktrees(repo.id, { requireAuthoritative: true })
const importedWorktreePaths = (
store.getState().detectedWorktreesByRepo[repo.id]?.worktrees ?? []
)
.filter((worktree) => !worktree.selectedCheckout)
.map((worktree) => worktree.path)
const updated = await store.getState().updateRepo(repo.id, {
externalWorktreeVisibility: 'show',
importedExternalWorktreePaths: importedWorktreePaths,
externalWorktreeInboxBaselinePaths: importedWorktreePaths
})
const refreshed = await store
.getState()
.fetchWorktrees(repo.id, { requireAuthoritative: true })
return {
detected,
updated,
refreshed,
detectedCount: store.getState().detectedWorktreesByRepo[repo.id]?.worktrees.length ?? 0,
importedCount: importedWorktreePaths.length,
visibleCount: store.getState().worktreesByRepo[repo.id]?.length ?? 0
}
}
return { error: 'repo-not-found' }
}, repoDir)
console.log(`[idle-cpu] fixture ${JSON.stringify(fixtureState)}`)
await page.waitForFunction(
() => window.__store?.getState().workspaceSessionReady === true,
(expectedWorktrees) => {
const state = window.__store?.getState()
return (
state?.workspaceSessionReady === true &&
Object.values(state.worktreesByRepo).flat().length === expectedWorktrees
)
},
options.worktrees,
{ timeout: 180_000 }
)
const scaleFixtureState = await configureRendererScaleFixture(page, options, repoDir)
if (scaleFixtureState.applied) {
console.log(`[idle-cpu] scale fixture ${JSON.stringify(scaleFixtureState)}`)
}
await page.waitForFunction(
(expectedAgentRows) => {
const state = window.__store?.getState()
return (
state !== undefined &&
Object.keys(state.agentStatusByPaneKey ?? {}).length >= expectedAgentRows
)
},
scaleFixtureState.seededAgentRows,
{ timeout: 30_000 }
)
await page.waitForFunction(
() => Boolean(document.querySelector('[data-worktree-sidebar] [data-worktree-id]')),
null,
{ timeout: 60_000 }
{ timeout: 30_000 }
)
console.log(
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees}`
`[idle-cpu] root pid=${rootPid}; warmup=${options.warmupMs}ms sample=${options.sampleMs}ms interval=${options.intervalMs}ms worktrees=${options.worktrees} lineage-depth=${options.lineageDepth} agents/worktree=${options.agentsPerWorktree} publications=${options.zustandPublications}`
)
await startRendererTimingProbe(page)
await sleep(options.warmupMs)
const rendererIdleState = await collectRendererIdleState(page)
const deadline = Date.now() + options.sampleMs
const samples = []
let previousSnapshot = null
while (Date.now() <= deadline || samples.length === 0) {
const sampledAt = Date.now()
const processRows = descendantsOf(readProcessRows(), rootPid)
const rawProcesses = processRows.map((row) => ({ ...row, kind: classify(row, rootPid) }))
if (previousSnapshot) {
const elapsedSeconds = Math.max(0.001, (sampledAt - previousSnapshot.at) / 1000)
const previousByPid = new Map(previousSnapshot.processes.map((proc) => [proc.pid, proc]))
const processes = rawProcesses.map((row) => {
const previous = previousByPid.get(row.pid)
const canComputeDelta =
typeof row.cpuTimeSeconds === 'number' && typeof previous?.cpuTimeSeconds === 'number'
const cpu = canComputeDelta
? Math.max(0, ((row.cpuTimeSeconds - previous.cpuTimeSeconds) / elapsedSeconds) * 100)
: row.percentCpu
return { ...row, cpu }
})
samples.push({
at: sampledAt,
elapsedMs: sampledAt - previousSnapshot.at,
totalCpuPercent: processes.reduce((sum, proc) => sum + proc.cpu, 0),
totalRssBytes: processes.reduce((sum, proc) => sum + proc.rssBytes, 0),
processes
})
}
previousSnapshot = { at: sampledAt, processes: rawProcesses }
await sleep(options.intervalMs)
}
const rendererCensusBefore = await collectRendererCensus(page, options.lineageDepth)
const rendererTimingBefore = await snapshotRendererTimingProbe(page)
const publicationPromise = runZustandPublications(
page,
options.zustandPublications,
options.zustandPublicationIntervalMs
)
const sampled = await sampleProcessTreeUntilWorkloadsComplete({
rootPid,
requestedDurationMs: options.sampleMs,
intervalMs: options.intervalMs,
workloadPromise: publicationPromise
})
const samples = sampled.samples
const zustandPublications = sampled.workloadResult
const rendererTimingAfter = await stopRendererTimingProbe(page)
const rendererCensusAfter = await collectRendererCensus(page, options.lineageDepth)
const report = {
benchmark: 'orca-idle-cpu',
createdAt: new Date().toISOString(),
options,
rootPid,
platform: { platform: process.platform, arch: process.arch, cpus: os.cpus().length },
fixtureState,
scaleFixtureState,
rendererIdleState,
rendererCensusBefore,
rendererCensusAfter,
rendererTiming: { before: rendererTimingBefore, after: rendererTimingAfter },
zustandPublications,
samplingWindow: sampled.samplingWindow,
sampleCount: samples.length,
summary: summarizeSamples(samples),
processInventory: summarizeProcessInventory(samples),
@@ -578,7 +462,13 @@ async function main() {
{
summary: report.summary,
processInventory: report.processInventory,
sampleCount: report.sampleCount
sampleCount: report.sampleCount,
scaleFixtureState: report.scaleFixtureState,
rendererCensusBefore: report.rendererCensusBefore,
rendererCensusAfter: report.rendererCensusAfter,
rendererTiming: report.rendererTiming,
zustandPublications: report.zustandPublications,
samplingWindow: report.samplingWindow
},
null,
2
@@ -17,8 +17,8 @@ const defaultFiles = [
const cliArguments = process.argv.slice(2)
const powerShellFlag = cliArguments.indexOf('--powershell')
const configuredPowerShell =
powerShellFlag >= 0 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
if (powerShellFlag >= 0 && !configuredPowerShell) {
powerShellFlag !== -1 ? cliArguments[powerShellFlag + 1] : process.env.ORCA_POWERSHELL_EXECUTABLE
if (powerShellFlag !== -1 && !configuredPowerShell) {
console.error('--powershell requires an executable path')
process.exit(2)
}
+2 -2
View File
@@ -222,8 +222,8 @@ if (process.argv[1]?.endsWith('terminal-e2e-helpers.mjs')) {
const args = process.argv.slice(2)
const portIdx = args.indexOf('--port')
const cmdIdx = args.indexOf('--command')
const port = portIdx >= 0 ? Number(args[portIdx + 1]) : 9444
const command = cmdIdx >= 0 ? args[cmdIdx + 1] : null
const port = portIdx !== -1 ? Number(args[portIdx + 1]) : 9444
const command = cmdIdx !== -1 ? args[cmdIdx + 1] : null
const term = new OrcaTerminal(port)
console.log('Connecting to Orca on CDP port', port, '...')
@@ -5,6 +5,7 @@ import {
accessSync,
chmodSync,
constants as fsConstants,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
@@ -141,7 +142,7 @@ function readGeneratedScripts(home, minMtime) {
const body = readFileSync(path, 'utf8')
const captureIndex = body.indexOf('payload=$(cat)')
const firstExitIndex = body.indexOf('exit 0')
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
if (captureIndex === -1 || firstExitIndex <= captureIndex) {
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
}
return { body, fileName, path, source }
@@ -311,43 +312,37 @@ async function verifyForwarding(scripts, home, payload) {
}
}
// Why: rewrite from the path embedded in the installed command, not a
// reconstructed join(home, ...). That way missing/failing-script cases cannot
// silently re-run the real script if the install layout changes.
function rewriteLauncherScriptPath(command, nextPath) {
const match = /if \[ -f '([^']+)'/.exec(command)
if (!match) {
throw new Error('Installed launcher command did not reference a quoted script path')
}
return command.replaceAll(match[1], nextPath)
}
async function verifyInstalledLauncher(home, payload) {
const settingsPath = join(home, '.claude', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const command = findStrings(settings).find(
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
)
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
if (
!command ||
!command.includes('"$HOME/.orca/agent-hooks/claude-hook.sh"') ||
!command.includes('] && [ -r ') ||
!command.includes('else { command -p cat')
) {
throw new Error('Electron did not install the guarded Claude launcher')
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
try {
const missingPath = join(scratch, 'missing-hook.sh')
const missingResult = await runShell(
rewriteLauncherScriptPath(command, missingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
const failingPath = join(scratch, 'failing-hook.sh')
const failingPath = join(scratch, '.orca', 'agent-hooks', 'claude-hook.sh')
mkdirSync(join(scratch, '.orca', 'agent-hooks'), { recursive: true })
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
chmodSync(failingPath, 0o755)
const failingResult = await runShell(
rewriteLauncherScriptPath(command, failingPath),
command,
payload,
withoutOrcaEnvironment({ HOME: home })
withoutOrcaEnvironment({ HOME: scratch })
)
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
throw new Error('Installed launcher did not preserve a running script failure')
+23 -4
View File
@@ -27,6 +27,12 @@ const checks = [
],
enabled: true
},
{
name: 'Linux snapshot renderer tests',
command: 'python3',
args: ['native/computer-use-linux/runtime_render_test.py'],
enabled: true
},
{
name: 'native provider argument guardrails',
run: verifyNativeArgumentGuardrails,
@@ -69,6 +75,19 @@ const checks = [
run: verifyWindowsProviderHandshake,
enabled: process.platform === 'win32'
},
{
name: 'Windows snapshot renderer tests',
command: 'powershell.exe',
args: [
'-NoLogo',
'-NoProfile',
'-ExecutionPolicy',
'Bypass',
'-File',
'native/computer-use-windows/runtime-render.test.ps1'
],
enabled: process.platform === 'win32'
},
{
name: 'macOS helper app bundle and signature',
run: verifyMacOSHelperApp,
@@ -470,11 +489,11 @@ function verifyNativeArgumentGuardrails() {
function powerShellFunctionBody(source, name) {
const start = source.indexOf(`function ${name}`)
if (start < 0) {
if (start === -1) {
return null
}
const bodyStart = source.indexOf('{', start)
if (bodyStart < 0) {
if (bodyStart === -1) {
return null
}
let depth = 0
@@ -494,11 +513,11 @@ function powerShellFunctionBody(source, name) {
function swiftFunctionBody(source, name) {
const start = source.indexOf(`func ${name}`)
if (start < 0) {
if (start === -1) {
return null
}
const bodyStart = source.indexOf('{', start)
if (bodyStart < 0) {
if (bodyStart === -1) {
return null
}
let depth = 0
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
for (const filename of [
'relay.js',
'relay-watcher.js',
'relay-ai-vault-service.js',
'managed-hook-runtime.js',
NODE_PTY_PATCH_FILENAME,
'.version'
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { performance } from 'node:perf_hooks'
import { planWorktreeSortOrderUpdates } from '../../src/shared/worktree-sort-order-update.ts'
import { reuseEqualCatalogRows } from '../../src/renderer/src/store/slices/worktree-catalog-reconciliation.ts'
const WORKTREE_COUNT = 655
const TAB_COUNT = 1_895
const REFRESH_COUNT = 10
const ROUNDS = 15
const BATCHES_PER_ROUND = 25
function makeFixture() {
const worktrees = Array.from({ length: WORKTREE_COUNT }, (_, index) => ({
id: `repo-${index % 15}::/workspace/${index}`,
sortOrder: WORKTREE_COUNT - index,
lastActivityAt: index % 19,
isArchived: false
}))
const tabs = Array.from({ length: TAB_COUNT }, (_, index) => ({
worktreeId: worktrees[index % worktrees.length].id,
live: index % 3 === 0
}))
return { worktrees, tabs }
}
function runSidebarProjection(worktrees, tabs) {
const liveTabsByWorktree = new Map()
for (const tab of tabs) {
if (tab.live) {
liveTabsByWorktree.set(tab.worktreeId, (liveTabsByWorktree.get(tab.worktreeId) ?? 0) + 1)
}
}
const sorted = [...worktrees].sort(
(left, right) =>
(liveTabsByWorktree.get(right.id) ?? 0) - (liveTabsByWorktree.get(left.id) ?? 0) ||
right.lastActivityAt - left.lastActivityAt ||
right.sortOrder - left.sortOrder
)
return sorted.map((worktree) => worktree.id)
}
function runBaseline() {
const fixture = makeFixture()
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
let catalog = fixture.worktrees
let publications = 0
let persistenceWrites = 0
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
const now = 1_000_000 + refresh
requestedIds.forEach((worktreeId, index) => {
metaById.set(worktreeId, {
...metaById.get(worktreeId),
sortOrder: now - index * 1000
})
persistenceWrites += 1
})
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
const reconciled = reuseEqualCatalogRows(catalog, incoming)
if (reconciled !== catalog) {
catalog = reconciled
publications += 1
runSidebarProjection(catalog, fixture.tabs)
}
}
return { publications, persistenceWrites }
}
function runCandidate() {
const fixture = makeFixture()
const metaById = new Map(fixture.worktrees.map((worktree) => [worktree.id, worktree]))
const requestedIds = fixture.worktrees.map((worktree) => worktree.id)
let catalog = fixture.worktrees
let publications = 0
let persistenceWrites = 0
for (let refresh = 0; refresh < REFRESH_COUNT; refresh += 1) {
const updates = planWorktreeSortOrderUpdates(
requestedIds,
(worktreeId) => metaById.get(worktreeId),
1_000_000 + refresh
)
for (const update of updates) {
metaById.set(update.worktreeId, {
...metaById.get(update.worktreeId),
sortOrder: update.sortOrder
})
}
persistenceWrites += updates.length
const incoming = structuredClone(requestedIds.map((worktreeId) => metaById.get(worktreeId)))
const reconciled = reuseEqualCatalogRows(catalog, incoming)
if (reconciled !== catalog) {
catalog = reconciled
publications += 1
runSidebarProjection(catalog, fixture.tabs)
}
}
return { publications, persistenceWrites }
}
function measureBatch(run) {
let counters
const startedAt = performance.now()
for (let batch = 0; batch < BATCHES_PER_ROUND; batch += 1) {
counters = run()
}
return { elapsedMs: (performance.now() - startedAt) / BATCHES_PER_ROUND, counters }
}
function measureComparison() {
runBaseline()
runCandidate()
const samples = Array.from({ length: ROUNDS }, (_, round) => {
let baseline
let candidate
if (round % 2 === 0) {
baseline = measureBatch(runBaseline)
candidate = measureBatch(runCandidate)
} else {
candidate = measureBatch(runCandidate)
baseline = measureBatch(runBaseline)
}
return { baseline, candidate, ratio: candidate.elapsedMs / baseline.elapsedMs }
}).sort((left, right) => left.ratio - right.ratio)
return samples[Math.floor(samples.length / 2)]
}
const { baseline, candidate } = measureComparison()
if (baseline.counters.publications !== REFRESH_COUNT || candidate.counters.publications !== 0) {
throw new Error('Benchmark fixture no longer reproduces the refresh feedback loop')
}
console.log(
`Worktree refresh churn: ${WORKTREE_COUNT} worktrees, ${TAB_COUNT} tabs, ${REFRESH_COUNT} refreshes`
)
console.log(
`Before: ${baseline.elapsedMs.toFixed(3)} ms, ${baseline.counters.publications} catalog publications, ${baseline.counters.persistenceWrites} rank writes`
)
console.log(
`After: ${candidate.elapsedMs.toFixed(3)} ms, ${candidate.counters.publications} catalog publications, ${candidate.counters.persistenceWrites} rank writes`
)
console.log(
`Reduction: ${((1 - candidate.elapsedMs / baseline.elapsedMs) * 100).toFixed(1)}% CPU, 100% feedback publications`
)
+1 -1
View File
@@ -175,7 +175,7 @@ async function main() {
wslShellArgs(distro, ['/bin/sh', '-lc', escapeWslShCommandForWindows(script)])
)
const markerOffset = result.stdout.indexOf(outputMarker)
if (markerOffset < 0) {
if (markerOffset === -1) {
throw new Error('Login shell did not emit the Git output marker')
}
result.stdout = result.stdout.subarray(markerOffset + Buffer.byteLength(outputMarker))
+3
View File
@@ -12,6 +12,8 @@
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/agent-hooks/managed-hook-script-refresh.ts",
"../src/main/agent-hooks/runtime-home-hook-command.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
@@ -74,6 +76,7 @@
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/startup/windows-shell-path-ownership.ts",
// Why: serve-electron-flag-parity.test.ts checks the Electron-side serve argv rewrite against this
// project's serve spec; the module has no imports, so listing it pulls in nothing else.
"../src/main/startup/serve-mode-argv.ts",
+2 -1
View File
@@ -16,7 +16,8 @@ export default defineConfig({
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why --expose-gc: retention tests need a deterministic collection point to measure what a queue really holds.
execArgv: ['--no-experimental-webstorage', '--expose-gc'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
include: [
+4 -4
View File
@@ -67,9 +67,9 @@ A common point of drift. Use these conventions for any list-style row (worktrees
- **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.
- **Keyboard-selected (cmdk highlight):** do **not** rely on flat `bg-accent` alone on light popover/dialog surfaces — `--accent` (#f5f5f5) is nearly identical to `--background` (#fff), so the cursor vanishes. Use the jump-palette recipe in `main.css` (`.jump-palette-item[data-selected='true']`): `color-mix` foreground into background (~12%) plus an inset ring. Expose the mix as `--jump-palette-selection-surface` when nested cutouts (status pips) must match. 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.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. Mix from existing tokens (`foreground`/`background`/`accent`) so light/dark stay aligned.
### Color mixing
@@ -110,7 +110,7 @@ Don't add a fourth level. If something needs more emphasis than "floating," you'
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.)
- 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` and `repo-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.
@@ -148,7 +148,7 @@ When a control has multiple plausible primitives, use this fork:
| 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 |
| Multi-select with search | `repo-multi-combobox` (mirror its 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) |
+596
View File
@@ -0,0 +1,596 @@
# AI Vault process isolation architecture and implementation plan
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
Last updated: 2026-08-09.
## Decision summary
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
The target has three rules:
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
## Goals
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
- The service can be restarted without restarting the app, runtime, relay, or terminals.
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
- Mixed-version remote peers continue to work.
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
## Non-goals
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
- Rewriting the scanner or changing session discovery semantics during the process migration.
- Moving xterm into another renderer.
- Adding a process per window, worktree, repository, or SSH request.
- Removing the old-relay compatibility fallback in the same change.
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
## Current architecture and coupling
| Path | Current execution | Remaining coupling |
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
## Feature-parity contract
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
| Capability | Required behavior after migration | Owner |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
## Target topology
```text
Desktop / runtime host
Integrated renderer
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
│
└─ host merge + compatibility routing
SSH execution host
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
└────────────────── PTY handler/event loop
```
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
## Process ownership
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
## Service responsibilities
The service owns:
- Source discovery and filesystem metadata reads.
- Transcript streaming and parsing for all supported agents.
- OpenCode SQLite reads and its nested worker lifecycle.
- In-memory and persisted session parse caches.
- The bounded title index and transcript title resolution.
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
- Scan parse concurrency and cooperative cancellation.
- Result construction, deduplication, sorting, and internal paging.
- Per-request metrics that contain counts and timings, not session content.
The router owns:
- Electron IPC, runtime RPC, or relay method registration.
- Authentication and connection ownership already enforced by that transport.
- Public argument validation and execution-host selection.
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
- Multi-host result merging.
- Request coalescing and mapping caller cancellation to the service request.
- Validation of service responses before publishing them across a public boundary.
- Compatibility fallback selection.
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
## Internal process protocol
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
Parent to service:
```ts
type ServiceRequest =
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
| { type: 'invalidate'; paths: string[]; generation: number }
| { type: 'cancel'; id: number }
| { type: 'shutdown' }
```
Service to parent:
```ts
type ServiceResponse =
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
| { type: 'invalidated'; generation: number }
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
| { type: 'fatal'; code: ServiceErrorCode; message: string }
```
Protocol rules:
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
- Continue returning the last good renderer result when a refresh fails.
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
## Lifecycle and supervision
### Startup
- Start lazily on the first scan or title request.
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
### Steady state
- Reuse the process and caches across calls.
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
### Faults and restart
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
- Retry queued work in a new process only when it is safe and has not been cancelled.
- Back off at 250 ms, 1 second, then 5 seconds.
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
## Resource and quality-of-service policy
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
- Queue: 16 calls, coalescing, background replacement, explicit priority.
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
## Renderer publication
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
Add `ai-vault-session-publication-gate.ts` with this behavior:
1. Cache the validated result immediately so another caller can reuse it.
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
3. Otherwise retain only the newest pending result and wait for quiet.
4. Publish after 1 second even if input continues, using a transition and one bounded page.
5. Cancel a pending publication when the scope, host, or component request token changes.
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
## Desktop and runtime implementation
Create these concrete modules:
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
- `src/main/ai-vault/cached-session-list.ts`
- `src/main/ai-vault/session-title-resolver.ts`
- `src/main/ipc/ai-vault-subagent-list.ts`
- `src/main/ai-vault/session-first-user-prompt-read.ts`
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
- `src/main/ipc/ai-vault.ts`
- `src/main/runtime/rpc/methods/ai-vault.ts`
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
## SSH relay sidecar implementation
Create:
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
If the sidecar cannot start or crashes:
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
- Do not scan inline in the relay.
- Keep all PTYs, reconnect state, and other relay methods available.
- Allow the next manual refresh to cross the restart circuit breaker once.
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
## Remote compatibility
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
- New desktop with current relay: calls the same methods and receives the same result shape.
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
## Folder workspaces, WSL, and paths
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
- Continue using `path.join` and the existing host-platform path adapters.
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
## Security boundary
The service is a performance and reliability boundary, not a privilege boundary.
- It has the same OS user and must access the same agent stores.
- Do not expose its IPC channel to renderer code or the network.
- The parent passes validated options; the service validates again before filesystem access.
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
- Preserve symlink and allowed-root checks for title reads.
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
## Packaging
Desktop packaging changes:
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
- Reuse the existing unpacked entry-path pattern used by other forked processes.
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
Relay packaging changes:
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
- Include it in `.version` content hashing and deploy completeness assertions.
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
## Observability
Add spans and counters for:
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
- Parent/child serialization duration and serialized bytes.
- Child RSS and heap-used snapshots at request completion.
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
- Relay sidecar failures separately from PTY and relay-loop health.
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
## Verification matrix
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
| Layer | Required coverage |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
## Failure behavior
| Failure | User-visible Vault behavior | Terminal behavior |
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
## Benchmark methodology
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
Each iteration:
1. Keeps the Vault panel mounted in both arms.
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
3. Alternates arm order to reduce warm-up bias.
4. Runs paced raw-mode terminal echo typing as the control.
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
## Current measurements
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
Pre-implementation parity baseline:
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
### Representative pass
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | -------------------: | ------------------: |
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
| Worst timer drift | 14.2 ms | 24.5 ms |
| Worst frame gap | 9.4 ms | 24.9 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 |
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
### Stress pass
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
| Metric | Control | Refresh active |
| ------------------------------ | ------------------: | -------------------: |
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
| Worst timer drift | 9.3 ms | 43.6 ms |
| Worst frame gap | 9.4 ms | 50.0 ms |
| Renderer long tasks over 50 ms | 0 | 0 |
| Missing echoes | 0 / 240 | 0 / 240 |
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
### Measurement limits
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
## Post-implementation A/B
Run the same build with the rollout switch off and on, alternating launch order:
```bash
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
```
Run at least five representative and five stress passes on:
- macOS Apple Silicon.
- Windows x64 with a folder workspace and a WSL-backed source present.
- Ubuntu 20.04-compatible x64 packaging/runtime.
- A 4-core or smaller machine or constrained CI runner.
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
## Implemented result
The implementation now matches the target topology:
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
Local validation on 2026-08-09:
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
| Metric | Worker control | Worker refresh | Process control | Process refresh |
| --------------------- | -------------: | -------------: | --------------: | --------------: |
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
## Acceptance criteria
Correctness:
- Zero missing terminal echoes.
- The latest seeded Vault session is visibly published in every treatment arm.
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
Performance:
- Treatment parse p95 is no more than 10 ms above its paired control.
- Treatment render p95 is no more than 15 ms above its paired control.
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
Isolation:
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
- The service respects queue, timeout, idle, and heap bounds.
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
## Phased implementation plan
### Phase 0 — measurement harness (completed in this change)
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
- Capture representative and stress baselines.
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
### Phase 1 — desktop and runtime service behind a switch
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
- Reuse current scanner/cache implementations.
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
- Keep the worker path as the disabled-switch fallback.
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
- Add E2E fault injection that kills the service during active terminal typing.
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
### Phase 2 — make the desktop/runtime process the default
- Enable the process by default in development, E2E, and canary builds.
- Collect process RSS, restart, timeout, and latency telemetry without content.
- Validate Windows, Linux, WSL, and constrained-runner results.
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
Exit: no unexplained crash or timeout increase and all acceptance gates green.
### Phase 3 — SSH relay sidecar
- Add the relay sidecar entry and supervisor.
- Remove scanner/parser/cache imports from the relay handler.
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
- Make inline relay scanning impossible; sidecar failure returns a host issue.
Exit: remote typing and fault gates green; current public relay methods unchanged.
### Phase 4 — renderer quiet publication
- Add the shared quiet-publication gate and React transition.
- Retain only the newest pending result per scope/host.
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
### Phase 5 — hardening and cleanup
- Remove the worker implementation after one stable release if the kill switch was unused.
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
- Document operational recovery and service diagnostics.
Exit: kill switch and dead worker code removed only after evidence supports it.
## Rollback
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
- Renderer: disable quiet publication and apply the validated complete result directly.
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
## Confidence and remaining questions
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
The remaining implementation questions are deliberately narrow:
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 17m">
<title>downloads: 17m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 19m">
<title>downloads: 19m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">17m</text>
<text x="90" y="14">17m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">19m</text>
<text x="90" y="14">19m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+18 -3
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.42](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -179,12 +179,14 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<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" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
<a href="https://pi.dev"><kbd><img src="https://pi.dev/favicon.svg" alt="Pi logo" width="16" valign="middle" /> Pi</kbd></a> &nbsp;
<a href="https://omp.sh"><kbd><img src="https://omp.sh/favicon.svg" alt="oh-my-pi logo" width="16" valign="middle" /> oh-my-pi</kbd></a> &nbsp;
<a href="https://hermes-agent.nousresearch.com/docs/"><kbd><img src="https://www.google.com/s2/favicons?domain=nousresearch.com&sz=64" alt="Hermes Agent logo" width="16" valign="middle" /> Hermes Agent</kbd></a> &nbsp;
<a href="https://devin.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=devin.ai&sz=64" alt="Devin logo" width="16" valign="middle" /> Devin</kbd></a> &nbsp;
<a href="https://block.github.io/goose/docs/quickstart/"><kbd><img src="https://www.google.com/s2/favicons?domain=goose-docs.ai&sz=64" alt="Goose logo" width="16" valign="middle" /> Goose</kbd></a> &nbsp;
<a href="https://docs.augmentcode.com/cli/overview"><kbd><img src="https://www.google.com/s2/favicons?domain=augmentcode.com&sz=64" alt="Auggie logo" width="16" valign="middle" /> Auggie</kbd></a> &nbsp;
<a href="https://github.com/autohandai/code-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=autohand.ai&sz=64" alt="Autohand Code logo" width="16" valign="middle" /> Autohand Code</kbd></a> &nbsp;
@@ -211,6 +213,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
- **[onOrca.dev에서 다운로드](https://onorca.dev/download)**
- 또는 빌드를 직접 받기: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [전체 빌드](https://github.com/stablyai/orca/releases/latest)
- headless Linux 서버에서 `orca serve`를 실행하시나요? [Headless Linux 서버 가이드](../reference/headless-linux-server.md)를 확인하세요.
_또는 패키지 매니저로 설치:_
@@ -226,8 +229,8 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217) 또는 [TestFlight 참여](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [APK 0.0.42 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.42/app-release.apk)
---
@@ -235,6 +238,10 @@ yay -S stably-orca-bin
- **Discord:** **[Discord](https://discord.gg/fzjDKHxv8Q)** 커뮤니티에 참여하세요.
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **WeChat:** QR 코드를 스캔해 Orca 커뮤니티 WeChat 그룹 7에 참여하세요.
<img src="../assets/wechat-qr-group7.jpg" alt="Orca 커뮤니티 WeChat 그룹 7 QR 코드" width="160" />
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
@@ -249,6 +256,14 @@ yay -S stably-orca-bin
<img src="https://contrib.rocks/image?repo=stablyai/orca" alt="Orca 기여자" />
</a>
<p align="center">
<img src="../assets/star-history.png" alt="stablyai/orca GitHub Star 히스토리 차트" width="880" />
</p>
## 서명된 빌드
Windows 코드 서명은 [SignPath.io](https://signpath.io)가 후원·제공하며, 인증서는 [SignPath Foundation](https://signpath.org)에서 제공합니다.
## 라이선스
Orca는 [MIT 라이선스](../../LICENSE)에 따라 자유롭게 사용할 수 있는 오픈 소스입니다.
@@ -0,0 +1,328 @@
# Renderer agent-status performance
## Status
This design is adopted for the renderer's high-frequency agent-status path. It
keeps the existing event semantics while bounding the amount of synchronous
store fanout performed for one IPC burst.
It lands in slices. This document and the `bench:idle-cpu` harness land first so
the store changes can be reviewed against a baseline someone else measured. Until
the store slice lands, the `setAgentStatuses` / `transactAgentStatuses` actions
and the agent-status write workload described below are not yet on `main`; the
harness measures scale, listener census, and raw publication fanout only.
## Context
Orca can display a large expanded worktree lineage inside one virtualized list
row. Virtualizing the root row does not virtualize its descendants, so a
100-worktree lineage can mount 100 `WorktreeCard` instances at once.
Agent-status IPC events are bursty. The renderer already groups live events into
a 33 ms window, but the original flush applied every queued event with a
separate Zustand write. Zustand synchronously visits every listener for every
publication. The resulting work therefore grew with both the number of status
events and the number of mounted subscriptions:
```text
burst work ~= status events x store listeners x selector work
```
A production trace captured the renderer repeatedly entering
`flushLiveAgentStatusBurst -> applyAgentStatus -> setAgentStatus -> setState`
through `Set.forEach`. A deterministic 100-worktree fixture reproduces the
structural multiplier; see "Baseline on `main`" below for the currently measured
listener count and publication cost.
The production app later recovered substantially when all configured remote
hosts were removed. Host removal can stop relay/reconnect traffic, remove
mounted remote worktrees, or both, depending on host type and removal options.
That observation identifies remote presence as the production trigger but does
not by itself distinguish traffic volume from mounted-listener fanout.
A read-only reconnect audit ruled out systematic double status emission from a
full PTY replay: replay bytes bypass OSC status parsing. Reconnect still causes
a full terminal-buffer repaint for every attached remote pane, which is a
separate source of renderer work and remains a follow-up investigation.
## Goals
- Keep a large expanded lineage responsive during dense agent-status traffic.
- Preserve every ordered status transition, including repeated updates for one
pane inside the same burst.
- Publish agent-status state once for a deferred live burst.
- Preserve selector identity and child render isolation.
- Make the regression reproducible without relying on a user's production data.
## Non-goals
- Changing the client/server status payload or remote protocol.
- Deduplicating status events by pane.
- Changing agent freshness, retention, history, title, completion, or provider
session behavior.
- Changing remote reconnect, PTY replay, or terminal repaint behavior.
- Redesigning lineage presentation or collapsing worktrees automatically.
## Design
### Bound mounted subscription fanout
Sidebar components select cohesive state bundles with shallow equality instead
of registering one listener per field. Derived arrays and maps retain their
existing shallow identity behavior so unrelated store writes do not rerender a
card. Full agent-list mode keeps its child-level subscription boundary; compact
mode passes the already selected rows to avoid selecting the same inputs twice.
The deterministic 100-worktree fixture pins the resulting listener budget:
| Surface | Listener budget |
| ------------------------------ | --------------: |
| Worktree card state and caches | 2 |
| Agent-row inputs | 1 |
| Worktree activity status | 1 |
| Closed context menu | 1 |
Unmount tests require the listener count to return to its prior baseline. In the
bundled prototype, the fixture without seeded agents fell from 8,518 listeners
to 1,218; with 100 visible agent rows the candidate mounted 1,618. Compare
against the census in "Baseline on `main`", which the harness reports directly.
### Share working-spinner phase without per-element animation queries
Working rows keep the existing compositor-driven CSS animation and shared
visual phase. Each mount derives one negative animation delay from the document
timeline instead of querying `getAnimations()` and mutating the animation start
time. This removes per-row Web Animations setup from dense status transitions
without adding a JavaScript animation clock.
### Fold a burst in event order
The store exposes the single-update action and two batch forms:
- `setAgentStatus(paneKey, payload, ...)` retains the positional single-update
API for the immediate live path.
- `setAgentStatuses(updates)` applies a prebuilt ordered list, while
`transactAgentStatuses(operation)` lets IPC derive each update against the
exact staged state before the single commit.
Both entry points reuse the same single-update state transition. The batch
reducer passes each resulting state into the next update, so a sequence such as
`working -> waiting -> done` retains the same history and timestamps as three
sequential calls. Updates are never keyed or deduplicated before the fold.
The live IPC queue is spliced before it is processed. This preserves the
existing reentrancy guarantee: a synchronous subscriber can enqueue another
event without causing the current queue to be drained recursively. The first
event outside an active burst remains immediate; events accumulated within the
33 ms window are applied as one ordered transaction. Startup snapshots and
bounded pending-hydration retries use the same transaction path instead of
publishing once per restored pane.
Each transaction builds pane-routing ownership once with the same first-match
semantics as the standalone resolver. Split-layout leaf membership is indexed
once per layout root, so a large snapshot performs linear tab and leaf work
instead of rescanning every mounted worktree for every pane.
### Run effects after the transaction
Generated-title work that requires committed state is deferred until after the
transaction. Accepted updates also request freshness scheduling; the outer batch
coalesces those requests and schedules the shared freshness timer once after its
single commit. Generated-title requests are folded in event order and published
together, including first-write and forced-replacement semantics. Resolved tab
titles are projected while the transaction folds, then final title changes are
published together. Completion-triggered review refreshes remain deferred
microtasks.
Bulk title application preserves event order and duplicate-tab behavior while
indexing owners once, cloning each changed owner array once, and replacing each
top-level map once. This keeps the post-commit title phase linear in mounted
tabs plus changed titles.
This separation is important: invoking store actions from inside a Zustand
updater would re-enter the store, while running an effect before the commit
would let it observe stale state.
## Semantic invariants
Sequential and batched application must agree on:
- live and retained agent maps;
- state history, `updatedAt`, and `stateStartedAt`;
- agent identity, model, prompt, tools, assistant messages, and subagents;
- orchestration and provider-session continuity;
- sleeping-session and launch-config recovery records;
- retired/closed-pane rejection and inherited-status suppression;
- retention cleanup and live-map eviction;
- `agentStatusEpoch` and `sortEpoch`;
- automation completion observation across intermediate transitions;
- generated-title inputs, freshness scheduling, and completion refreshes.
Equivalence tests use fixed timestamps and include repeated same-pane
transitions. A publication-count test subscribes to the real store and requires
one notification for a non-empty batch and none for an empty batch.
## Benchmark contract
The benchmark launches an E2E-mode Electron build with the store exposed only
for instrumentation. It creates 100 worktrees in one expanded lineage, verifies
100 mounted cards, captures the store listener census, and then applies seeded
ordered agent-status traffic through the real store action.
The benchmark measures the synchronous store action, not the live IPC leading
edge or post-commit notification path. A real-store snapshot test covers the
end-to-end budget for 100 panes with auto-generated titles enabled: one status,
one bulk generated-title, and one bulk resolved-title publication. Disabling
generated titles removes that middle publication, independent of pane count.
The artifact records only fixed diagnostic fields needed for comparison:
- requested and completed batches and updates;
- store action calls and observed publications;
- elapsed time, throughput, and scheduling drift;
- final-state verification;
- renderer mean, p95, and maximum CPU;
- renderer timer drift and long tasks;
- mounted-card and listener counts.
Raw process inventories, temporary paths, pane identifiers, and DOM text are
diagnostic-only and must not be embedded in the shareable report.
Run baseline and candidate on the same machine and OS with the same Electron
build mode. CPU samples from macOS and Linux are comparable within that
constraint; Windows process CPU collection currently cannot support this
comparison.
## Harness
`pnpm run bench:idle-cpu` drives `config/scripts/run-idle-cpu-benchmark.mjs`,
which composes four modules:
| Module | Responsibility |
| ------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `idle-cpu-renderer-scale-fixture.mjs` | Seeds the lineage, agent rows, and sidebar view state; takes the mounted-card and listener census |
| `idle-cpu-renderer-timing-probe.mjs` | In-page timer drift and long-task probe; runs the no-op publication workload |
| `idle-cpu-process-sampling.mjs` | Classifies the Electron process tree and samples per-role CPU/RSS |
| `idle-cpu-synthetic-spinners.mjs` | Measurement-only visible spinners |
The sampling window extends past `--sample-ms` until the workload settles, and
fails the run rather than reporting a truncated window if the workload overruns
the guard. That is why a 2,000-publication run reports a measured window longer
than the requested one.
`--zustand-publications` publishes an empty partial through the real store, so
each publication costs exactly one full subscriber visit and nothing else. It
isolates the `listeners x selector work` half of the burst-cost model from
agent-status payload work, and it is store-API independent — it measures the
same thing before and after the batching slice.
The agent-status write workload (`--agent-status-batches`,
`--agent-status-write-mode`) is not in this harness yet. It depends on
`setAgentStatuses`, so it lands with the store slice.
## Baseline on `main`
Measured on `main` at `077f5a11cd4` (macOS, arm64, 16 CPUs), Electron built with
`electron-vite --mode e2e`, headless, 100 worktrees at lineage depth 99 with 100
seeded agent rows, 10 s warmup and a 30 s sampling window.
Fixture scale is confirmed by the census rather than assumed: 100 store
worktrees, 100 mounted cards, 100 mounted agent rows, and **9,279 store
listeners**. That listener count is the multiplier the design targets.
2,000 no-op store publications at a 1 ms cadence, three repetitions. The
listener census was 9,279 in every run.
| Measure | Median | Runs |
| ------------------------ | ----------: | ------------------------------ |
| Wall time to complete | 12,325.7 ms | 13,148.6 / 12,325.7 / 11,870.5 |
| p50 scheduling drift | 5,150.3 ms | 5,432.3 / 5,150.3 / 4,945.2 |
| p95 scheduling drift | 9,802.9 ms | 10,570.4 / 9,802.9 / 9,390.0 |
| Renderer mean CPU | 18.25% | 20.25 / 18.25 / 15.88 |
| Renderer p95 CPU | 32.59% | 40.07 / 32.59 / 31.28 |
| Renderer timer drift p95 | 7.0 ms | 7.3 / 5.3 / 7.0 |
2,000 publications requested over 2 s take about 12 s, so the renderer sustains
roughly 160 publications per second at this scale. Each publication is
individually short - the long-task observer recorded zero entries in all three
runs - so the cost surfaces as scheduling drift and sustained CPU rather than as
discrete long tasks. Compare drift and CPU here, not long-task counts.
The idle control at the same scale with `--zustand-publications 0` reports 6.63%
renderer mean CPU, 17.11% p95, and 1.6 ms p95 timer drift. Roughly 11.6 points
of mean renderer CPU are therefore attributable to publication fanout rather than
to the mounted fixture itself. 200 spinner animations run in both cases, so the
control also bounds the animation cost out of the comparison.
Reproduce with:
```bash
pnpm run bench:idle-cpu -- --worktrees 100 --lineage-depth 99 \
--agents-per-worktree 1 --warmup-ms 10000 --sample-ms 30000 \
--zustand-publications 2000 --zustand-publication-interval-ms 1 \
--output /tmp/idle-cpu-baseline.json
```
## Results
Three repetitions used 100 mounted worktrees, lineage depth 99, 100 seeded
agent rows, and verified final state. Medians from the regenerated evidence set
are:
| Single 2,000-update burst | Sequential | Batched |
| ------------------------- | ---------: | ---------: |
| Status-state publications | 2,000 | 1 |
| Store action time | 3,692.0 ms | 188.7 ms |
| Update throughput | 541.7/s | 10,598.8/s |
| Renderer mean CPU | 36.2% | 2.9% |
| Renderer p95 CPU | 107.3% | 8.2% |
| p95 long task | 4,653 ms | 216 ms |
The direct store transaction performs 99.95% fewer status-state publications,
spends 94.9% less time in the store action, and processes updates 19.6 times
faster. Renderer mean CPU falls 92.0%, renderer p95 CPU falls 92.4%, and the p95
long task falls 95.4%.
The 60-burst × 32-update case at 33 ms is a sustained saturation stress, not a
real-time production SLO. Publications fall from 1,920 to 60 and median store
action time falls from 2,791.9 ms to 323.3 ms. Median completion time falls from
5,298.2 ms to 2,710.6 ms, p95 scheduling drift falls from 3,073.0 ms to 664.9
ms, and long-task count falls from 57 to 1. Renderer p95 CPU remains saturated
and noisy in this cadence, so it is not used as the discriminating measure.
The 20-pane artificial OpenCode regression passes with 12.4 ms median key echo,
25.2 ms worst key echo, 19.4 ms maximum timer drift, and zero dropped renderer
backlogs.
These figures come from the bundled prototype and are restated here as the
target. They are re-measured with the harness when the store slice lands.
## Acceptance criteria
- The 100-worktree fixture stays at or below the pinned listener budgets.
- A deferred transaction performs one status-state publication while preserving
ordered final state, including live-map eviction at the 500-row cap.
- A 100-pane startup snapshot performs one status and one bulk resolved-title
publication with generated titles disabled; enabling generated titles adds at
most one ordered bulk publication while preserving final statuses and titles.
- Sequential-versus-batch equivalence tests pass across same-pane transitions
and side-effect-bearing updates.
- Renderer CPU tails and scheduling drift improve in repeated candidate runs.
- The 20-pane artificial terminal test reports no dropped output backlog and no
material typing-latency regression.
- Web typecheck, focused unit tests, lint, max-lines ratchet, and E2E build pass.
## Compatibility
This is renderer-local. It adds no RPC field, stream opcode, persisted data, Git
command, or provider-specific contract. Native, WSL, SSH, relay, folder
workspace, and git-worktree status events enter the same renderer action. Mixed
client/server versions therefore need no capability negotiation.
## Failure containment
The first live event remains immediate. Startup replay and bounded pending
retries fold synchronously without waiting for the 33 ms live-burst window, but
publish their accepted updates together. Empty batches are no-ops. If an update
is stale or targets retired authority, the reducer skips only that update and
continues folding later events in order.
+3
View File
@@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = {
'session-scanner-worker-entry': resolve(
'src/main/ai-vault/session-scanner-worker-entry.ts'
),
'session-scanner-service-entry': resolve(
'src/main/ai-vault/session-scanner-service-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
-6
View File
@@ -93,12 +93,6 @@
"max-lines": ["error", { "max": 531, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["app/terminal-settings.tsx"],
"rules": {
"max-lines": ["error", { "max": 514, "skipBlankLines": true, "skipComments": true }]
}
},
{
"files": ["scripts/mock-server.ts"],
"rules": {
+3 -130
View File
@@ -154,7 +154,7 @@ import { ActionSheetModal } from '../../../../src/components/ActionSheetModal'
import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon'
import { TextInputModal } from '../../../../src/components/TextInputModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { MobileRichMarkdownEditor } from '../../../../src/components/MobileRichMarkdownEditor'
import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader'
import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments'
import {
CustomKeyModal,
@@ -267,7 +267,6 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
import {
@@ -275,7 +274,7 @@ import {
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { colors } from '../../../../src/theme/mobile-theme'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import type { DiffComment, TerminalQuickCommand } from '../../../../../src/shared/types'
@@ -302,132 +301,6 @@ import type {
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}) {
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = (doc.stale && !doc.isDirty) || !doc.editable
const showCopy = doc.saveError || !doc.editable
const showSave = doc.isDirty || doc.saving
const showFloatingActions = statusText || showRefresh || showCopy || showSave
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
function DiffLineRow({
line,
title,
@@ -4636,7 +4509,7 @@ export default function SessionScreen() {
</View>
) : activeMarkdownTab ? (
<View style={styles.markdownFrame}>
<MarkdownReader
<MobileMarkdownReader
documentId={activeMarkdownTab.id}
doc={markdownDocs.get(activeMarkdownTab.id)}
onRefresh={() => void readMarkdownTab(activeMarkdownTab)}
+5 -3
View File
@@ -115,13 +115,15 @@ import {
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { triggerMediumImpact } from '../../../src/platform/haptics'
import {
type GitHubProjectSortDirection,
type GitHubProjectTable as SharedGitHubProjectTable,
groupRows,
isIterationCurrent,
sortRows,
type ProjectGroup
} from '../../../src/tasks/mobile-github-project-group-sort'
} from '../../../../src/shared/github-project-group-sort'
import type {
GitHubProjectSortDirection,
GitHubProjectTable as SharedGitHubProjectTable
} from '../../../../src/shared/github-project-types'
import {
CROSS_REPO_DISPLAY_LIMIT,
isGitHubWorkItemsSshRemoteRequiredError,
+3 -91
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, Switch } from 'react-native'
import { View, Text, Pressable, Switch } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import Animated, {
@@ -9,7 +9,7 @@ import Animated, {
} from 'react-native-reanimated'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { colors, spacing } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
@@ -17,6 +17,7 @@ import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
import { terminalSettingsScreenStyles as styles } from '../src/terminal/terminal-settings-screen-styles'
import {
loadTerminalAutocompleteEnabled,
loadTerminalTextScale,
@@ -380,92 +381,3 @@ export default function TerminalSettingsScreen() {
</GestureHandlerRootView>
)
}
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: radii.card,
overflow: 'hidden'
},
sectionTopGap: {
marginTop: spacing.sm
},
inputGroupGap: {
marginTop: spacing.xl
},
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
}
})
+3
View File
@@ -253,6 +253,9 @@ platform :ios do
upload_to_testflight(
api_key: api_key,
app_identifier: BUNDLE_ID,
# distribute_only means there is no local .ipa to sniff, so fastlane would
# otherwise fall through to an interactive platform prompt and crash in CI.
app_platform: "ios",
app_version: version,
build_number: build_number,
distribute_only: true,
@@ -1,170 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check, ChevronDown } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { RpcClient } from '../transport/rpc-client'
import { searchBaseRefs } from '../source-control/mobile-base-ref-search'
type Props = {
client: RpcClient | null
worktreeId: string
value: string
onChange: (ref: string) => void
editable?: boolean
}
// Base-branch field for the create-PR composer: a free-text input that also searches
// repo refs (debounced) and offers matches to tap — the RN analogue of desktop's
// base-ref combobox. Free text stays valid so an SSH-only / unmatched ref can still
// be entered.
export function MobilePrBasePicker({
client,
worktreeId,
value,
onChange,
editable = true
}: Props) {
const [results, setResults] = useState<string[]>([])
const [focused, setFocused] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Guards: drop results after unmount, and ignore an earlier search whose response
// arrives after a later one (out-of-order network) so stale matches can't clobber.
const mounted = useRef(true)
const seq = useRef(0)
useEffect(() => {
return () => {
mounted.current = false
if (timer.current) {
clearTimeout(timer.current)
}
}
}, [])
const queryRefs = useCallback(
(query: string) => {
if (timer.current) {
clearTimeout(timer.current)
}
if (!client || query.trim().length === 0) {
// Advance the generation so an earlier in-flight search can't land and
// repopulate results after the input was cleared.
seq.current += 1
setResults([])
return
}
timer.current = setTimeout(() => {
const requestSeq = ++seq.current
void searchBaseRefs(client, worktreeId, query.trim())
.then((refs) => {
if (!mounted.current || requestSeq !== seq.current) {
return
}
setResults(refs.filter((r) => r !== query).slice(0, 6))
})
// Why: a rejected ref search must not escape as an unhandled rejection;
// drop to an empty result set (free text stays valid to submit).
.catch(() => {
if (mounted.current && requestSeq === seq.current) {
setResults([])
}
})
}, 200)
},
[client, worktreeId]
)
return (
<View>
<View style={[styles.inputShell, !editable && styles.inputShellDisabled]}>
<TextInput
style={styles.input}
value={value}
onChangeText={(text) => {
onChange(text)
queryRefs(text)
}}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
placeholder="main"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
editable={editable}
/>
<ChevronDown size={14} color={colors.textSecondary} strokeWidth={2.2} />
</View>
{focused && results.length > 0 ? (
<View style={styles.results}>
{results.map((ref) => (
<Pressable
key={ref}
style={({ pressed }) => [styles.resultRow, pressed && styles.resultRowPressed]}
onPress={() => {
onChange(ref)
setResults([])
}}
>
<Text style={styles.resultText} numberOfLines={1}>
{ref}
</Text>
{ref === value ? (
<Check size={14} color={colors.textPrimary} strokeWidth={2.2} />
) : null}
</Pressable>
))}
</View>
) : null}
</View>
)
}
const styles = StyleSheet.create({
inputShell: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs
},
inputShellDisabled: {
opacity: 0.6
},
input: {
flex: 1,
minWidth: 0,
padding: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
},
results: {
marginTop: spacing.xs,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgPanel,
overflow: 'hidden'
},
resultRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
paddingHorizontal: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
resultRowPressed: { backgroundColor: colors.bgRaised },
resultText: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontFamily: typography.monoFamily
}
})
@@ -0,0 +1,84 @@
import { createElement, createRef, forwardRef, useImperativeHandle } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
MobileRichMarkdownEditor,
type MobileRichMarkdownEditorHandle
} from './MobileRichMarkdownEditor'
const mocks = vi.hoisted(() => ({
dismissKeyboard: vi.fn(),
injectJavaScript: vi.fn()
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
Keyboard: { dismiss: mocks.dismissKeyboard },
Linking: { openURL: vi.fn() },
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
View: 'View'
}
})
vi.mock('react-native-webview', () => {
const WebView = forwardRef((props: Record<string, unknown>, ref) => {
useImperativeHandle(ref, () => ({ injectJavaScript: mocks.injectJavaScript }))
return createElement('WebView', props)
})
return { WebView, default: WebView }
})
vi.mock('lucide-react-native', () => ({
Bold: 'Bold',
ChevronDown: 'ChevronDown',
Code2: 'Code2',
FileCode2: 'FileCode2',
Heading1: 'Heading1',
Heading2: 'Heading2',
Heading3: 'Heading3',
ImageIcon: 'ImageIcon',
Italic: 'Italic',
Keyboard: 'Keyboard',
Link: 'Link',
List: 'List',
ListOrdered: 'ListOrdered',
ListTodo: 'ListTodo',
Pilcrow: 'Pilcrow',
Quote: 'Quote',
Strikethrough: 'Strikethrough'
}))
describe('MobileRichMarkdownEditor', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.clearAllMocks()
})
it('exposes WebView keyboard dismissal to its native parent', () => {
const editorRef = createRef<MobileRichMarkdownEditorHandle>()
act(() => {
renderer = create(
createElement(MobileRichMarkdownEditor, {
ref: editorRef,
content: '',
editable: true,
onChange: vi.fn()
})
)
})
act(() => editorRef.current?.dismissKeyboard())
expect(mocks.injectJavaScript).toHaveBeenCalledWith(
expect.stringContaining('window.__orcaRichMarkdown.dismissKeyboard()')
)
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
})
})
@@ -1,5 +1,15 @@
import { memo, useCallback, useEffect, useMemo, useRef, type ComponentType } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
type ComponentType,
type ForwardedRef
} from 'react'
import { Keyboard, Linking, Pressable, ScrollView, StyleSheet, View } from 'react-native'
import {
Bold,
Code2,
@@ -77,6 +87,10 @@ type Props = {
onKeyboardInsetChange?: (bottom: number) => void
}
export type MobileRichMarkdownEditorHandle = {
dismissKeyboard: () => void
}
type EditorWebViewMessage =
| { type: 'ready' }
| { type: 'change'; markdown: string; generation: number }
@@ -107,12 +121,10 @@ const TOOLBAR_ITEMS: ToolbarItem[] = [
{ command: 'codeBlock', label: 'Code block', icon: FileCode2 }
]
function MobileRichMarkdownEditorInner({
content,
editable,
onChange,
onKeyboardInsetChange
}: Props) {
function MobileRichMarkdownEditorInner(
{ content, editable, onChange, onKeyboardInsetChange }: Props,
ref: ForwardedRef<MobileRichMarkdownEditorHandle>
) {
const webViewRef = useRef<WebView>(null)
const readyRef = useRef(false)
const documentGenerationRef = useRef(0)
@@ -227,6 +239,15 @@ function MobileRichMarkdownEditorInner({
[inject]
)
const dismissKeyboard = useCallback(() => {
// Why: the caret lives in the WebView, so the injected blur is what closes the keyboard;
// Keyboard.dismiss only clears a native TextInput that stole focus first.
inject('window.__orcaRichMarkdown && window.__orcaRichMarkdown.dismissKeyboard();')
Keyboard.dismiss()
}, [inject])
useImperativeHandle(ref, () => ({ dismissKeyboard }), [dismissKeyboard])
return (
<View style={styles.container}>
<View style={styles.toolbar}>
@@ -278,7 +299,7 @@ function MobileRichMarkdownEditorInner({
)
}
export const MobileRichMarkdownEditor = memo(MobileRichMarkdownEditorInner)
export const MobileRichMarkdownEditor = memo(forwardRef(MobileRichMarkdownEditorInner))
const styles = StyleSheet.create({
container: {
+1 -2
View File
@@ -149,8 +149,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
// control, matching TextInputModal / MobilePrBasePicker input shells.
// Keep search visually distinct from surrounding panels.
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
@@ -97,7 +97,7 @@ function attrValue(tag: string, name: string): string {
const valueStart = cursor
if (quote) {
const valueEnd = tag.indexOf(quote, cursor)
if (valueEnd < 0) {
if (valueEnd === -1) {
return ''
}
cursor = valueEnd + 1
@@ -191,7 +191,7 @@ function closingMarkupTagNames(value: string): Set<string> {
let cursor = 0
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
if (start === -1) {
return names
}
const end = findMobileMarkdownMarkupTagEnd(value, start + 1)
@@ -228,7 +228,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
const closingTagNames = closingMarkupTagNames(value)
while (cursor < value.length) {
const start = value.indexOf('<', cursor)
if (start < 0) {
if (start === -1) {
return output + value.slice(cursor)
}
output += value.slice(cursor, start)
@@ -261,7 +261,7 @@ export function stripMobileMarkdownMarkupTags(value: string): string {
!/[-:]/.test(name) &&
!knownMarkupTagNames.has(lowerName) &&
!closingTagNames.has(lowerName)
if (isNestedGeneric && nestedStart >= 0) {
if (isNestedGeneric && nestedStart !== -1) {
output += value.slice(start, nestedStart)
cursor = nestedStart
continue
@@ -11,9 +11,7 @@ function editorScript(): string {
return script ?? ''
}
function extractFunctionSource(script: string, name: string): string {
const start = script.indexOf(`function ${name}`)
expect(start).toBeGreaterThanOrEqual(0)
function extractBracedSource(script: string, start: number, label: string): string {
const bodyStart = script.indexOf('{', start)
let depth = 0
for (let index = bodyStart; index < script.length; index += 1) {
@@ -28,7 +26,19 @@ function extractFunctionSource(script: string, name: string): string {
}
}
}
throw new Error(`Could not extract ${name}`)
throw new Error(`Could not extract ${label}`)
}
function extractFunctionSource(script: string, name: string): string {
const start = script.indexOf(`function ${name}`)
expect(start).toBeGreaterThanOrEqual(0)
return extractBracedSource(script, start, name)
}
function extractEditorListenerSource(script: string, type: string): string {
const start = script.indexOf(`editor.addEventListener('${type}'`)
expect(start).toBeGreaterThanOrEqual(0)
return `${extractBracedSource(script, start, `${type} listener`)});`
}
function runtimeMarkdownToHtml(markdown: string, editable: boolean): string {
@@ -65,6 +75,134 @@ function runtimeListMarkdown(): (list: unknown) => string {
return new Function(sources)() as (list: unknown) => string
}
type FakeRange = {
commonAncestorContainer: unknown
cloneRange: () => FakeRange
selectNodeContents: (node: unknown) => void
collapse: (toStart: boolean) => void
}
function createFakeRange(container: unknown): FakeRange {
const range: FakeRange = {
commonAncestorContainer: container,
cloneRange: () => createFakeRange(range.commonAncestorContainer),
selectNodeContents: (node) => {
range.commonAncestorContainer = node
},
collapse: () => {}
}
return range
}
type FakeClickEvent = {
clientX: number
clientY: number
target: { closest: (selector: string) => unknown }
preventDefault: () => void
}
// Drives the editor's real selection and click handling against a stub DOM whose blur
// drops the selection, the way WebKit does.
function createSelectionRuntime(caretContainer: string | null) {
const liveNodes = new Set(caretContainer ? [caretContainer] : [])
const listeners = new Map<string, (event: FakeClickEvent) => void>()
let focused = caretContainer != null
let ranges: FakeRange[] = caretContainer ? [createFakeRange(caretContainer)] : []
let caretAtPoint: string | null = null
const editor = {
contains: (node: unknown) => typeof node === 'string' && liveNodes.has(node),
focus: () => {
focused = true
fakeDocument.activeElement = editor
},
blur: () => {
focused = false
fakeDocument.activeElement = null
ranges = []
},
addEventListener: (type: string, handler: (event: FakeClickEvent) => void) => {
listeners.set(type, handler)
}
}
const fakeDocument: {
activeElement: unknown
createRange: () => FakeRange
caretRangeFromPoint: () => FakeRange | null
} = {
activeElement: focused ? editor : null,
createRange: () => createFakeRange('detached'),
caretRangeFromPoint: () => (caretAtPoint ? createFakeRange(caretAtPoint) : null)
}
const fakeWindow = {
getSelection: () => ({
get rangeCount() {
return ranges.length
},
getRangeAt: (index: number) => ranges[index],
removeAllRanges: () => {
ranges = []
},
addRange: (range: FakeRange) => {
ranges = [range]
}
})
}
const script = editorScript()
const sources = [
'var editor = arguments[0];',
'var document = arguments[1];',
'var window = arguments[2];',
'var editable = true;',
'var savedSelectionRange = null;',
'var selectionDroppedOnBlur = false;',
'function post() {}',
'function emitChange() {}',
extractFunctionSource(script, 'focusEditor'),
extractFunctionSource(script, 'rememberSelection'),
extractFunctionSource(script, 'applySelectionRange'),
extractFunctionSource(script, 'caretRangeAtPoint'),
extractFunctionSource(script, 'dismissKeyboard'),
extractFunctionSource(script, 'restoreSelectionOrEnd'),
extractEditorListenerSource(script, 'click'),
'return { dismissKeyboard: dismissKeyboard, restoreSelectionOrEnd: restoreSelectionOrEnd };'
].join('\n')
const api = new Function(sources)(editor, fakeDocument, fakeWindow) as {
dismissKeyboard: () => void
restoreSelectionOrEnd: () => void
}
return {
dismissKeyboard: api.dismissKeyboard,
restoreSelectionOrEnd: api.restoreSelectionOrEnd,
tapAt: (container: string, options?: { uneditableAncestor?: string }) => {
liveNodes.add(container)
caretAtPoint = container
listeners.get('click')?.({
clientX: 12,
clientY: 34,
target: {
closest: (selector: string) =>
selector === '[contenteditable="false"]' ? (options?.uneditableAncestor ?? null) : null
},
preventDefault: () => {}
})
},
detachEditorContent: () => liveNodes.clear(),
selectedContainer: () => {
if (ranges.length === 0) {
return null
}
const container = ranges[0].commonAncestorContainer
return container === editor ? 'editor-end' : (container as string)
},
get focused() {
return focused
}
}
}
describe('mobile rich markdown editor HTML', () => {
it('builds parseable WebView JavaScript', () => {
const script = editorScript()
@@ -233,4 +371,64 @@ describe('mobile rich markdown editor HTML', () => {
expect(emitChange).not.toContain('window.setTimeout')
expect(emitChange).toContain('generation: pendingGeneration')
})
it('exposes a keyboard dismissal command that blurs WebView focus', () => {
const script = editorScript()
const dismissKeyboard = extractFunctionSource(script, 'dismissKeyboard')
expect(dismissKeyboard).toContain('rememberSelection()')
expect(dismissKeyboard).toContain('document.activeElement.blur()')
expect(dismissKeyboard).toContain('editor.blur()')
expect(script).toContain('dismissKeyboard: dismissKeyboard')
})
it('reclaims editor focus at the tapped caret after keyboard dismissal', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.tapAt('paragraph-7')
expect(runtime.focused).toBe(true)
expect(runtime.selectedContainer()).toBe('paragraph-7')
})
it('leaves task-list label taps to the checkbox instead of refocusing the editor', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.tapAt('paragraph-7', { uneditableAncestor: 'task-label' })
expect(runtime.focused).toBe(false)
expect(runtime.selectedContainer()).toBe(null)
})
it('restores the pre-dismissal caret so commands do not insert at the document end', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
expect(runtime.selectedContainer()).toBe(null)
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('paragraph-3')
expect(runtime.focused).toBe(true)
})
it('falls back to the document end when no caret was ever placed', () => {
const runtime = createSelectionRuntime(null)
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('editor-end')
})
it('drops a remembered caret whose nodes left the document', () => {
const runtime = createSelectionRuntime('paragraph-3')
runtime.dismissKeyboard()
runtime.detachEditorContent()
runtime.restoreSelectionOrEnd()
expect(runtime.selectedContainer()).toBe('editor-end')
})
})
@@ -1,5 +1,7 @@
import { colors } from '../theme/mobile-theme'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT } from './mobile-rich-markdown-keyboard-dismiss-script'
import { MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT } from './mobile-rich-markdown-editor-keyboard-inset-script'
import { MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT } from './mobile-rich-markdown-selection-script'
export function escapeInjectedJavaScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, '<\\/script')
@@ -559,6 +561,9 @@ export function buildMobileRichMarkdownEditorHtml(): string {
window.clearTimeout(inputTimer);
documentGeneration = Number(generation) || 0;
suppressInput = true;
// Why: replacing innerHTML detaches the remembered caret's nodes.
savedSelectionRange = null;
selectionDroppedOnBlur = false;
lastMarkdown = String(markdown || '');
editor.innerHTML = markdownToHtml(lastMarkdown);
syncTaskCheckboxesDisabled();
@@ -571,39 +576,8 @@ export function buildMobileRichMarkdownEditorHtml(): string {
syncTaskCheckboxesDisabled();
}
function focusEditor() {
editor.focus();
}
function restoreSelectionOrEnd() {
focusEditor();
var selection = window.getSelection();
if (!selection || selection.rangeCount > 0) return;
var range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
}
function wrapSelection(tagName) {
restoreSelectionOrEnd();
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) return;
var wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch (_error) {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
selection.removeAllRanges();
selection.selectAllChildren(wrapper);
emitChange();
}
${MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT}
${MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT}
function runCommand(command) {
if (!editable || editor.getAttribute('contenteditable') !== 'true') return;
restoreSelectionOrEnd();
@@ -632,6 +606,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
editor.addEventListener('input', function () {
selectionDroppedOnBlur = false;
if (editable) emitChange();
});
editor.addEventListener('change', function (event) {
@@ -654,7 +629,19 @@ export function buildMobileRichMarkdownEditorHtml(): string {
return;
}
var input = event.target && event.target.closest && event.target.closest('input[type="checkbox"]');
if (!input) return;
if (!input) {
if (!editable) return;
// Why: a task-list label forwards its click to the checkbox, so refocusing here would steal it and re-open the keyboard.
var uneditable = event.target && event.target.closest && event.target.closest('[contenteditable="false"]');
if (uneditable && uneditable !== editor) return;
selectionDroppedOnBlur = false;
if (document.activeElement === editor) return;
// Why: refocusing after a dismissal otherwise types at the stale caret, not where the user tapped.
var caret = caretRangeAtPoint(event.clientX, event.clientY);
focusEditor();
if (caret && editor.contains(caret.commonAncestorContainer)) applySelectionRange(caret);
return;
}
if (!editable) {
event.preventDefault();
return;
@@ -670,7 +657,7 @@ export function buildMobileRichMarkdownEditorHtml(): string {
}
});
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown };
window.__orcaRichMarkdown = { setMarkdown: setMarkdown, setEditable: setEditable, runCommand: runCommand, currentMarkdown: currentMarkdown, dismissKeyboard: dismissKeyboard };
${MOBILE_RICH_MARKDOWN_KEYBOARD_INSET_SCRIPT}
post({ type: 'ready' });
})();
@@ -0,0 +1,13 @@
// Composed into the editor document after MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT, whose
// rememberSelection/selectionDroppedOnBlur this depends on.
export const MOBILE_RICH_MARKDOWN_KEYBOARD_DISMISS_SCRIPT = `
function dismissKeyboard() {
// Why: WebKit discards the DOM selection on blur, so capture the caret before it goes.
rememberSelection();
selectionDroppedOnBlur = true;
if (document.activeElement && document.activeElement.blur) {
document.activeElement.blur();
}
editor.blur();
}
`
@@ -0,0 +1,71 @@
// Caret and selection management for the WebView editor. Split out so the editor
// document script stays inside its line budget.
export const MOBILE_RICH_MARKDOWN_SELECTION_SCRIPT = `
var savedSelectionRange = null;
var selectionDroppedOnBlur = false;
function focusEditor() {
editor.focus();
}
function rememberSelection() {
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (editor.contains(range.commonAncestorContainer)) savedSelectionRange = range.cloneRange();
}
function applySelectionRange(range) {
var selection = window.getSelection();
if (!selection) return;
selection.removeAllRanges();
selection.addRange(range);
savedSelectionRange = range.cloneRange();
}
function caretRangeAtPoint(x, y) {
if (document.caretRangeFromPoint) return document.caretRangeFromPoint(x, y);
if (!document.caretPositionFromPoint) return null;
var position = document.caretPositionFromPoint(x, y);
if (!position) return null;
var range = document.createRange();
range.setStart(position.offsetNode, position.offset);
range.collapse(true);
return range;
}
function restoreSelectionOrEnd() {
focusEditor();
var selection = window.getSelection();
if (!selection) return;
// Why: the blur dropped the live selection, so commands would otherwise insert at the document end.
if (selectionDroppedOnBlur && savedSelectionRange && editor.contains(savedSelectionRange.commonAncestorContainer)) {
selectionDroppedOnBlur = false;
applySelectionRange(savedSelectionRange);
return;
}
if (selection.rangeCount > 0) return;
var range = document.createRange();
range.selectNodeContents(editor);
range.collapse(false);
applySelectionRange(range);
}
function wrapSelection(tagName) {
restoreSelectionOrEnd();
var selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
var range = selection.getRangeAt(0);
if (range.collapsed) return;
var wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch (_error) {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
selection.removeAllRanges();
selection.selectAllChildren(wrapper);
emitChange();
}
`
@@ -1,289 +0,0 @@
import { useCallback, useState } from 'react'
import { ActivityIndicator, Pressable, Switch, Text, TextInput, View } from 'react-native'
import {
ArrowRight,
GitMerge,
GitPullRequestArrow,
Sparkles,
TriangleAlert,
X
} from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import type { RpcSuccess } from '../../transport/types'
import { triggerError, triggerSuccess } from '../../platform/haptics'
import {
createMobilePr,
getMobilePrCreateSuccessWarning,
shouldPushBeforeMobilePrCreate,
type MobilePrPrefill
} from '../../source-control/mobile-pr-create'
import { hostedReviewCopy } from '../../source-control/hosted-review-copy'
import {
getPrComposeDisabledReason,
isBaseHeadDistinct
} from '../../source-control/pr-compose-validation'
import { MobilePrBasePicker } from '../MobilePrBasePicker'
import { mobilePrComposeFormStyles as styles } from './mobile-pr-compose-form-styles'
export type PrComposePrefill = MobilePrPrefill
type Props = {
client: RpcClient | null
worktreeId: string
prefill: PrComposePrefill
// Head branch — enables the base≠head guard and the "from <branch>" hint.
head?: string | null
onCancel: () => void
onCreated: (url: string, warning?: string) => void
}
// A plain View keeps the compose form usable inside the PR sidebar ScrollView.
export function MobilePrComposeForm({
client,
worktreeId,
prefill,
head,
onCancel,
onCreated
}: Props) {
const copy = hostedReviewCopy(prefill.provider)
const ReviewIcon = prefill.provider === 'gitlab' ? GitMerge : GitPullRequestArrow
const [title, setTitle] = useState(prefill.title)
const [body, setBody] = useState(prefill.body)
const [base, setBase] = useState(prefill.base)
const [draft, setDraft] = useState(false)
const [generating, setGenerating] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const pushBeforeCreate = shouldPushBeforeMobilePrCreate(prefill)
const generate = useCallback(async () => {
if (!client || generating) {
return
}
setGenerating(true)
setError(null)
try {
const response = await client.sendRequest('git.generatePullRequestFields', {
worktree: `id:${worktreeId}`,
base,
title,
body,
draft
})
if (!response.ok) {
setError(response.error?.message || 'Failed to generate PR fields')
return
}
const result = (response as RpcSuccess).result as {
success?: boolean
fields?: { base: string; title: string; body: string; draft: boolean }
error?: string
}
if (result.success && result.fields) {
setBase(result.fields.base || base)
setTitle(result.fields.title || title)
setBody(result.fields.body || body)
setDraft(result.fields.draft)
} else if (result.error) {
setError(result.error)
}
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Failed to generate PR fields')
} finally {
setGenerating(false)
}
}, [base, body, client, draft, generating, title, worktreeId])
const headRef = head ?? ''
const baseConflict = base.trim().length > 0 && !isBaseHeadDistinct(base, headRef)
const submitDisabledReason = getPrComposeDisabledReason({
title,
base,
head: headRef,
generating,
reviewLabel: copy.reviewLabel
})
const canSubmit = submitDisabledReason === null
const fieldsLocked = submitting || generating
const submit = useCallback(async () => {
if (!client || submitting || !canSubmit) {
return
}
setSubmitting(true)
setError(null)
try {
const outcome = await createMobilePr(client, worktreeId, {
provider: prefill.provider,
base,
// Send the same head the submit guard validated against, so the PR opens
// from the validated branch instead of a host-inferred one.
...(head ? { head } : {}),
title,
body,
draft,
pushBeforeCreate
})
if (outcome.ok) {
triggerSuccess()
const warning = getMobilePrCreateSuccessWarning(outcome, prefill.provider)
if (warning) {
setError(warning)
}
onCreated(outcome.url, warning)
} else {
triggerError()
setError(outcome.error)
}
} finally {
setSubmitting(false)
}
}, [
base,
body,
canSubmit,
client,
copy.titleLabel,
draft,
head,
onCreated,
prefill.provider,
pushBeforeCreate,
submitting,
title,
worktreeId
])
return (
<View style={styles.root}>
<View style={styles.headingRow}>
<View style={styles.headingTitle}>
<ReviewIcon size={14} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.heading}>New {copy.reviewLabel}</Text>
</View>
<View style={styles.headingActions}>
<Pressable
style={({ pressed }) => [styles.genButton, pressed && styles.genButtonPressed]}
disabled={generating || submitting}
onPress={() => void generate()}
accessibilityRole="button"
accessibilityLabel={`Generate ${copy.reviewLabel} details with AI`}
>
{generating ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
<Text style={styles.genButtonText}>{generating ? 'Generating…' : 'Generate'}</Text>
</Pressable>
<Pressable
style={styles.iconButton}
onPress={onCancel}
disabled={submitting}
accessibilityRole="button"
accessibilityLabel="Cancel"
hitSlop={8}
>
<X size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</View>
</View>
{head ? (
<View style={styles.branchFlow}>
<Text style={styles.branchToken} numberOfLines={1}>
{head}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text
style={[styles.branchToken, baseConflict && styles.branchTokenError]}
numberOfLines={1}
>
{base || 'base'}
</Text>
</View>
) : null}
<View style={styles.fieldStack}>
<TextInput
style={styles.titleInput}
value={title}
onChangeText={setTitle}
placeholder="Title"
placeholderTextColor={colors.textMuted}
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} title`}
/>
<TextInput
style={styles.bodyInput}
value={body}
onChangeText={setBody}
placeholder="Description (optional)"
placeholderTextColor={colors.textMuted}
multiline
editable={!fieldsLocked}
accessibilityLabel={`${copy.titleLabel} description`}
/>
</View>
{generating ? (
<View style={styles.notice}>
<Sparkles size={13} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.noticeText}>Generating title and description…</Text>
</View>
) : null}
<View style={styles.baseRow}>
<Text style={styles.baseLabel}>Base</Text>
<View style={styles.baseControl}>
<MobilePrBasePicker
client={client}
worktreeId={worktreeId}
value={base}
onChange={setBase}
editable={!fieldsLocked}
/>
</View>
</View>
<View style={styles.draftRow}>
<Text style={styles.draftText}>Create as draft</Text>
<Switch value={draft} onValueChange={setDraft} disabled={fieldsLocked} />
</View>
{error || submitDisabledReason ? (
<View style={styles.notice}>
<TriangleAlert size={13} color={colors.statusRed} strokeWidth={2.1} />
<Text style={[styles.noticeText, styles.errorText]}>{error ?? submitDisabledReason}</Text>
</View>
) : null}
<Pressable
style={({ pressed }) => [
styles.submit,
(submitting || !canSubmit) && styles.submitDisabled,
pressed && styles.submitPressed
]}
disabled={submitting || !canSubmit}
onPress={() => void submit()}
accessibilityRole="button"
>
{submitting ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<ReviewIcon size={14} color={colors.bgBase} strokeWidth={2.2} />
)}
<Text style={styles.submitText}>
{pushBeforeCreate
? draft
? `Push & create draft ${copy.shortLabel}`
: `Push & create ${copy.shortLabel}`
: draft
? `Create draft ${copy.shortLabel}`
: `Create ${copy.shortLabel}`}
</Text>
</Pressable>
</View>
)
}
@@ -7,7 +7,7 @@ import { canEditComment, isResolvableComment } from '../../session/pr-comment-ac
import { ConfirmModal } from '../ConfirmModal'
import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentComposer } from './PRCommentComposer'
import { formatPrCommentRelativeTime } from './pr-comment-time'
import { formatPrCommentRelativeTime } from '../../../../src/shared/pr-comment-time'
import { prCommentsStyles as styles } from './pr-comments-styles'
export type PRCommentRepoSlug = { owner: string; repo: string; host?: string }
@@ -12,12 +12,14 @@ import { CommentMarkdown } from './CommentMarkdown'
import { PRCommentCard, type PRCommentCardActions } from './PRCommentCard'
import { PRCommentComposer } from './PRCommentComposer'
import {
PR_COMMENT_AUDIENCE_FILTERS,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
getPRCommentAudienceEmptyLabel,
type PRCommentAudienceFilter
} from './pr-comment-audience'
} from '../../../../src/shared/pr-comment-audience'
import {
PR_COMMENT_AUDIENCE_FILTERS,
getPRCommentAudienceEmptyLabel
} from './pr-comment-audience-labels'
import {
getPRCommentGroupCount,
getPRCommentGroupId,
@@ -25,7 +27,7 @@ import {
groupPRComments,
isResolvedPRCommentGroup,
type PRCommentGroup
} from './pr-comment-groups'
} from '../../../../src/shared/pr-comment-groups'
import { prCommentsStyles as styles } from './pr-comments-styles'
import { mobilePrSidebarStyles as shared } from './mobile-pr-sidebar-styles'
@@ -1,165 +0,0 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
export const mobilePrComposeFormStyles = StyleSheet.create({
root: {
gap: spacing.sm
},
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
marginBottom: spacing.xs
},
headingTitle: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
heading: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
headingActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
genButton: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
genButtonPressed: {
opacity: 0.7
},
genButtonText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '700'
},
iconButton: {
minWidth: 32,
minHeight: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
branchFlow: {
minHeight: 28,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
branchToken: {
maxWidth: 116,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
branchTokenError: {
color: colors.statusRed
},
fieldStack: {
gap: spacing.sm
},
titleInput: {
minHeight: 40,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
bodyInput: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
color: colors.textPrimary,
fontSize: typography.bodySize,
// Why: a moderate fixed height avoids over-expanding inside the sidebar scroll.
minHeight: 120,
textAlignVertical: 'top'
},
baseRow: {
minHeight: 40,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
baseLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize,
width: 36
},
baseControl: {
flex: 1,
minWidth: 0
},
draftRow: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm
},
draftText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '700'
},
notice: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs
},
noticeText: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
errorText: {
color: colors.statusRed
},
submit: {
marginTop: spacing.xs,
minHeight: 44,
borderRadius: radii.button,
backgroundColor: colors.textPrimary,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.xs
},
submitDisabled: {
opacity: 0.45
},
submitPressed: {
opacity: 0.8
},
submitText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
}
})
@@ -0,0 +1,18 @@
import type { PRCommentAudienceFilter } from '../../../../src/shared/pr-comment-audience'
export const PR_COMMENT_AUDIENCE_FILTERS = [
{ value: 'all', label: 'All' },
{ value: 'human', label: 'Humans' },
{ value: 'bot', label: 'Bots' }
] satisfies { value: PRCommentAudienceFilter; label: string }[]
export function getPRCommentAudienceEmptyLabel(filter: PRCommentAudienceFilter): string {
switch (filter) {
case 'bot':
return 'No bot comments.'
case 'human':
return 'No human comments.'
case 'all':
return 'No comments yet.'
}
}
@@ -1,68 +0,0 @@
import type { PRComment } from '../../../../src/shared/types'
// Thread grouping for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-groups.ts) minus the DOM class constants.
// Inline review comments sharing a threadId collapse into a root + replies; all
// other comments are standalone. Upstream order is preserved.
export type PRCommentGroup =
| { kind: 'standalone'; comment: PRComment }
| { kind: 'thread'; threadId: string; root: PRComment; replies: PRComment[] }
export function groupPRComments(comments: PRComment[]): PRCommentGroup[] {
const threadMap = new Map<string, { root: PRComment; replies: PRComment[] }>()
const groupsByFirstComment = new Map<PRComment, PRCommentGroup>()
for (const comment of comments) {
if (!comment.threadId) {
groupsByFirstComment.set(comment, { kind: 'standalone', comment })
continue
}
const existing = threadMap.get(comment.threadId)
if (existing) {
existing.replies.push(comment)
continue
}
threadMap.set(comment.threadId, { root: comment, replies: [] })
}
const emitted = new Set<string>()
const groups: PRCommentGroup[] = []
for (const comment of comments) {
if (!comment.threadId) {
const group = groupsByFirstComment.get(comment)
if (group) {
groups.push(group)
}
continue
}
if (emitted.has(comment.threadId)) {
continue
}
emitted.add(comment.threadId)
const thread = threadMap.get(comment.threadId)
if (thread) {
groups.push({ kind: 'thread', threadId: comment.threadId, ...thread })
}
}
return groups
}
export function getPRCommentGroupComments(group: PRCommentGroup): PRComment[] {
return group.kind === 'thread' ? [group.root, ...group.replies] : [group.comment]
}
export function getPRCommentGroupRoot(group: PRCommentGroup): PRComment {
return group.kind === 'thread' ? group.root : group.comment
}
export function getPRCommentGroupCount(group: PRCommentGroup): number {
return getPRCommentGroupComments(group).length
}
export function isResolvedPRCommentGroup(group: PRCommentGroup): boolean {
return getPRCommentGroupRoot(group).isResolved === true
}
export function getPRCommentGroupId(group: PRCommentGroup): string {
return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}`
}
@@ -1,81 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
} from './pr-comment-audience'
import { groupPRComments, isResolvedPRCommentGroup } from './pr-comment-groups'
import { formatPrCommentRelativeTime } from './pr-comment-time'
function comment(overrides: Partial<PRComment> & { id: number }): PRComment {
return {
author: 'octocat',
authorAvatarUrl: '',
body: '',
createdAt: '',
url: '',
...overrides
}
}
describe('pr comment audience', () => {
it('classifies app bots, [bot] suffix, and known automation logins', () => {
expect(isBotPRComment(comment({ id: 1, author: 'alice' }))).toBe(false)
expect(isBotPRComment(comment({ id: 2, author: 'alice', isBot: true }))).toBe(true)
expect(isBotPRComment(comment({ id: 3, author: 'renovate[bot]' }))).toBe(true)
expect(isBotPRComment(comment({ id: 4, author: 'coderabbitai' }))).toBe(true)
})
it('counts and filters by audience', () => {
const comments = [
comment({ id: 1, author: 'alice' }),
comment({ id: 2, author: 'dependabot[bot]' }),
comment({ id: 3, author: 'bob' })
]
expect(getPRCommentAudienceCounts(comments)).toEqual({ all: 3, human: 2, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot').map((c) => c.id)).toEqual([2])
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
expect(isBotPRComment(comments[1])).toBe(false)
expect(isBotPRComment(comments[1], overrides)).toBe(true)
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
})
})
describe('pr comment groups', () => {
it('threads comments sharing a threadId as root + replies, preserving order', () => {
const comments = [
comment({ id: 1, author: 'a' }),
comment({ id: 2, author: 'b', threadId: 't1', isResolved: true }),
comment({ id: 3, author: 'c', threadId: 't1' })
]
const groups = groupPRComments(comments)
expect(groups).toHaveLength(2)
expect(groups[0]).toEqual({ kind: 'standalone', comment: comments[0] })
expect(groups[1].kind).toBe('thread')
if (groups[1].kind === 'thread') {
expect(groups[1].root.id).toBe(2)
expect(groups[1].replies.map((r) => r.id)).toEqual([3])
}
expect(isResolvedPRCommentGroup(groups[1])).toBe(true)
})
})
describe('pr comment relative time', () => {
const now = Date.parse('2026-06-16T12:00:00Z')
it('formats buckets and rejects bad input', () => {
expect(formatPrCommentRelativeTime('2026-06-16T11:59:30Z', now)).toBe('just now')
expect(formatPrCommentRelativeTime('2026-06-16T11:30:00Z', now)).toBe('30m ago')
expect(formatPrCommentRelativeTime('2026-06-16T09:00:00Z', now)).toBe('3h ago')
expect(formatPrCommentRelativeTime('2026-06-10T12:00:00Z', now)).toBe('6d ago')
expect(formatPrCommentRelativeTime('not-a-date', now)).toBe('')
})
})
@@ -1,30 +0,0 @@
// Relative timestamp for PR comments (ISO string in, "Xm/Xh/Xd/Xmo/Xy" out),
// mirroring the desktop formatRelativeTime so the timeline reads the same. Pure +
// unit-testable; nowMs is passed in (Date.now() is unavailable in some contexts).
export function formatPrCommentRelativeTime(iso: string, nowMs: number): string {
const ts = Date.parse(iso)
if (Number.isNaN(ts)) {
return ''
}
const delta = nowMs - ts
if (delta < 60_000) {
return 'just now'
}
const minutes = Math.floor(delta / 60_000)
if (minutes < 60) {
return `${minutes}m ago`
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return `${hours}h ago`
}
const days = Math.floor(hours / 24)
if (days < 30) {
return `${days}d ago`
}
const months = Math.floor(days / 30)
if (months < 12) {
return `${months}mo ago`
}
return `${Math.floor(months / 12)}y ago`
}
+1 -1
View File
@@ -132,7 +132,7 @@ export function getDirectoryCacheState(
): DirectoryState | undefined {
// Why: repository paths are arbitrary object keys; inherited keys like
// "constructor" must not masquerade as loaded directory state.
return Object.prototype.hasOwnProperty.call(cache, relativePath) ? cache[relativePath] : undefined
return Object.hasOwn(cache, relativePath) ? cache[relativePath] : undefined
}
export function joinRelativePath(parentPath: string, name: string): string {
@@ -37,7 +37,7 @@ export function useActiveWorktreeScroll<T extends WithId, S>(
(worktreeId: string): boolean => {
for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex++) {
const itemIndex = sections[sectionIndex].data.findIndex((w) => w.worktreeId === worktreeId)
if (itemIndex >= 0) {
if (itemIndex !== -1) {
sectionListRef.current?.scrollToLocation({
sectionIndex,
itemIndex,
@@ -0,0 +1,119 @@
import { createElement, forwardRef, useImperativeHandle } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdownReader } from './MobileMarkdownReader'
import type { MarkdownDocState } from './mobile-session-route-types'
const mocks = vi.hoisted(() => ({
dismissKeyboard: vi.fn(),
reportKeyboardInset: null as ((bottom: number) => void) | null
}))
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Platform: { OS: 'ios', select: (choices: Record<string, unknown>) => choices.ios },
Pressable: ({ children, ...props }: { children?: unknown }) =>
React.createElement('Pressable', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
Keyboard: 'KeyboardIcon',
RefreshCw: 'RefreshCw'
}))
vi.mock('../components/MobileRichMarkdownEditor', () => {
const Editor = forwardRef(
(props: { onKeyboardInsetChange?: (bottom: number) => void }, ref: unknown) => {
mocks.reportKeyboardInset = props.onKeyboardInsetChange ?? null
useImperativeHandle(ref as never, () => ({ dismissKeyboard: mocks.dismissKeyboard }))
return createElement('MobileRichMarkdownEditor')
}
)
return { MobileRichMarkdownEditor: Editor }
})
const readyDoc: MarkdownDocState = {
status: 'ready',
content: '# Notes',
localContent: '# Notes',
baseVersion: '1',
isDirty: false,
editable: true
}
describe('MobileMarkdownReader', () => {
let renderer: ReactTestRenderer | null = null
function render(keyboardLift: number, doc: MarkdownDocState = readyDoc): ReactTestRenderer {
act(() => {
renderer = create(
createElement(MobileMarkdownReader, {
documentId: 'doc-1',
doc,
keyboardLift,
onRefresh: vi.fn(),
onChange: vi.fn(),
onSave: vi.fn(),
onCopy: vi.fn(),
onDiscard: vi.fn()
})
)
})
return renderer as unknown as ReactTestRenderer
}
function dismissButtons(instance: ReactTestRenderer) {
return instance.root.findAll(
(node) =>
typeof node.type === 'string' && node.props?.accessibilityLabel === 'Dismiss keyboard'
)
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
mocks.reportKeyboardInset = null
vi.clearAllMocks()
})
it('offers keyboard dismissal while the keyboard covers the editor', () => {
const instance = render(291)
const [button] = dismissButtons(instance)
expect(button).toBeDefined()
act(() => button.props.onPress())
expect(mocks.dismissKeyboard).toHaveBeenCalledOnce()
})
it('hides the floating row entirely on a clean document with the keyboard closed', () => {
const instance = render(0)
expect(dismissButtons(instance)).toHaveLength(0)
expect(instance.root.findAllByType('Text')).toHaveLength(0)
})
it('keeps document actions available without offering dismissal when the keyboard is closed', () => {
const instance = render(0, { ...readyDoc, isDirty: true })
expect(dismissButtons(instance)).toHaveLength(0)
expect(instance.root.findAllByType('Text').length).toBeGreaterThan(0)
})
it('treats a WebView-reported inset as an open keyboard when native lift reports none', () => {
const instance = render(0)
expect(dismissButtons(instance)).toHaveLength(0)
act(() => mocks.reportKeyboardInset?.(291))
expect(dismissButtons(instance)).toHaveLength(1)
})
})
+172
View File
@@ -0,0 +1,172 @@
import { useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, Keyboard as KeyboardIcon, RefreshCw } from 'lucide-react-native'
import {
MobileRichMarkdownEditor,
type MobileRichMarkdownEditorHandle
} from '../components/MobileRichMarkdownEditor'
import { colors, spacing } from '../theme/mobile-theme'
import {
resolveMarkdownFloatingActionsBottom,
shouldShowMarkdownFloatingActions
} from './markdown-floating-actions-layout'
import type { MarkdownDocState } from './mobile-session-route-types'
import { styles } from './mobile-session-styles'
type Props = {
documentId: string
doc: MarkdownDocState | undefined
onRefresh: () => void
onChange: (content: string) => void
onSave: () => void
onCopy: () => void
onDiscard: () => void
keyboardLift: number
}
export function MobileMarkdownReader({
documentId,
doc,
onRefresh,
onChange,
onSave,
onCopy,
onDiscard,
keyboardLift
}: Props) {
const editorRef = useRef<MobileRichMarkdownEditorHandle>(null)
// Native Keyboard events under-report the WebView editor's covered area, so prefer the larger WebView-measured inset.
const [webviewKeyboardInset, setWebviewKeyboardInset] = useState(0)
const effectiveKeyboardLift = Math.max(keyboardLift, webviewKeyboardInset)
const keyboardOpen = effectiveKeyboardLift > 0
if (!doc || doc.status === 'loading') {
return (
<View style={styles.markdownState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
)
}
if (doc.status === 'error') {
return (
<View style={styles.markdownState}>
<Text style={styles.markdownError}>{doc.message}</Text>
<Pressable style={styles.markdownRefreshButton} onPress={onRefresh}>
<RefreshCw size={14} color={colors.textPrimary} />
<Text style={styles.markdownRefreshText}>Retry</Text>
</Pressable>
</View>
)
}
const statusText = doc.saveError
? doc.saveError
: doc.readOnlyReason
? 'Read only'
: doc.stale
? 'Changed on desktop'
: null
const showRefresh = Boolean((doc.stale && !doc.isDirty) || !doc.editable)
const showCopy = Boolean(doc.saveError || !doc.editable)
const showSave = Boolean(doc.isDirty || doc.saving)
const showFloatingActions = shouldShowMarkdownFloatingActions({
keyboardLift: effectiveKeyboardLift,
hasStatus: statusText != null,
showRefresh,
showCopy,
showSave
})
return (
<View style={styles.markdownEditor}>
<MobileRichMarkdownEditor
ref={editorRef}
key={documentId}
content={doc.localContent}
editable={doc.editable && !doc.saving}
onChange={onChange}
onKeyboardInsetChange={setWebviewKeyboardInset}
/>
{showFloatingActions ? (
<View
pointerEvents="box-none"
style={[
styles.markdownFloatingBar,
// Why: editor focus lives in a WebView, so lift native Save/Discard controls instead of resizing it.
{
bottom: resolveMarkdownFloatingActionsBottom({
keyboardLift: effectiveKeyboardLift,
restingBottom: spacing.lg,
liftedClearance: spacing.md
})
}
]}
>
{statusText ? (
<Text
style={[styles.markdownFloatingStatus, doc.saveError ? styles.markdownError : null]}
numberOfLines={2}
>
{statusText}
</Text>
) : null}
<View style={styles.markdownFloatingActions}>
{keyboardOpen ? (
<Pressable
style={[styles.markdownFloatingButton, styles.markdownKeyboardDismissButton]}
onPress={() => editorRef.current?.dismissKeyboard()}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the markdown editor open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
) : null}
{showCopy ? (
<Pressable style={styles.markdownFloatingButton} onPress={onCopy}>
<Text style={styles.markdownFloatingButtonText}>Copy</Text>
</Pressable>
) : null}
{showRefresh ? (
<Pressable style={styles.markdownFloatingButton} onPress={onRefresh}>
<RefreshCw size={13} color={colors.textPrimary} />
<Text style={styles.markdownFloatingButtonText}>Refresh</Text>
</Pressable>
) : null}
{doc.isDirty ? (
<Pressable style={styles.markdownFloatingButton} onPress={onDiscard}>
<Text style={styles.markdownFloatingButtonText}>Discard</Text>
</Pressable>
) : null}
{showSave ? (
<Pressable
style={[
styles.markdownFloatingButton,
styles.markdownSaveButton,
(!doc.editable || !doc.isDirty || doc.saving) && styles.markdownButtonDisabled
]}
disabled={!doc.editable || !doc.isDirty || doc.saving}
onPress={onSave}
>
{doc.saving ? (
<ActivityIndicator size="small" color={colors.textPrimary} />
) : (
<Text style={styles.markdownFloatingButtonText}>Save</Text>
)}
</Pressable>
) : null}
</View>
</View>
) : null}
</View>
)
}
@@ -298,11 +298,12 @@ describe('MobileNativeChatComposer', () => {
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const onChangeText = vi.fn()
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onChangeText,
onSend: vi.fn().mockResolvedValue(true),
agent: 'claude'
})
@@ -325,6 +326,7 @@ describe('MobileNativeChatComposer', () => {
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
expect(onChangeText).toHaveBeenCalledWith('/clear ')
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
+10 -15
View File
@@ -276,7 +276,6 @@ function AgentControls({
function MobileNativeChatMessageImpl({
message,
queued,
toolsExpanded = false,
fontScale = 1,
messageIndex,
@@ -284,7 +283,6 @@ function MobileNativeChatMessageImpl({
onOpenFile
}: {
message: NativeChatMessage
queued?: boolean
toolsExpanded?: boolean
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
@@ -328,27 +326,24 @@ function MobileNativeChatMessageImpl({
// Copy + scroll-to-top, shown inline with the first tool call (or after the
// prose when there are no tools).
const controls =
isAgent && !queued ? (
<AgentControls
onCopy={handleCopy}
onScrollToTop={
onScrollToMessage && messageIndex !== undefined
? () => onScrollToMessage(messageIndex)
: undefined
}
/>
) : null
const controls = isAgent ? (
<AgentControls
onCopy={handleCopy}
onScrollToTop={
onScrollToMessage && messageIndex !== undefined
? () => onScrollToMessage(messageIndex)
: undefined
}
/>
) : null
return (
<View style={[styles.row, isUser && styles.rowUser]}>
{isUser && queued ? <Text style={styles.queuedTag}>Queued</Text> : null}
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
queued && styles.queued,
copied && styles.copied
]}
>
@@ -71,6 +71,7 @@ type Overrides = {
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
}
function assistantTurn(id: string, text: string): NativeChatMessage {
@@ -117,6 +118,13 @@ describe('MobileNativeChatView', () => {
return (list.props.data as { id: string }[]).map((row) => row.id)
}
function renderedRow(id: string): ReturnType<typeof createElement> {
const list = renderer!.root.find((node) => node.type === 'FlatList')
const data = list.props.data as NativeChatMessage[]
const index = data.findIndex((row) => row.id === id)
return list.props.renderItem({ item: data[index], index })
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
@@ -187,6 +195,15 @@ describe('MobileNativeChatView', () => {
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('renders an accepted optimistic image send without a queued state', async () => {
await render({
pending: [{ id: 'pending-1', text: 'look', images: ['file:///phone-photo.jpg'] }]
})
expect(listIds()).toEqual(['pending-1'])
expect(renderedRow('pending-1').props).not.toHaveProperty('queued')
})
it('keeps a visible lock through a subscribed-end lease blip', async () => {
vi.useFakeTimers()
try {
+3 -6
View File
@@ -58,8 +58,7 @@ type Props = {
loadingEarlier?: boolean
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Optimistic queued sends (owned by the route so they survive view switches). */
/** Optimistic user echoes, including any ridden-along image preview URIs. */
/** Accepted user echoes awaiting transcript replacement, including image previews. */
pending: MobileNativeChatPendingItem[]
/** Local photo URIs retained when the authoritative transcript replaces an
* optimistic image bubble. */
@@ -176,9 +175,8 @@ export function MobileNativeChatView({
[]
)
const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending])
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned optimistic queued messages. Memoize on the same deps so the
// route-owned accepted echoes. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const { data } = useMemo(
() =>
@@ -248,7 +246,6 @@ export function MobileNativeChatView({
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
message={item}
queued={pendingIds.has(item.id)}
toolsExpanded={toolsExpanded}
fontScale={fontScale}
messageIndex={index}
@@ -256,7 +253,7 @@ export function MobileNativeChatView({
onOpenFile={onOpenFile}
/>
),
[pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
[toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useRef, useState } from 'react'
import { View, Text, Pressable, StyleSheet } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { Check, Copy, Pencil, Play, Trash2 } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import type { TerminalQuickCommand } from '../../../src/shared/types'
import {
getQuickCommandDisplayPreview,
getTerminalQuickCommandBody,
isAgentQuickCommand
} from '../terminal/quick-commands'
type QuickCommandRowProps = {
command: TerminalQuickCommand
first: boolean
onLaunch: (command: TerminalQuickCommand) => void
onEdit: (command: TerminalQuickCommand) => void
onDelete: (command: TerminalQuickCommand) => void
disabled: boolean
}
type CopyFeedback = {
body: string
status: 'copied' | 'failed'
}
export function QuickCommandRow({
command,
first,
onLaunch,
onEdit,
onDelete,
disabled
}: QuickCommandRowProps) {
const isAgent = isAgentQuickCommand(command)
const body = getTerminalQuickCommandBody(command)
const canCopy = body.trim().length > 0
// Why: key feedback to the copied body so a prop change drops stale labels
// without setState-in-effect (react-doctor no-adjust-state-on-prop-change).
const [feedback, setFeedback] = useState<CopyFeedback | null>(null)
const copyResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mountedRef = useRef(true)
const copyStatus: 'idle' | 'copied' | 'failed' =
feedback != null && feedback.body === body ? feedback.status : 'idle'
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
}
}, [])
// Drop any pending reset timer when the body changes; display status is already idle.
useEffect(() => {
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
copyResetTimerRef.current = null
}
}, [body])
const handleCopy = async (): Promise<void> => {
if (!canCopy || disabled) {
return
}
try {
await Clipboard.setStringAsync(body)
if (!mountedRef.current) {
return
}
setFeedback({ body, status: 'copied' })
} catch {
if (!mountedRef.current) {
return
}
setFeedback({ body, status: 'failed' })
}
if (copyResetTimerRef.current) {
clearTimeout(copyResetTimerRef.current)
}
copyResetTimerRef.current = setTimeout(() => {
copyResetTimerRef.current = null
if (mountedRef.current) {
setFeedback(null)
}
}, 1500)
}
const copyDisabled = disabled || !canCopy
const copyLabel =
copyStatus === 'copied'
? 'Copied'
: copyStatus === 'failed'
? "Couldn't copy"
: canCopy
? `Copy ${command.label}`
: 'Nothing to copy'
const copyIconColor =
copyStatus === 'copied'
? colors.statusGreen
: copyStatus === 'failed'
? colors.statusRed
: colors.textSecondary
return (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.rowAction,
// Why: row already dims when `disabled`; only dim again for empty body.
!canCopy && styles.disabled,
pressed && !copyDisabled && styles.pressed
]}
disabled={copyDisabled}
onPress={() => void handleCopy()}
accessibilityRole="button"
accessibilityLabel={copyLabel}
accessibilityState={{ disabled: copyDisabled }}
>
{copyStatus === 'copied' ? (
<Check size={15} color={copyIconColor} />
) : (
<Copy size={15} color={copyIconColor} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
pressed: { backgroundColor: colors.bgRaised },
disabled: { opacity: 0.45 },
row: { flexDirection: 'row', alignItems: 'center' },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
rowMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingLeft: spacing.md,
minWidth: 0
},
rowIcon: {
width: 26,
height: 26,
borderRadius: 6,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
rowText: { flex: 1, minWidth: 0 },
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
mono: { fontFamily: typography.monoFamily },
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }
})
@@ -17,6 +17,7 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({
Check: 'Check',
Copy: 'Copy',
Pencil: 'Pencil',
Play: 'Play',
Plus: 'Plus',
@@ -24,6 +25,8 @@ vi.mock('lucide-react-native', () => ({
Trash2: 'Trash2'
}))
vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
vi.mock('../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
describe('QuickCommandsList search', () => {
+4 -82
View File
@@ -1,14 +1,11 @@
import { View, Text, Pressable, TextInput, StyleSheet, ActivityIndicator } from 'react-native'
import { Pencil, Plus, Search, Trash2, Check, Play } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { Check, Plus, Search } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileAgentIcon } from '../components/MobileAgentIcon'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import type { TerminalQuickCommand, TuiAgent } from '../../../src/shared/types'
import {
getQuickCommandDisplayPreview,
isAgentQuickCommand,
supportsTerminalAgentQuickCommand
} from '../terminal/quick-commands'
import { supportsTerminalAgentQuickCommand } from '../terminal/quick-commands'
import { QuickCommandRow } from './QuickCommandRow'
export const QUICK_COMMAND_SUPPORTED_AGENTS = MOBILE_AGENT_CATALOG.filter((entry) =>
supportsTerminalAgentQuickCommand(entry.id)
@@ -162,67 +159,6 @@ function QuickCommandGroup({
)
}
function QuickCommandRow({
command,
first,
onLaunch,
onEdit,
onDelete,
disabled
}: {
command: TerminalQuickCommand
first: boolean
onLaunch: (command: TerminalQuickCommand) => void
onEdit: (command: TerminalQuickCommand) => void
onDelete: (command: TerminalQuickCommand) => void
disabled: boolean
}) {
const isAgent = isAgentQuickCommand(command)
return (
<View style={[styles.row, !first && styles.rowBorder, disabled && styles.disabled]}>
<Pressable
style={({ pressed }) => [styles.rowMain, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onLaunch(command)}
accessibilityRole="button"
accessibilityLabel={`Run ${command.label}`}
>
<View style={styles.rowIcon}>
{isAgent ? (
<MobileAgentIcon agentId={command.agent} size={16} />
) : (
<Play size={14} color={colors.textPrimary} fill={colors.textPrimary} />
)}
</View>
<View style={styles.rowText}>
<Text style={styles.rowLabel} numberOfLines={1}>
{command.label}
</Text>
<Text style={[styles.rowPreview, !isAgent && styles.mono]} numberOfLines={1}>
{getQuickCommandDisplayPreview(command)}
</Text>
</View>
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onEdit(command)}
accessibilityLabel={`Edit ${command.label}`}
>
<Pencil size={15} color={colors.textSecondary} />
</Pressable>
<Pressable
style={({ pressed }) => [styles.rowAction, pressed && !disabled && styles.pressed]}
disabled={disabled}
onPress={() => onDelete(command)}
accessibilityLabel={`Delete ${command.label}`}
>
<Trash2 size={15} color={colors.statusRed} />
</Pressable>
</View>
)
}
export function QuickCommandAgentPicker({
selected,
onSelect
@@ -292,15 +228,6 @@ const styles = StyleSheet.create({
group: { backgroundColor: colors.bgPanel, borderRadius: 12, overflow: 'hidden' },
row: { flexDirection: 'row', alignItems: 'center' },
rowBorder: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.borderSubtle },
rowMain: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingVertical: spacing.md,
paddingLeft: spacing.md,
minWidth: 0
},
rowIcon: {
width: 26,
height: 26,
@@ -309,11 +236,6 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center'
},
rowText: { flex: 1, minWidth: 0 },
rowLabel: { fontSize: 14, fontWeight: '600', color: colors.textPrimary },
rowPreview: { fontSize: 12, color: colors.textSecondary, marginTop: 1 },
mono: { fontFamily: typography.monoFamily },
rowAction: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' },
agentLabel: { flex: 1, fontSize: 14, color: colors.textPrimary },
addRow: {
flexDirection: 'row',
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { resolveMarkdownFloatingActionsBottom } from './markdown-floating-actions-layout'
import {
resolveMarkdownFloatingActionsBottom,
shouldShowMarkdownFloatingActions
} from './markdown-floating-actions-layout'
describe('resolveMarkdownFloatingActionsBottom', () => {
it('keeps markdown actions at their resting bottom when the keyboard is closed', () => {
@@ -22,3 +25,28 @@ describe('resolveMarkdownFloatingActionsBottom', () => {
).toBe(303)
})
})
describe('shouldShowMarkdownFloatingActions', () => {
const idle = {
keyboardLift: 0,
hasStatus: false,
showRefresh: false,
showCopy: false,
showSave: false
}
it('shows the floating row for keyboard dismissal without document actions', () => {
expect(shouldShowMarkdownFloatingActions({ ...idle, keyboardLift: 291 })).toBe(true)
})
it('hides the floating row on a clean document with the keyboard closed', () => {
expect(shouldShowMarkdownFloatingActions(idle)).toBe(false)
})
it.each(['hasStatus', 'showRefresh', 'showCopy', 'showSave'] as const)(
'shows the floating row for %s alone',
(field) => {
expect(shouldShowMarkdownFloatingActions({ ...idle, [field]: true })).toBe(true)
}
)
})
@@ -9,3 +9,19 @@ export function resolveMarkdownFloatingActionsBottom({
}): number {
return keyboardLift > 0 ? keyboardLift + liftedClearance : restingBottom
}
export function shouldShowMarkdownFloatingActions({
keyboardLift,
hasStatus,
showRefresh,
showCopy,
showSave
}: {
keyboardLift: number
hasStatus: boolean
showRefresh: boolean
showCopy: boolean
showSave: boolean
}): boolean {
return keyboardLift > 0 || hasStatus || showRefresh || showCopy || showSave
}
@@ -100,7 +100,7 @@ export function nextReviewIndexAfterMarkReviewed({
const wrappedIndex = filteredQueue.findIndex(
(item) => item.key !== currentItemKey && !item.isReviewed
)
const targetIndex = nextIndex >= 0 ? nextIndex : wrappedIndex >= 0 ? wrappedIndex : null
const targetIndex = nextIndex !== -1 ? nextIndex : wrappedIndex !== -1 ? wrappedIndex : null
if (targetIndex === null) {
return null
}
@@ -87,7 +87,7 @@ export function mergeLandedImagePreviewEchoes(
const entries = Object.entries(previous[sessionKey] ?? {})
for (const preview of landed) {
const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId)
if (existingIndex >= 0) {
if (existingIndex !== -1) {
entries.splice(existingIndex, 1)
}
entries.push([preview.messageId, preview.images])

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