Merge remote-tracking branch 'origin/main' into brennanb2025/agent-terminal-popover

This commit is contained in:
brennanb2025
2026-05-31 12:41:46 -07:00
1589 changed files with 102802 additions and 19075 deletions
+4 -3
View File
@@ -88,7 +88,7 @@ The workflow resolves the next version from GitHub Releases, bumps `package.json
**How the next version is chosen:**
All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable* release, ignoring any RCs in between.
All stable kinds (`patch`, `minor`, `major`) are computed off the latest _stable_ release, ignoring any RCs in between.
- `kind=rc` + last tag was stable (e.g. `v1.3.14`) → `v1.3.15-rc.0`.
- `kind=rc` + active RC series (e.g. `v1.3.15-rc.2`) → `v1.3.15-rc.3`.
@@ -99,8 +99,9 @@ All stable kinds (`patch`, `minor`, `major`) are computed off the latest *stable
**Safety guarantees:**
- Stable releases are refused if the new version isn't strictly greater than the latest published stable. This is the only rule `electron-updater` actually needs — it compares semver within the `latest` channel, so a regressing stable is the one thing that breaks auto-update for fresh installs.
- Complete RC draft releases created by the release workflow are published before cutting a new tag, so a GitHub queue failure in the final publish job can be resumed safely.
- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag before cutting the next RC. This keeps retries from leaving multiple unpublished draft releases behind.
- Complete RC draft releases created by the release workflow are published before cutting a new tag only when the draft tag was built from the current release ref. Stale drafts are skipped so fixes cut a fresh RC instead of exposing old artifacts.
- If the latest RC tag exists but is still draft-only or missing its GitHub Release, the workflow resumes that tag only when it was built from the current release ref. Otherwise the next RC number is cut.
- RC numbering also considers release commits on `main`, so deleting a stale tag does not let a later cut reuse the same RC number.
- Off-main releases (when `ref` is not the tip of `main`) only push the tag. `main` is never mutated from a non-main ref, so you can safely release an older commit without polluting history.
- When `ref` is the tip of `main`, the version-bump commit is fast-forwarded onto `main` so local `package.json` stays in sync with what's shipped.
+1 -1
View File
@@ -1,6 +1,6 @@
name: Bug report
description: Report a problem with Orca
title: "[Bug]: "
title: '[Bug]: '
labels:
- bug
body:
+1 -1
View File
@@ -1,6 +1,6 @@
name: Feature request
description: Suggest an improvement for Orca
title: "[Feature]: "
title: '[Feature]: '
labels:
- enhancement
body:
+1 -1
View File
@@ -1,6 +1,6 @@
name: Other
description: Report another Orca issue type
title: "[Other]: "
title: '[Other]: '
body:
- type: textarea
id: details
+8 -2
View File
@@ -94,5 +94,11 @@ jobs:
- name: Test
run: pnpm test
- name: Build
run: pnpm build
- name: Build unpacked app
run: pnpm build:unpack
# Why: the packaged CLI runs outside Electron's asar integration, so
# bare runtime imports must be present in the package itself. Run from a
# temp copy so Node cannot mask missing package deps with repo node_modules.
- name: Smoke packaged CLI
run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked
+28 -10
View File
@@ -252,11 +252,24 @@ jobs:
}
highest_rc_for_base() {
local base="$1"
git tag --list "v${base}-rc.*" \
| sed -E "s/^v${base//./\\.}-rc\.//" \
| sort -n \
| tail -n 1 || true
node config/scripts/release-rc-history.mjs "$1"
}
tag_matches_current_ref() {
local tag="$1"
local tag_commit
local head_commit
if ! tag_commit="$(git rev-parse "${tag}^{}" 2>/dev/null)"; then
return 1
fi
head_commit="$(git rev-parse HEAD)"
if [[ "$tag_commit" == "$head_commit" ]]; then
return 0
fi
local tag_parent
tag_parent="$(git rev-parse "${tag_commit}^" 2>/dev/null)" || return 1
[[ "$tag_parent" == "$head_commit" ]]
}
release_draft_state() {
@@ -280,6 +293,10 @@ jobs:
release_state="$(release_draft_state "$tag")"
case "$release_state" in
missing|true)
if ! tag_matches_current_ref "$tag"; then
echo "::warning::Tag $tag already exists but was cut from a different release ref ($reason) - cutting the next version instead of reusing stale artifacts."
return 1
fi
echo "::warning::Tag $tag already exists but has no published release ($reason) - recovering by re-dispatching the release build against the existing tag."
echo "recovered_tag=$tag" >>"$GITHUB_OUTPUT"
echo "recovered=true" >>"$GITHUB_OUTPUT"
@@ -317,10 +334,11 @@ jobs:
else
existing_rc_tag="v${base}-rc.${highest_rc}"
# Why: a failed or GitHub-stuck run can leave the highest RC
# tag attached to a draft/missing release. Resume that tag so
# retries finish the intended release instead of piling up
# vX.Y.Z-rc.N+1 drafts.
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
# tag attached to a draft/missing release. Resume only when it
# was cut from this ref; stale attempts advance to rc.N+1.
if git rev-parse "$existing_rc_tag" >/dev/null 2>&1; then
recover_unpublished_tag "$existing_rc_tag" "latest RC in series" || true
fi
new="${base}-rc.$((highest_rc + 1))"
fi
;;
@@ -363,7 +381,7 @@ jobs:
# over a shipped version) and needs human attention.
if git rev-parse "v$new" >/dev/null 2>&1; then
recover_unpublished_tag "v$new" "tag collision" || {
echo "::error::Tag v$new already exists and has a published release. Refusing to re-cut over a shipped version." >&2
echo "::error::Tag v$new already exists and cannot be recovered for this ref. Refusing to re-cut over an existing version." >&2
exit 1
}
fi
+5 -2
View File
@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schemas/oxlint/v0.17.0/config.json",
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "react", "react-hooks", "react-perf", "unicorn"],
"categories": {
"correctness": "error"
@@ -39,7 +39,10 @@
"typescript/prefer-function-type": "error",
"typescript/prefer-includes": "error",
"typescript/prefer-optional-chain": "error",
"typescript/switch-exhaustiveness-check": "error",
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
],
"curly": "error",
"no-unneeded-ternary": "error",
"no-useless-return": "error",
+1 -1
View File
@@ -12,7 +12,7 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
## File and Module Naming
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero information and tend to become dumping grounds. Name files after what they *actually* contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
Never use vague names like `helpers`, `utils`, `common`, `misc`, or `shared-stuff` for files, folders, or modules. They carry zero information and tend to become dumping grounds. Name files after what they _actually_ contain — prefer the concrete domain concept (e.g. `tab-group-state.ts`, `terminal-orphan-cleanup.ts`) over the generic role (`tabs-helpers.ts`, `terminal-utils.ts`). If you find yourself reaching for `helpers`, the file probably has more than one responsibility and should be split, or there's a better name hiding in the code that describes what the functions operate on.
## Worktree Safety
+6 -5
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>The AI Orchestrator for 100x builders.</strong><br/>
Run Claude Code, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Run Claude Code, OpenClaude, Codex, Grok, Antigravity, or OpenCode side-by-side across repos — each in its own worktree, tracked in one place.<br/>
Available for <strong>macOS, Windows, and Linux</strong>.
</p>
@@ -26,10 +26,11 @@
## Supported Agents
Orca supports any CLI agent (*not just this list*).
Orca supports any CLI agent (_not just this list_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="docs/assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -62,7 +63,7 @@ Orca supports any CLI agent (*not just this list*).
## Features
- **No login required** — Bring your own Claude Code, Codex, Grok, or Antigravity subscription.
- **No login required** — Bring your own Claude Code, OpenClaude, Codex, Grok, or Antigravity subscription.
- **Worktree-native** — Every feature gets its own worktree. No stashing, no branch juggling. Spin up and switch instantly.
- **Multi-agent terminals** — Run multiple AI agents side-by-side in tabs and panes. See which ones are active at a glance.
- **Built-in source control** — Review AI-generated diffs, make quick edits, and commit without leaving Orca.
@@ -79,7 +80,7 @@ Orca supports any CLI agent (*not just this list*).
- **[Download from onOrca.dev](https://onOrca.dev)**
- Or via **[GitHub Releases page](https://github.com/stablyai/orca/releases/latest)**
*Alternatively, install from a package manager:*
_Alternatively, install from a package manager:_
### macOS (Homebrew)
@@ -108,7 +109,7 @@ Control your agents from your phone.
</p>
- **iOS:** [Download from App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download from GH release](https://github.com/stablyai/orca/releases/tag/mobile-v0.0.10)
- **Android:** [Download APK from GitHub Releases](https://github.com/stablyai/orca/releases/download/mobile-v0.0.10/app-release.apk)
---
+64 -6
View File
@@ -1,6 +1,12 @@
const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
verifyPackagedMainRuntimeDeps
} = require('./packaged-runtime-node-modules.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const featureWallResources = {
@@ -14,6 +20,25 @@ const relayExtraResource = {
from: 'out/relay',
to: 'relay'
}
// Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute
// from package directories where pnpm's symlink farm is absent. Copy the exact
// runtime dependency closure to Resources/node_modules so bare require() calls
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
}
const linuxSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-linux-${arch}',
to: 'node_modules/sherpa-onnx-linux-${arch}'
}
const winSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-win-x64',
to: 'node_modules/sherpa-onnx-win-x64'
}
/** @type {import('electron-builder').Configuration} */
module.exports = {
@@ -24,12 +49,22 @@ module.exports = {
},
files: [
'!**/.vscode/*',
'!src/*',
// Why: these repo-only inputs are either bundled into out/ or copied via
// extraResources. Shipping them in app.asar bloats the desktop bundle.
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
'!electron.vite.config.{js,ts,mjs,cjs}',
'!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}',
'!{.env,.env.*,.npmrc,pnpm-lock.yaml}',
'!tsconfig.json',
'!config/*',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
@@ -88,6 +123,9 @@ module.exports = {
if (!existsSync(resourcesDir)) {
return
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName)
verifyPackagedMainRuntimeDeps(resourcesDir)
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
if (!filename.startsWith('agent-browser-')) {
continue
@@ -104,7 +142,8 @@ module.exports = {
win: {
executableName: 'Orca',
extraResources: [
relayExtraResource,
...commonExtraResources,
winSpeechNativeResource,
{
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
@@ -158,7 +197,8 @@ module.exports = {
hardenedRuntime: isMacRelease,
notarize: isMacRelease,
extraResources: [
relayExtraResource,
...commonExtraResources,
macSpeechNativeResource,
{
from: 'resources/darwin/bin/orca',
to: 'bin/orca'
@@ -198,7 +238,8 @@ module.exports = {
// sizes; a single 1024px PNG is ignored by some Linux docks/launchers.
icon: 'resources/build/icon.icns',
extraResources: [
relayExtraResource,
...commonExtraResources,
linuxSpeechNativeResource,
{
from: 'resources/linux/bin/orca-ide',
to: 'bin/orca-ide'
@@ -230,11 +271,13 @@ module.exports = {
artifactName: 'orca-ide-${version}.${arch}.${ext}',
depends: ['python3', 'python3-gobject', 'at-spi2-core', 'xdotool', 'xclip']
},
beforeBuild: electronBuilderNativeRebuild,
// Why: must be true so that electron-builder rebuilds native modules
// (node-pty) for each target architecture when producing dual-arch macOS
// builds (x64 + arm64). With npmRebuild disabled, CI on an arm64 runner
// packages arm64 binaries into the x64 DMG, causing "posix_spawnp failed"
// on Intel Macs.
// on Intel Macs. The beforeBuild hook performs Orca's targeted rebuild and
// returns false so electron-builder does not rebuild optional cpu-features.
npmRebuild: true,
publish: {
provider: 'github',
@@ -244,6 +287,21 @@ module.exports = {
}
}
function chmodUnixCliLaunchers(resourcesDir, electronPlatformName) {
if (electronPlatformName === 'win32') {
return
}
for (const launcherName of ['orca', 'orca-ide']) {
const launcherPath = join(resourcesDir, 'bin', launcherName)
if (!existsSync(launcherPath)) {
continue
}
// Why: packaged Unix installs expose these extraResources as public shell
// commands, and source/packager mode drift must not ship a non-executable CLI.
chmodSync(launcherPath, 0o755)
}
}
async function signMacComputerUseHelper(helperAppPath, packager) {
if (!existsSync(helperAppPath)) {
if (isMacRelease) {
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "../node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript"],
"categories": {
"correctness": "off",
"suspicious": "off",
"pedantic": "off",
"perf": "off",
"style": "off",
"restriction": "off",
"nursery": "off"
},
"rules": {
"typescript/switch-exhaustiveness-check": [
"error",
{ "allowDefaultCaseForExhaustiveSwitch": false }
]
}
}
+256
View File
@@ -0,0 +1,256 @@
const { existsSync, readFileSync, readdirSync, realpathSync, rmSync } = require('node:fs')
const { dirname, join, resolve } = require('node:path')
const { builtinModules, createRequire } = require('node:module')
const projectDir = resolve(__dirname, '..')
const requireFromProject = createRequire(join(projectDir, 'package.json'))
const PACKAGED_RUNTIME_PACKAGE_ROOTS = [
'@electron-toolkit/utils',
'@linear/sdk',
'electron-updater',
'node-pty',
'posthog-node',
'qrcode',
'ssh2',
'tweetnacl',
'ws',
'yaml',
'zod'
]
const NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM = {
darwin: 'darwin-',
linux: 'linux-',
win32: 'win32-'
}
const TYPE_DECLARATION_ARTIFACT_RE = /\.d\.(?:c|m)?ts(?:\.map)?$/
const VERSIONED_ONNXRUNTIME_DYLIB_RE = /^libonnxruntime\.\d[\d.]*\.dylib$/
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map((moduleName) => `node:${moduleName}`)
])
function packageNameFromSpecifier(specifier) {
if (specifier.startsWith('@')) {
const [scope, name] = specifier.split('/')
return scope && name ? `${scope}/${name}` : specifier
}
return specifier.split('/')[0]
}
function isPackagedExternalSpecifier(specifier) {
return (
!specifier.startsWith('.') &&
!specifier.startsWith('/') &&
specifier !== 'electron' &&
!NODE_BUILTINS.has(specifier)
)
}
function resolvePackageJsonPath(packageName, fromDir = projectDir) {
try {
return requireFromProject.resolve(`${packageName}/package.json`, { paths: [fromDir] })
} catch {
const entryPath = requireFromProject.resolve(packageName, { paths: [fromDir] })
let dir = dirname(entryPath)
while (dir !== dirname(dir)) {
const packageJsonPath = join(dir, 'package.json')
if (existsSync(packageJsonPath)) {
return packageJsonPath
}
dir = dirname(dir)
}
throw new Error(`Could not find package.json for ${packageName}`)
}
}
function readPackage(packageName, fromDir = projectDir) {
const packageJsonPath = resolvePackageJsonPath(packageName, fromDir)
const packageDir = realpathSync(dirname(packageJsonPath))
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
return {
name: packageJson.name ?? packageName,
packageDir,
dependencies: Object.keys(packageJson.dependencies ?? {})
}
}
function collectPackagedRuntimePackages() {
const packages = new Map()
const visit = (packageName, fromDir = projectDir) => {
if (packageName === 'electron' || packages.has(packageName)) {
return
}
const packageInfo = readPackage(packageName, fromDir)
if (packages.has(packageInfo.name)) {
return
}
packages.set(packageInfo.name, packageInfo.packageDir)
for (const dependencyName of packageInfo.dependencies) {
visit(dependencyName, packageInfo.packageDir)
}
}
for (const packageName of PACKAGED_RUNTIME_PACKAGE_ROOTS) {
visit(packageName)
}
return [...packages.entries()].sort(([left], [right]) => left.localeCompare(right))
}
function createPackagedRuntimeNodeModuleResources() {
return collectPackagedRuntimePackages().map(([packageName, packageDir]) => ({
from: packageDir,
to: join('node_modules', ...packageName.split('/'))
}))
}
function normalizeAsarEntryPath(entry) {
return entry.replace(/\\/g, '/').replace(/^\/+/, '')
}
function findAsarEntry(entries, expectedPath) {
return entries.find((entry) => normalizeAsarEntryPath(entry) === expectedPath)
}
function verifyPackagedMainRuntimeDeps(resourcesDir, asar = require('@electron/asar')) {
const asarPath = join(resourcesDir, 'app.asar')
if (!existsSync(asarPath)) {
return
}
const mainFiles = ['out/main/index.js', 'out/main/agent-hooks/managed-agent-hook-controls.js']
const entries = asar.listPackage(asarPath)
const missing = new Set()
for (const file of mainFiles) {
const entry = findAsarEntry(entries, file)
if (!entry) {
throw new Error(`Packaged main file ${file} was not found in ${asarPath}`)
}
// Why: @electron/asar lists entries with host separators; Windows returns
// backslashes, and extractFile expects that same host-style path.
const internalPath = entry.replace(/^[\\/]+/, '')
const source = asar.extractFile(asarPath, internalPath).toString('utf8')
for (const match of source.matchAll(/require\(["']([^"']+)["']\)/g)) {
const specifier = match[1]
if (!isPackagedExternalSpecifier(specifier)) {
continue
}
const packageName = packageNameFromSpecifier(specifier)
if (!existsSync(join(resourcesDir, 'node_modules', ...packageName.split('/')))) {
missing.add(packageName)
}
}
}
if (missing.size > 0) {
throw new Error(
`Packaged main bundle has bare runtime imports without copied node_modules: ${[
...missing
].join(', ')}`
)
}
}
function prunePackagedNodePty(resourcesDir, electronPlatformName) {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
if (!existsSync(nodePtyDir)) {
return
}
const allowedPrebuildPrefix = NODE_PTY_PREBUILD_PREFIX_BY_PLATFORM[electronPlatformName]
if (allowedPrebuildPrefix) {
const prebuildsDir = join(nodePtyDir, 'prebuilds')
if (existsSync(prebuildsDir)) {
for (const entry of readdirSync(prebuildsDir, { withFileTypes: true })) {
if (entry.isDirectory() && !entry.name.startsWith(allowedPrebuildPrefix)) {
rmSync(join(prebuildsDir, entry.name), { recursive: true, force: true })
}
}
}
}
if (electronPlatformName !== 'win32') {
// Why: conpty is Windows-only and node-pty resolves runtime binaries from
// build/Release or prebuilds/<platform>-<arch>, not third_party/conpty.
rmSync(join(nodePtyDir, 'third_party', 'conpty'), { recursive: true, force: true })
rmSync(join(nodePtyDir, 'deps', 'winpty'), { recursive: true, force: true })
}
}
function prunePackagedRuntimeTypeDeclarations(resourcesDir) {
const nodeModulesDir = join(resourcesDir, 'node_modules')
if (!existsSync(nodeModulesDir)) {
return
}
pruneMatchingFiles(nodeModulesDir, (filename) => TYPE_DECLARATION_ARTIFACT_RE.test(filename))
}
function prunePackagedSherpaOnnx(resourcesDir, electronPlatformName) {
if (electronPlatformName !== 'darwin') {
return
}
const nodeModulesDir = join(resourcesDir, 'node_modules')
if (!existsSync(nodeModulesDir)) {
return
}
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('sherpa-onnx-darwin-')) {
continue
}
const packageDir = join(nodeModulesDir, entry.name)
const packageEntries = readdirSync(packageDir)
const hasVersionedOnnxRuntime = packageEntries.some((filename) =>
VERSIONED_ONNXRUNTIME_DYLIB_RE.test(filename)
)
if (hasVersionedOnnxRuntime) {
// Why: darwin sherpa-onnx binaries link to the versioned ONNX Runtime
// install name; the unversioned dylib is a duplicate fallback copy.
rmSync(join(packageDir, 'libonnxruntime.dylib'), { force: true })
}
}
}
function prunePackagedZodSources(resourcesDir) {
// Why: Zod's src tree is TypeScript source only selected by the @zod/source
// condition; packaged runtime import/require paths resolve to built JS.
rmSync(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true, force: true })
}
function prunePackagedRuntimeNodeModules(resourcesDir, electronPlatformName) {
prunePackagedNodePty(resourcesDir, electronPlatformName)
prunePackagedRuntimeTypeDeclarations(resourcesDir)
prunePackagedSherpaOnnx(resourcesDir, electronPlatformName)
prunePackagedZodSources(resourcesDir)
}
function pruneMatchingFiles(directory, shouldPrune) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
pruneMatchingFiles(entryPath, shouldPrune)
} else if (entry.isFile() && shouldPrune(entry.name)) {
rmSync(entryPath, { force: true })
}
}
}
module.exports = {
PACKAGED_RUNTIME_PACKAGE_ROOTS,
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
isPackagedExternalSpecifier,
packageNameFromSpecifier,
prunePackagedNodePty,
prunePackagedRuntimeNodeModules,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeDeclarations,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
if (process.platform !== 'darwin') {
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
process.exit(0)
}
runPnpmScript('build:computer-macos')
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
const command = npmExecPath
? process.execPath
: process.platform === 'win32'
? 'pnpm.cmd'
: 'pnpm'
const args = npmExecPath ? [npmExecPath, 'run', scriptName] : ['run', scriptName]
const result = spawnSync(command, args, { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
}
+1 -1
View File
@@ -36,7 +36,7 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher', 'better-sqlite3'],
external: ['node-pty', '@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
@@ -129,6 +129,22 @@ describe('check-styled-scrollbars', () => {
expect(reports).toHaveLength(0)
})
it('reports inline vertical overflow when the scrollbar class is conditional or short-circuited', () => {
for (const classNameExpression of [
"enabled && 'scrollbar-sleek'",
"enabled ? 'scrollbar-sleek' : undefined",
"enabled || 'scrollbar-sleek'",
"enabled ?? 'scrollbar-sleek'"
]) {
const reports = reportUnstyledScrollbars(
'Example.tsx',
`export function Example({ enabled }) { return <div className={${classNameExpression}} style={{ overflowY: 'auto' }} /> }`
)
expect(reports, classNameExpression).toHaveLength(1)
}
})
it('reports logical inline style spreads without an Orca scrollbar class', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
@@ -156,6 +172,15 @@ describe('check-styled-scrollbars', () => {
expect(reports).toHaveLength(0)
})
it('uses later spread className props over earlier explicit className props', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
"export function Example() { return <div className=\"scrollbar-sleek\" {...{ className: 'overflow-y-auto' }} /> }"
)
expect(reports).toHaveLength(1)
})
it('supports variant helper className config', () => {
const reports = reportUnstyledScrollbars(
'Example.tsx',
+39 -23
View File
@@ -11,7 +11,11 @@ const args = new Set(process.argv.slice(2))
const requestedApps = valueFlag('--apps')
const includeScreenshot = args.has('--screenshot')
const session = valueFlag('--session') ?? `computer-smoke-${process.pid}`
const preferredApps = (requestedApps ?? process.env.ORCA_COMPUTER_SMOKE_APPS ?? 'Finder,TextEdit,Spotify,Slack,Microsoft Edge')
const preferredApps = (
requestedApps ??
process.env.ORCA_COMPUTER_SMOKE_APPS ??
'Finder,TextEdit,Spotify,Slack,Microsoft Edge'
)
.split(',')
.map((app) => app.trim())
.filter(Boolean)
@@ -23,8 +27,12 @@ if (!existsSync(cliPath)) {
const list = unwrapResult(runCli(['computer', 'list-apps', '--json']))
const apps = Array.isArray(list.apps) ? list.apps : []
const availableNames = new Set(apps.map((app) => String(app.name ?? '').toLowerCase()))
const availableBundles = new Set(apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean))
const targets = preferredApps.filter((app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase()))
const availableBundles = new Set(
apps.map((app) => String(app.bundleId ?? '').toLowerCase()).filter(Boolean)
)
const targets = preferredApps.filter(
(app) => availableNames.has(app.toLowerCase()) || availableBundles.has(app.toLowerCase())
)
console.log(`computer-use smoke: ${apps.length} apps listed`)
if (targets.length === 0) {
@@ -34,16 +42,19 @@ if (targets.length === 0) {
let failures = 0
for (const app of targets) {
const result = runCli([
'computer',
'get-app-state',
'--session',
session,
'--app',
app,
...(includeScreenshot ? [] : ['--no-screenshot']),
'--json'
], { allowFailure: true })
const result = runCli(
[
'computer',
'get-app-state',
'--session',
session,
'--app',
app,
...(includeScreenshot ? [] : ['--no-screenshot']),
'--json'
],
{ allowFailure: true }
)
if (!result.ok) {
failures += 1
@@ -57,15 +68,19 @@ for (const app of targets) {
const lineCount = treeText.split('\n').filter(Boolean).length
const secondaryActions = (treeText.match(/Secondary Actions:/g) ?? []).length
const settable = (treeText.match(/\bsettable\b/g) ?? []).length
const screenshotState = state.screenshot ? `${state.screenshot.width}x${state.screenshot.height}` : 'missing'
console.log([
`computer-use smoke: ${snapshot.app.name}`,
`${snapshot.elementCount} elements`,
`${lineCount} lines`,
`${secondaryActions} secondary-action lines`,
`${settable} settable elements`,
`screenshot=${screenshotState}`
].join(' | '))
const screenshotState = state.screenshot
? `${state.screenshot.width}x${state.screenshot.height}`
: 'missing'
console.log(
[
`computer-use smoke: ${snapshot.app.name}`,
`${snapshot.elementCount} elements`,
`${lineCount} lines`,
`${secondaryActions} secondary-action lines`,
`${settable} settable elements`,
`screenshot=${screenshotState}`
].join(' | ')
)
}
if (failures > 0) {
@@ -86,7 +101,8 @@ function runCli(cliArgs, options = {}) {
encoding: 'utf8',
env: {
...process.env,
ORCA_USER_DATA_PATH: process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
ORCA_USER_DATA_PATH:
process.env.ORCA_COMPUTER_SMOKE_USER_DATA_PATH ?? defaultDevUserDataPath()
}
})
if (child.status !== 0) {
@@ -1,10 +1,66 @@
import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
findAsarEntry,
prunePackagedNodePty,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeDeclarations,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!tests{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
])
)
})
it('keeps runtime resources available through extraResources', () => {
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
to: 'Orca Computer Use.app'
})
])
)
expect(electronBuilderConfig.linux.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-linux/runtime.py',
to: 'computer-use-linux/runtime.py'
})
])
)
expect(electronBuilderConfig.win.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
})
])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -18,4 +74,147 @@ describe('electron-builder config', () => {
artifactName: 'orca-ide-${version}.${arch}.${ext}'
})
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
})
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
)
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
recursive: true
})
prunePackagedNodePty(resourcesDir, 'darwin')
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
'darwin-arm64',
'darwin-x64'
])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
await mkdir(join(packageDir, 'dist'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
prunePackagedRuntimeTypeDeclarations(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
await mkdir(packageDir, { recursive: true })
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
'libonnxruntime.1.23.2.dylib',
'sherpa-onnx.node'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'zod')
await mkdir(join(packageDir, 'src'), { recursive: true })
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
prunePackagedZodSources(resourcesDir)
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})
@@ -0,0 +1,52 @@
const { execFileSync } = require('node:child_process')
const { resolve } = require('node:path')
const projectDir = resolve(__dirname, '../..')
function electronBuilderNativeRebuild(context) {
return runElectronBuilderNativeRebuild(context)
}
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
runner(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
})
// Why: returning false tells electron-builder that native deps were handled
// externally, avoiding its all-module rebuild of optional cpu-features.
return false
}
function buildNativeRebuildArgs(context) {
const platform = readPlatformName(context?.platform)
const arch = readArchName(context?.arch)
return [
'config/scripts/rebuild-native-deps.mjs',
`--platform=${platform}`,
`--arch=${arch}`,
'--force'
]
}
function readPlatformName(platform) {
const name = typeof platform === 'string' ? platform : platform?.nodeName
if (!name) {
throw new Error('electron-builder native rebuild context is missing platform.nodeName')
}
return name
}
function readArchName(arch) {
if (!arch || typeof arch !== 'string') {
throw new Error('electron-builder native rebuild context is missing arch')
}
return arch
}
module.exports = electronBuilderNativeRebuild
module.exports.default = electronBuilderNativeRebuild
module.exports.buildNativeRebuildArgs = buildNativeRebuildArgs
module.exports.runElectronBuilderNativeRebuild = runElectronBuilderNativeRebuild
@@ -0,0 +1,49 @@
import { createRequire } from 'node:module'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
buildNativeRebuildArgs,
runElectronBuilderNativeRebuild
} = require('./electron-builder-native-rebuild.cjs')
describe('electron-builder native rebuild hook', () => {
it('passes the target platform and arch to Orca native rebuild script', () => {
expect(
buildNativeRebuildArgs({
platform: { nodeName: 'darwin' },
arch: 'x64'
})
).toEqual([
'config/scripts/rebuild-native-deps.mjs',
'--platform=darwin',
'--arch=x64',
'--force'
])
})
it('returns false so electron-builder skips its optional module rebuild pass', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
{
platform: { nodeName: 'linux' },
arch: 'arm64'
},
(...args) => calls.push(args)
)
expect(result).toBe(false)
expect(calls).toEqual([
[
process.execPath,
['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=arm64', '--force'],
expect.objectContaining({ stdio: 'inherit' })
]
])
})
it('rejects incomplete electron-builder contexts', () => {
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
})
})
+63 -16
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, readFileSync } from 'node:fs'
import { release } from 'node:os'
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -11,7 +12,7 @@ const scriptPath = fileURLToPath(import.meta.url)
const projectDir = resolve(dirname(scriptPath), '../..')
const runtime = readRuntimeArg()
const NATIVE_MODULES = ['better-sqlite3', 'node-pty']
const NATIVE_MODULES = ['node-pty']
const CHILD_CHECK_FLAG = '--check-only'
if (process.argv.includes(CHILD_CHECK_FLAG)) {
@@ -106,14 +107,12 @@ function runNodeCheck() {
}
function runElectronCheck() {
let electronExecutable
try {
electronExecutable = require('electron')
} catch (error) {
return { ok: false, error }
const electronExecutable = resolveInstalledElectronExecutable()
if (!electronExecutable.ok) {
return { ok: false, error: electronExecutable.error }
}
const result = spawnSync(electronExecutable, [scriptPath, CHILD_CHECK_FLAG], {
const result = spawnSync(electronExecutable.path, [scriptPath, CHILD_CHECK_FLAG], {
cwd: projectDir,
env: {
...process.env,
@@ -126,6 +125,63 @@ function runElectronCheck() {
return parseChildCheckResult(result)
}
function resolveInstalledElectronExecutable() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
try {
const electronVersion = JSON.parse(
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
).version
const platformPath = getElectronPlatformPath()
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
.trim()
.replace(/^v/, '')
if (installedVersion !== electronVersion) {
return {
ok: false,
error: new Error(
`Electron package binary version ${installedVersion} does not match ${electronVersion}.`
)
}
}
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
if (installedPlatformPath !== platformPath) {
return {
ok: false,
error: new Error(
`Electron package path.txt points at ${installedPlatformPath}, expected ${platformPath}.`
)
}
}
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronPath)) {
return { ok: false, error: new Error(`Electron executable is missing at ${electronPath}.`) }
}
return { ok: true, path: electronPath }
} catch (error) {
return { ok: false, error }
}
}
function getElectronPlatformPath() {
const targetPlatform =
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || process.platform
switch (targetPlatform) {
case 'mas':
case 'darwin':
return 'Electron.app/Contents/MacOS/Electron'
case 'freebsd':
case 'openbsd':
case 'linux':
return 'electron'
case 'win32':
return 'electron.exe'
default:
throw new Error(`Electron builds are not available on platform: ${targetPlatform}`)
}
}
function parseChildCheckResult(result) {
const failures = parseCheckFailures(result.stderr)
@@ -163,15 +219,6 @@ function collectNativeModuleFailures() {
}
function loadNativeModule(moduleName) {
if (moduleName === 'better-sqlite3') {
const Database = require(moduleName)
// Why: better-sqlite3 defers loading its .node binding until Database is
// constructed, so a plain require() misses Node ABI mismatches.
const db = new Database(':memory:')
db.close()
return
}
if (moduleName === 'node-pty') {
loadNodePtyNativeModule()
return
+10 -30
View File
@@ -39,10 +39,10 @@ describe('ensure-native-runtime', () => {
expect(result.status, result.stderr).toBe(0)
const log = readFileSync(logPath, 'utf8')
expect(log).toContain('pnpm rebuild better-sqlite3\n')
expect(log.split('\n').filter((line) => line.startsWith('better-sqlite3 '))).toEqual([
'better-sqlite3 child marker=false',
'better-sqlite3 child marker=true'
expect(log).toContain('pnpm rebuild node-pty\n')
expect(log.split('\n').filter((line) => line.startsWith('node-pty child '))).toEqual([
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=false$/),
expect.stringMatching(/^node-pty child (?:conpty|pty) marker=true$/)
])
} finally {
rmSync(projectDir, { recursive: true, force: true })
@@ -69,44 +69,24 @@ function envWithPrependedPath(binDir, extraEnv) {
}
function writeFakeNativeModules(projectDir) {
const sqliteDir = join(projectDir, 'node_modules', 'better-sqlite3')
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
mkdirSync(sqliteDir, { recursive: true })
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
writeFileSync(
join(sqliteDir, 'index.js'),
`
const { appendFileSync, existsSync } = require('node:fs')
module.exports = class Database {
constructor() {
const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`better-sqlite3 \${process.argv.includes('--check-only') ? 'child' : 'parent'} marker=\${markerExists}\\n\`
)
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
}
close() {}
}
`
)
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
writeFileSync(
join(nodePtyDir, 'lib', 'utils.js'),
`
const { appendFileSync } = require('node:fs')
const { appendFileSync, existsSync } = require('node:fs')
exports.loadNativeModule = function loadNativeModule(nativeName) {
const markerExists = existsSync(process.env.ORCA_NATIVE_TEST_MARKER)
appendFileSync(
process.env.ORCA_NATIVE_TEST_LOG,
\`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName}\\n\`
\`node-pty \${process.argv.includes('--check-only') ? 'child' : 'parent'} \${nativeName} marker=\${markerExists}\\n\`
)
if (!markerExists) {
throw new Error('ABI mismatch sentinel')
}
}
`
)
+5 -5
View File
@@ -7,12 +7,10 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const source = path.join(scriptDir, 'orca-dev')
const source = path.join(scriptDir, 'orca-dev.mjs')
const commandPath =
process.platform === 'darwin' || process.platform === 'linux'
? '/usr/local/bin/orca-dev'
: null
process.platform === 'darwin' || process.platform === 'linux' ? '/usr/local/bin/orca-dev' : null
if (!commandPath) {
console.log('[orca-dev] Skipping global symlink (unsupported platform).')
@@ -21,7 +19,9 @@ if (!commandPath) {
function isOwnedByUs(target) {
try {
if (!lstatSync(target).isSymbolicLink()) { return false }
if (!lstatSync(target).isSymbolicLink()) {
return false
}
return readlinkSync(target) === source
} catch {
return false
@@ -17,13 +17,14 @@ import { platform as osPlatform, tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const require = createRequire(import.meta.url)
const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronRequire = createRequire(resolve(electronPackageDir, 'package.json'))
const { version: electronVersion } = electronRequire('./package.json')
const { downloadArtifact } = electronRequire('@electron/get')
const platformPath = getElectronPlatformPath()
const targetPlatform = getElectronTargetPlatform()
const targetArch = getElectronTargetArch()
const platformPath = getElectronPlatformPath(targetPlatform)
try {
// Why: Electron's own install.js can exit 0 while an async extract promise is
@@ -59,17 +60,24 @@ async function main() {
function electronPackageIsUsable() {
try {
const electronPath = resolveElectronPath()
return existsSync(electronPath)
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
.trim()
.replace(/^v/, '')
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
return (
installedVersion === electronVersion &&
installedPlatformPath === platformPath &&
existsSync(getElectronExecutablePath())
)
} catch {
return false
}
}
function resolveElectronPath() {
const electronModulePath = require.resolve('electron')
delete require.cache[electronModulePath]
return require('electron')
function getElectronExecutablePath() {
return process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
}
function resetPartialElectronInstall() {
@@ -107,8 +115,8 @@ async function installElectronPackageBinary() {
const zipPath = await downloadArtifact({
version: electronVersion,
artifactName: 'electron',
platform: process.env.npm_config_platform || osPlatform(),
arch: process.env.npm_config_arch || process.arch,
platform: targetPlatform,
arch: targetArch,
cacheRoot,
force: true,
tempDirectory: tempDir,
@@ -250,8 +258,15 @@ function safeReaddir(targetPath) {
}
}
function getElectronPlatformPath() {
const targetPlatform = process.env.npm_config_platform || osPlatform()
function getElectronTargetPlatform() {
return process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || osPlatform()
}
function getElectronTargetArch() {
return process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || process.arch
}
function getElectronPlatformPath(targetPlatform) {
switch (targetPlatform) {
case 'mas':
case 'darwin':
@@ -49,6 +49,50 @@ describe('install-electron-package-binary', () => {
}
})
it('uses Electron 42 install env vars before npm config platform flags', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir)
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir, {
ELECTRON_INSTALL_PLATFORM: 'win32',
ELECTRON_INSTALL_ARCH: 'arm64',
npm_config_platform: 'linux',
npm_config_arch: 'x64'
})
expect(result.status, result.stderr).toBe(0)
expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain(
'platform=win32 arch=arm64'
)
expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe(
'electron.exe'
)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('does not trigger Electron 42 lazy require downloads while checking install state', () => {
const projectDir = mkTempProject()
try {
writeFakeElectronPackage(projectDir, { lazyRequireMarker: 'lazy-require.marker' })
writeFakeElectronGet(projectDir)
writeFakeExtractor(projectDir, { createExecutable: true })
const result = runInstallScript(projectDir)
expect(result.status, result.stderr).toBe(0)
expect(existsSync(join(projectDir, 'lazy-require.marker'))).toBe(false)
} finally {
rmSync(projectDir, { recursive: true, force: true })
}
})
it('fails instead of silently accepting a partial Electron extract', () => {
const projectDir = mkTempProject()
@@ -100,7 +144,7 @@ function mkTempProject() {
return projectDir
}
function runInstallScript(projectDir) {
function runInstallScript(projectDir, extraEnv = {}) {
return spawnSync(process.execPath, ['config/scripts/install-electron-package-binary.mjs'], {
cwd: projectDir,
encoding: 'utf8',
@@ -108,12 +152,13 @@ function runInstallScript(projectDir) {
...process.env,
npm_config_platform: 'linux',
npm_config_arch: 'x64',
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs')
ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs'),
...extraEnv
}
})
}
function writeFakeElectronPackage(projectDir) {
function writeFakeElectronPackage(projectDir, { lazyRequireMarker = null } = {}) {
const electronDir = join(projectDir, 'node_modules', 'electron')
mkdirSync(electronDir, { recursive: true })
writeFileSync(
@@ -126,6 +171,7 @@ function writeFakeElectronPackage(projectDir) {
`
const fs = require('node:fs')
const path = require('node:path')
${lazyRequireMarker ? `fs.writeFileSync(${JSON.stringify(lazyRequireMarker)}, 'required')` : ''}
const pathFile = path.join(__dirname, 'path.txt')
if (!fs.existsSync(pathFile)) {
throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again')
@@ -144,7 +190,10 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {})
const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs')
const { join } = require('node:path')
exports.downloadArtifact = async function downloadArtifact(details) {
appendFileSync('electron-get.log', 'cacheRoot=' + details.cacheRoot + '\\n')
appendFileSync(
'electron-get.log',
'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n'
)
if (${JSON.stringify(downloadNeverSettles)}) {
return new Promise(() => {})
}
@@ -167,6 +216,7 @@ const extractDir = process.argv[3]
mkdirSync(join(extractDir, 'locales'), { recursive: true })
if (${JSON.stringify(createExecutable)}) {
writeFileSync(join(extractDir, 'electron'), '')
writeFileSync(join(extractDir, 'electron.exe'), '')
writeFileSync(join(extractDir, 'version'), 'v41.5.0')
if (process.platform !== 'win32') {
symlinkSync('version', join(extractDir, 'version-link'))
+53
View File
@@ -0,0 +1,53 @@
import { execFileSync } from 'node:child_process'
import { chmodSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = path.resolve(import.meta.dirname, '../..')
const packageJson = JSON.parse(readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
const wrapperPath = path.join(projectDir, 'config', 'scripts', 'orca-dev.mjs')
describe('orca-dev package bin', () => {
it('uses a Node entrypoint for cross-platform package installs', () => {
expect(packageJson.bin['orca-dev']).toBe('./config/scripts/orca-dev.mjs')
expect(readFileSync(wrapperPath, 'utf8').startsWith('#!/usr/bin/env node\n')).toBe(true)
})
it('runs the dev CLI through Node without requiring Bash', () => {
const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-bin-'))
const cliEntry = path.join(root, 'cli-entry.cjs')
const outputPath = path.join(root, 'output.json')
writeFileSync(
cliEntry,
[
'const fs = require("node:fs");',
`fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`,
' argv: process.argv.slice(2),',
' userDataPath: process.env.ORCA_USER_DATA_PATH,',
' appExecutable: process.env.ORCA_APP_EXECUTABLE',
'}));'
].join('\n'),
'utf8'
)
if (process.platform !== 'win32') {
chmodSync(cliEntry, 0o755)
}
execFileSync(process.execPath, [wrapperPath, '--help'], {
env: {
...process.env,
ORCA_DEV_CLI_ENTRY_PATH: cliEntry,
ORCA_DEV_USER_DATA_PATH: path.join(root, 'user-data'),
ORCA_APP_EXECUTABLE: path.join(root, 'Electron')
},
stdio: 'ignore'
})
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
argv: ['--help'],
userDataPath: path.join(root, 'user-data'),
appExecutable: path.join(root, 'Electron')
})
})
})
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const scriptPath = realpathSync(fileURLToPath(import.meta.url))
const scriptDir = path.dirname(scriptPath)
const repoRoot = path.resolve(scriptDir, '..', '..')
const cliEntry =
process.env.ORCA_DEV_CLI_ENTRY_PATH ?? path.join(repoRoot, 'out', 'cli', 'index.js')
if (!existsSync(cliEntry)) {
console.error("orca-dev: CLI not built yet. Run 'pnpm run build:cli' first.")
process.exit(1)
}
process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath()
const electronExecutable = getElectronExecutable()
if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) {
process.env.ORCA_APP_EXECUTABLE = electronExecutable
process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1'
}
const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], {
stdio: 'inherit',
env: process.env
})
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
function getDefaultDevUserDataPath() {
if (process.platform === 'darwin') {
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return path.join(
process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'),
'orca-dev'
)
}
return path.join(
process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'),
'orca-dev'
)
}
function getElectronExecutable() {
if (process.platform === 'win32') {
return path.join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe')
}
return path.join(repoRoot, 'node_modules', '.bin', 'electron')
}
function isRunnableFile(candidate) {
try {
const stats = statSync(candidate)
if (!stats.isFile()) {
return false
}
if (process.platform === 'win32') {
return true
}
accessSync(candidate, constants.X_OK)
return true
} catch {
return false
}
}
@@ -33,6 +33,21 @@ describe('Electron runtime package contract', () => {
}
})
it('keeps Windows and Linux package builds off the macOS native helper build', () => {
const scripts = packageJson.scripts
expect(scripts['build:desktop']).not.toContain('build:computer-macos')
expect(scripts['build:win']).toContain('pnpm run build:desktop')
expect(scripts['build:win']).not.toContain('pnpm run build ')
expect(scripts['build:win']).not.toContain('build:computer-macos')
expect(scripts['build:linux']).toContain('pnpm run build:desktop')
expect(scripts['build:linux']).not.toContain('pnpm run build ')
expect(scripts['build:linux']).not.toContain('build:computer-macos')
expect(scripts['build:mac']).toContain('pnpm run build:computer-macos')
expect(scripts['build:release']).toContain('pnpm run build:native')
expect(scripts['build:release']).not.toContain('build:computer-macos')
})
it('guards release publishing before electron-builder runs', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
@@ -75,6 +90,22 @@ describe('Electron runtime package contract', () => {
expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"')
})
it('keeps release-cut RC retries monotonic across stale attempts', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const versionStep = parsedWorkflow.jobs.cut.steps.find(
(step) => step.name === 'Compute next version'
)
expect(versionStep.run).toContain('node config/scripts/release-rc-history.mjs "$1"')
expect(versionStep.run).toContain('tag_matches_current_ref')
expect(versionStep.run).toContain('cutting the next version instead of reusing stale artifacts')
expect(versionStep.run).toContain('git rev-parse "$existing_rc_tag"')
})
it('bumps separate Homebrew casks for stable and RC desktop tags', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -86,7 +117,7 @@ describe('Electron runtime package contract', () => {
expect(releaseWorkflow.jobs['homebrew-bump'].if).toContain(
"startsWith(needs.cut.outputs.tag, 'v')"
)
expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain("-rc.")
expect(releaseWorkflow.jobs['homebrew-bump'].if).not.toContain('-rc.')
expect(releaseWorkflow.jobs['homebrew-bump-published-rc-draft'].with.tag).toBe(
'${{ needs.cut.outputs.latest_published_rc_tag }}'
)
@@ -117,4 +148,16 @@ describe('Electron runtime package contract', () => {
expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs')
})
it('smokes the packaged CLI from outside the checkout in PR checks', () => {
const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8')
const parsedWorkflow = parse(prWorkflow)
const smokeStep = parsedWorkflow.jobs.verify.steps.find(
(step) => step.name === 'Smoke packaged CLI'
)
expect(smokeStep.run).toBe(
'node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked'
)
})
})
+3 -10
View File
@@ -7,10 +7,7 @@ const PAYLOAD_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_PAYLOAD_CHARS ?
const RUNS = Number.parseInt(process.env.ORCA_PTY_BENCH_RUNS ?? '30', 10)
const MEASURE_TIMER_DELAYS = process.env.ORCA_PTY_BENCH_MEASURE_TIMER_DELAYS !== '0'
const INGRESS_CHUNKS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHUNKS ?? '96', 10)
const INGRESS_CHUNK_CHARS = Number.parseInt(
process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536',
10
)
const INGRESS_CHUNK_CHARS = Number.parseInt(process.env.ORCA_PTY_BENCH_INGRESS_CHARS ?? '65536', 10)
const CHUNK_CHARS = 16 * 1024
const MAX_WRITES_PER_SLICE = 2
const RECENT_PTY_OUTPUT_LIMIT = 4096
@@ -30,14 +27,10 @@ if (!Number.isInteger(RUNS) || RUNS <= 0) {
throw new Error(`ORCA_PTY_BENCH_RUNS must be positive, received ${RUNS}`)
}
if (!Number.isInteger(INGRESS_CHUNKS) || INGRESS_CHUNKS <= 0) {
throw new Error(
`ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}`
)
throw new Error(`ORCA_PTY_BENCH_INGRESS_CHUNKS must be positive, received ${INGRESS_CHUNKS}`)
}
if (!Number.isInteger(INGRESS_CHUNK_CHARS) || INGRESS_CHUNK_CHARS <= 0) {
throw new Error(
`ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}`
)
throw new Error(`ORCA_PTY_BENCH_INGRESS_CHARS must be positive, received ${INGRESS_CHUNK_CHARS}`)
}
function makePendingData() {
@@ -1,5 +1,6 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { appendFileSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import { verifyRequiredReleaseAssets } from './verify-release-required-assets.mjs'
@@ -21,6 +22,28 @@ function isRcTag(tag) {
return tag.includes('-rc.')
}
function gitOutput(args, cwd) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}).trim()
}
export function isTagBuiltFromCurrentRef(tag, { cwd = process.cwd() } = {}) {
try {
const tagCommit = gitOutput(['rev-parse', `${tag}^{}`], cwd)
const currentCommit = gitOutput(['rev-parse', 'HEAD'], cwd)
if (tagCommit === currentCommit) {
return true
}
return gitOutput(['rev-parse', `${tagCommit}^`], cwd) === currentCommit
} catch {
return false
}
}
async function githubJson(fetchImpl, url, token, options = {}) {
const res = await fetchImpl(url, {
...options,
@@ -55,6 +78,7 @@ export async function publishCompleteDraftReleases({
token,
fetchImpl = fetch,
verifyReleaseAssets = verifyRequiredReleaseAssets,
isDraftBuiltFromCurrentRef = ({ tag }) => isTagBuiltFromCurrentRef(tag),
log = console.log
}) {
if (!repo) {
@@ -74,6 +98,13 @@ export async function publishCompleteDraftReleases({
for (const release of candidates) {
const tag = release.tag_name
if (!(await isDraftBuiltFromCurrentRef({ tag, release }))) {
const reason = 'tag is not built from the current release ref'
skipped.push({ tag, reason })
log(`Skipping stale RC draft release ${tag}: ${reason}`)
continue
}
try {
await verifyReleaseAssets({ repo, tag, token })
} catch (error) {
@@ -85,13 +116,18 @@ export async function publishCompleteDraftReleases({
// Why: only release-cut-authored RC drafts with a complete asset set are
// resumed here; incomplete drafts stay private for the normal rebuild path.
await githubJson(fetchImpl, `https://api.github.com/repos/${repo}/releases/${release.id}`, token, {
method: 'PATCH',
body: JSON.stringify({
draft: false,
prerelease: isRcTag(tag)
})
})
await githubJson(
fetchImpl,
`https://api.github.com/repos/${repo}/releases/${release.id}`,
token,
{
method: 'PATCH',
body: JSON.stringify({
draft: false,
prerelease: isRcTag(tag)
})
}
)
published.push(tag)
log(`Published complete RC draft release ${tag}`)
}
@@ -1,13 +1,39 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
isTagBuiltFromCurrentRef,
isReleaseCutDraft,
publishCompleteDraftReleases,
writeGithubOutputs
} from './publish-complete-draft-releases.mjs'
function git(cwd, args) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim()
}
function withGitRepo(run) {
const dir = mkdtempSync(join(tmpdir(), 'orca-draft-release-'))
try {
git(dir, ['init', '--initial-branch=main'])
git(dir, ['config', 'user.name', 'Test Bot'])
git(dir, ['config', 'user.email', 'test@example.com'])
run(dir)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
function commit(cwd, message) {
git(cwd, ['commit', '--allow-empty', '-m', message])
}
function jsonResponse(body, init = {}) {
return {
ok: init.ok ?? true,
@@ -51,6 +77,41 @@ describe('isReleaseCutDraft', () => {
})
})
describe('isTagBuiltFromCurrentRef', () => {
it('accepts a tag on the current release commit', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
commit(repo, 'release: v1.4.36-rc.6')
git(repo, ['tag', 'v1.4.36-rc.6'])
expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true)
})
})
it('accepts a tag whose release commit is built from the current ref', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
const source = git(repo, ['rev-parse', 'HEAD'])
commit(repo, 'release: v1.4.36-rc.6')
git(repo, ['tag', 'v1.4.36-rc.6'])
git(repo, ['checkout', source])
expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(true)
})
})
it('rejects a stale tag when the current ref has moved on', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
commit(repo, 'release: v1.4.36-rc.6')
git(repo, ['tag', 'v1.4.36-rc.6'])
commit(repo, 'fix: release packaging')
expect(isTagBuiltFromCurrentRef('v1.4.36-rc.6', { cwd: repo })).toBe(false)
})
})
})
describe('publishCompleteDraftReleases', () => {
it('publishes complete release-cut drafts and skips incomplete ones', async () => {
const fetchImpl = vi
@@ -86,6 +147,7 @@ describe('publishCompleteDraftReleases', () => {
token: 'token',
fetchImpl,
verifyReleaseAssets,
isDraftBuiltFromCurrentRef: vi.fn(async () => true),
log
})
@@ -106,6 +168,38 @@ describe('publishCompleteDraftReleases', () => {
})
)
})
it('skips stale complete drafts before publishing', async () => {
const fetchImpl = vi.fn().mockResolvedValueOnce(
jsonResponse([
{
id: 7,
draft: true,
tag_name: 'v1.4.2-rc.7',
created_at: '2026-05-15T07:31:19Z',
author: { login: 'github-actions[bot]' }
}
])
)
const verifyReleaseAssets = vi.fn()
const log = vi.fn()
const result = await publishCompleteDraftReleases({
repo: 'stablyai/orca',
token: 'token',
fetchImpl,
verifyReleaseAssets,
isDraftBuiltFromCurrentRef: vi.fn(async () => false),
log
})
expect(result).toEqual({
published: [],
skipped: [{ tag: 'v1.4.2-rc.7', reason: 'tag is not built from the current release ref' }]
})
expect(verifyReleaseAssets).not.toHaveBeenCalled()
expect(fetchImpl).toHaveBeenCalledTimes(1)
})
})
describe('writeGithubOutputs', () => {
+114 -68
View File
@@ -4,47 +4,58 @@
*
* The standard `electron-builder install-app-deps` uses @electron/rebuild
* internally but does not expose the `ignoreModules` option (as of
* electron-builder 26.x). On Windows dev machines that lack the full
* Visual C++ / Python build toolchain, `cpu-features@0.0.10` (an optional
* performance dependency of `ssh2`) fails to build with node-gyp because
* `buildcheck.gypi` is missing from the tarball. This causes the entire
* postinstall step to fail and prevents `pnpm install` from completing.
* electron-builder 26.x). `cpu-features@0.0.10` is an optional performance
* dependency of `ssh2`; it fails to build in common environments (missing
* buildcheck.gypi on Windows, and Electron 42's V8 external-pointer API on
* Linux). This can make the entire postinstall step fail and prevent
* `pnpm install` from completing.
*
* This script replaces `electron-builder install-app-deps` in the postinstall
* lifecycle. It calls @electron/rebuild's JS API directly so that we can pass
* `ignoreModules: ['cpu-features']` on Windows. Skipping cpu-features is
* safe: ssh2 detects the missing native module and falls back to pure-JS CPU
* feature detection automatically.
*
* On macOS and Linux the full rebuild (including cpu-features) runs as usual.
* lifecycle and the electron-builder beforeBuild hook. It calls
* @electron/rebuild's JS API directly so that we can skip `cpu-features` when
* rebuilding modules against Electron. Skipping
* cpu-features is safe: ssh2 detects the missing native module and falls back
* to pure-JS CPU feature detection automatically.
*/
import { rebuild } from '@electron/rebuild'
import { execFileSync, spawnSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { platform as osPlatform } from 'node:os'
import { resolve } from 'node:path'
const require = createRequire(import.meta.url)
const projectDir = process.cwd()
let cliOptions
try {
cliOptions = readCliOptions(process.argv.slice(2))
} catch (error) {
console.error(`[rebuild] ${formatError(error)}`)
process.exit(2)
}
const rebuildPlatform = cliOptions.platform ?? osPlatform()
const rebuildArch = cliOptions.arch ?? process.arch
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronVersion = JSON.parse(
readFileSync(resolve(projectDir, 'node_modules/electron/package.json'), 'utf8')
readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8')
).version
const ignoreModules = process.platform === 'win32' ? ['cpu-features'] : []
const ignoreModules = ['cpu-features']
if (ignoreModules.length > 0) {
console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`)
console.log(`[rebuild] Skipping optional Electron rebuild modules: ${ignoreModules.join(', ')}`)
}
// Why: @electron/rebuild's default module walker doesn't reliably find native
// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to
// rebuild via `onlyModules` ensures they're recompiled against Electron's Node
// ABI regardless of the package manager's store layout.
const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
const NATIVE_MODULES = ['node-pty', 'cpu-features']
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
const forceRebuild = process.env.ORCA_FORCE_NATIVE_REBUILD === '1'
const forceRebuild =
process.env.ORCA_FORCE_NATIVE_REBUILD === '1' ||
cliOptions.force ||
rebuildPlatform !== osPlatform() ||
rebuildArch !== process.arch
ensureElectronPackageInstalled()
@@ -61,7 +72,7 @@ if (!forceRebuild) {
console.log(probe.stderr.trim())
}
} else {
console.log('[rebuild] ORCA_FORCE_NATIVE_REBUILD=1 set; forcing native rebuild.')
console.log(`[rebuild] Forcing native rebuild for ${rebuildPlatform}-${rebuildArch}.`)
}
// Why: cpu-features ships without `buildcheck.gypi`; its own `install` script
@@ -98,6 +109,8 @@ try {
await rebuild({
buildPath: projectDir,
electronVersion,
platform: rebuildPlatform,
arch: rebuildArch,
ignoreModules,
onlyModules,
// Why: without force, @electron/rebuild skips modules it considers
@@ -127,18 +140,13 @@ try {
}
function ensureElectronPackageInstalled() {
try {
require('electron')
if (electronPackageIsUsable()) {
return
} catch (/** @type {any} */ err) {
if (!isElectronPackageInstallError(err)) {
throw err
}
}
// Why: CI has observed Electron's postinstall exiting cleanly without
// writing path.txt; use our strict installer instead of Electron's install.js
// so partial extracts and poisoned caches are rejected before native rebuild.
// writing path.txt. Electron 42's lazy require() would run install.js here,
// so inspect dist/ directly and keep using our strict partial-extract checks.
console.log('[rebuild] Electron package binary is missing; installing Electron package binary.')
resetPartialElectronInstall()
try {
@@ -152,32 +160,35 @@ function ensureElectronPackageInstalled() {
process.exit(1)
}
try {
require('electron')
} catch (/** @type {any} */ err) {
if (!repairElectronPathFile()) {
if (!electronPackageIsUsable()) {
const repaired = repairElectronPathFile()
if (!repaired || !electronPackageIsUsable()) {
logElectronInstallDiagnostics()
if (continuePostinstallWithoutElectron()) {
process.exit(0)
}
console.error(
'[rebuild] Electron package is still unavailable after retry:',
err?.message ?? err
)
process.exit(1)
}
try {
require('electron')
} catch (/** @type {any} */ retryErr) {
console.error(
'[rebuild] Electron package is still unavailable after repairing path.txt:',
retryErr?.message ?? retryErr
)
console.error('[rebuild] Electron package is still unavailable after retry.')
process.exit(1)
}
}
}
function electronPackageIsUsable() {
try {
const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8')
.trim()
.replace(/^v/, '')
const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8')
return (
installedVersion === electronVersion &&
installedPlatformPath === getElectronPlatformPath() &&
existsSync(getElectronExecutablePath())
)
} catch {
return false
}
}
function runElectronPackageBinaryInstall() {
const env = { ...process.env }
delete env.ELECTRON_SKIP_BINARY_DOWNLOAD
@@ -204,7 +215,6 @@ function runElectronPackageBinaryInstall() {
}
function resetPartialElectronInstall() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
// Why: Electron's installer can leave a partial dist/ tree behind after
// skipped or interrupted postinstall runs; retry from a clean target.
rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true })
@@ -224,12 +234,8 @@ function continuePostinstallWithoutElectron() {
}
function repairElectronPathFile() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const platformPath = getElectronPlatformPath()
const electronPath = process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
if (!existsSync(electronPath)) {
if (!existsSync(getElectronExecutablePath())) {
return false
}
@@ -242,7 +248,6 @@ function repairElectronPathFile() {
}
function logElectronInstallDiagnostics() {
const electronPackageDir = resolve(projectDir, 'node_modules/electron')
const electronDistDir = resolve(electronPackageDir, 'dist')
const pathFile = resolve(electronPackageDir, 'path.txt')
console.error('[rebuild] Electron install diagnostics:')
@@ -263,7 +268,8 @@ function safeReaddir(targetPath) {
}
function getElectronPlatformPath() {
const targetPlatform = process.env.npm_config_platform || osPlatform()
const targetPlatform =
process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || rebuildPlatform
switch (targetPlatform) {
case 'mas':
case 'darwin':
@@ -279,13 +285,63 @@ function getElectronPlatformPath() {
}
}
function probeElectronNativeModules(moduleNames) {
let electronExecutable
try {
electronExecutable = require('electron')
} catch (error) {
return { ok: false, status: null, stderr: formatError(error) }
function readCliOptions(args) {
const options = { force: false }
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === '--force') {
options.force = true
continue
}
if (arg === '--platform') {
options.platform = readRequiredArgValue(args, (index += 1), '--platform')
continue
}
if (arg.startsWith('--platform=')) {
options.platform = readInlineArgValue(arg, '--platform')
continue
}
if (arg === '--arch') {
options.arch = readRequiredArgValue(args, (index += 1), '--arch')
continue
}
if (arg.startsWith('--arch=')) {
options.arch = readInlineArgValue(arg, '--arch')
continue
}
throw new Error(`Unknown argument: ${arg}`)
}
return options
}
function readRequiredArgValue(args, index, flag) {
const value = args[index]
if (!value || value.startsWith('--')) {
throw new Error(`Missing value for ${flag}`)
}
return value
}
function readInlineArgValue(arg, flag) {
const value = arg.slice(`${flag}=`.length)
if (!value) {
throw new Error(`Missing value for ${flag}`)
}
return value
}
function getElectronExecutablePath() {
const platformPath = getElectronPlatformPath()
return process.env.ELECTRON_OVERRIDE_DIST_PATH
? resolve(process.env.ELECTRON_OVERRIDE_DIST_PATH, platformPath)
: resolve(electronPackageDir, 'dist', platformPath)
}
function probeElectronNativeModules(moduleNames) {
if (!electronPackageIsUsable()) {
return { ok: false, status: null, stderr: 'Electron package binary is unavailable.' }
}
const electronExecutable = getElectronExecutablePath()
const probeSource = `
const { createRequire } = require('node:module')
@@ -309,12 +365,6 @@ if (failures.length > 0) {
}
function loadNativeModule(moduleName) {
if (moduleName === 'better-sqlite3') {
const Database = projectRequire(moduleName)
const db = new Database(':memory:')
db.close()
return
}
if (moduleName === 'node-pty') {
projectRequire('node-pty')
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
@@ -373,10 +423,6 @@ function isPostinstall() {
return process.env.npm_lifecycle_event === 'postinstall'
}
function isElectronPackageInstallError(error) {
return /Electron failed to install correctly/i.test(formatError(error))
}
function formatError(error) {
return error instanceof Error ? error.message : String(error)
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { pathToFileURL } from 'node:url'
function gitLines(args, cwd) {
try {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
export function rcNumberFromTag(base, tag) {
const prefix = `v${base}-rc.`
if (!tag.startsWith(prefix)) {
return null
}
const suffix = tag.slice(prefix.length)
return /^\d+$/.test(suffix) ? Number(suffix) : null
}
export function rcNumberFromReleaseSubject(base, subject) {
const prefix = `release: v${base}-rc.`
if (!subject.startsWith(prefix)) {
return null
}
const match = /^(\d+)(?:\s|$)/.exec(subject.slice(prefix.length))
return match ? Number(match[1]) : null
}
export function highestRcForBase(base, { cwd = process.cwd() } = {}) {
const numbers = []
for (const tag of gitLines(['tag', '--list', `v${base}-rc.*`], cwd)) {
const rcNumber = rcNumberFromTag(base, tag)
if (rcNumber !== null) {
numbers.push(rcNumber)
}
}
const logRefs = ['HEAD', 'origin/main'].filter(
(ref) => gitLines(['rev-parse', '--verify', '--quiet', ref], cwd).length > 0
)
if (logRefs.length > 0) {
for (const subject of gitLines(['log', '--format=%s', ...logRefs], cwd)) {
const rcNumber = rcNumberFromReleaseSubject(base, subject)
if (rcNumber !== null) {
numbers.push(rcNumber)
}
}
}
return numbers.length === 0 ? null : Math.max(...numbers)
}
function main() {
const base = process.argv[2]
if (!base) {
throw new Error('Usage: node config/scripts/release-rc-history.mjs <base-version>')
}
const highest = highestRcForBase(base)
if (highest !== null) {
process.stdout.write(String(highest))
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
@@ -0,0 +1,78 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
highestRcForBase,
rcNumberFromReleaseSubject,
rcNumberFromTag
} from './release-rc-history.mjs'
function git(cwd, args) {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
}).trim()
}
function withGitRepo(run) {
const dir = mkdtempSync(join(tmpdir(), 'orca-rc-history-'))
try {
git(dir, ['init', '--initial-branch=main'])
git(dir, ['config', 'user.name', 'Test Bot'])
git(dir, ['config', 'user.email', 'test@example.com'])
run(dir)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
function commit(cwd, message, { allowEmpty = true } = {}) {
const args = ['commit', '-m', message]
if (allowEmpty) {
args.splice(1, 0, '--allow-empty')
}
git(cwd, args)
}
describe('release RC history', () => {
it('parses only exact desktop RC tag suffixes', () => {
expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7')).toBe(7)
expect(rcNumberFromTag('1.4.36', 'v1.4.36-rc.7-extra')).toBeNull()
expect(rcNumberFromTag('1.4.36', 'v1.4.35-rc.7')).toBeNull()
})
it('parses release commit subjects with optional slot markers', () => {
expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6')).toBe(6)
expect(
rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6 [rc-slot:2026-05-30-03]')
).toBe(6)
expect(rcNumberFromReleaseSubject('1.4.36', 'release: v1.4.36-rc.6-extra')).toBeNull()
expect(rcNumberFromReleaseSubject('1.4.36', 'fix: v1.4.36-rc.6')).toBeNull()
})
it('keeps RC numbers monotonic after a stale tag is deleted', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
commit(repo, 'release: v1.4.36-rc.5')
git(repo, ['tag', 'v1.4.36-rc.5'])
commit(repo, 'release: v1.4.36-rc.6')
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
})
it('considers origin/main when releasing from an older ref', () => {
withGitRepo((repo) => {
commit(repo, 'initial')
git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
commit(repo, 'release: v1.4.36-rc.6')
git(repo, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repo, ['checkout', 'HEAD~1'])
expect(highestRcForBase('1.4.36', { cwd: repo })).toBe(6)
})
})
})
+1 -6
View File
@@ -241,12 +241,7 @@ function ensureRelativeSymlink(linkPath, target) {
}
function restoreElectronFrameworkSymlinks(appPath) {
const frameworkPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Framework.framework'
)
const frameworkPath = path.join(appPath, 'Contents', 'Frameworks', 'Electron Framework.framework')
const versionsPath = path.join(frameworkPath, 'Versions')
if (!existsSync(path.join(versionsPath, 'A'))) {
return
+46
View File
@@ -0,0 +1,46 @@
import { cp, mkdtemp, rm } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { basename, join, resolve } from 'node:path'
import { promisify } from 'node:util'
const execFileAsync = promisify(execFile)
function readAppDirArg(argv) {
const explicit = argv.find((arg) => arg.startsWith('--app-dir='))
if (explicit) {
return explicit.slice('--app-dir='.length)
}
if (process.platform === 'darwin') {
return 'dist/mac-arm64/Orca.app'
}
if (process.platform === 'win32') {
return 'dist/win-unpacked'
}
return 'dist/linux-unpacked'
}
function getPackagedCliPath(appDir) {
if (process.platform === 'darwin' || appDir.endsWith('.app')) {
return join(appDir, 'Contents', 'Resources', 'bin', 'orca')
}
if (process.platform === 'win32') {
return join(appDir, 'resources', 'bin', 'orca.cmd')
}
return join(appDir, 'resources', 'bin', 'orca-ide')
}
const appDir = resolve(readAppDirArg(process.argv.slice(2)))
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-packaged-cli-smoke-'))
const copiedAppDir = join(tempRoot, basename(appDir))
try {
await cp(appDir, copiedAppDir, { recursive: true, verbatimSymlinks: true })
const cliPath = getPackagedCliPath(copiedAppDir)
await execFileAsync(cliPath, ['--help'], {
env: { ...process.env, NODE_PATH: '' }
})
console.log(`[packaged-cli-smoke] ${cliPath} --help succeeded outside the repo`)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
@@ -138,6 +138,20 @@ function expressionHasStyledScrollbarLiteral(node) {
hasStyledScrollbar = true
return
}
// Why: a scrollbar literal that only renders on some branches must not be
// treated as covering an unconditional inline overflow. Skip conditional
// and short-circuit expressions when proving unconditional coverage.
if (ts.isConditionalExpression(current)) {
return
}
if (
ts.isBinaryExpression(current) &&
(current.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
current.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
current.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken)
) {
return
}
ts.forEachChild(current, visit)
}
@@ -246,7 +260,12 @@ function jsxElementReports(node, filePath, sourceFile) {
for (const attribute of node.attributes.properties) {
if (ts.isJsxSpreadAttribute(attribute)) {
classExpression ??= spreadPropExpressions(attribute.expression, 'className').at(-1)
// Why: at runtime React applies attributes in source order, so a later
// spread that supplies className overrides an earlier explicit className.
const spreadClassExpression = spreadPropExpressions(attribute.expression, 'className').at(-1)
if (spreadClassExpression) {
classExpression = spreadClassExpression
}
styleExpressions.push(...spreadPropExpressions(attribute.expression, 'style'))
} else if (jsxAttributeName(attribute) === 'className') {
classExpression = jsxAttributeExpression(attribute)
+20 -12
View File
@@ -16,7 +16,7 @@
* await term.connect()
* const ptyId = await term.discoverActivePtyId()
* await term.send(ptyId, 'echo hello\r')
* const screenshot = await term.screenshot('/tmp/test.png')
* const screenshot = await term.screenshot()
* await term.waitForOutput(ptyId, 'hello')
*
* Or run directly:
@@ -24,8 +24,19 @@
*/
import { execFileSync } from 'child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const AGENT_BROWSER = 'agent-browser'
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4))
function sleep(ms) {
Atomics.wait(sleepBuffer, 0, 0, ms)
}
function tempScreenshotPath(filename) {
return join(tmpdir(), filename)
}
/** Thin wrapper around `agent-browser --cdp <port> <subcommand>`. */
function ab(port, args) {
@@ -77,7 +88,7 @@ export class OrcaTerminal {
evalInRenderer(this.port, js)
// Wait for output to render
execFileSync('sleep', ['1.5'])
sleep(1_500)
// Read the visible xterm buffer to find which marker appeared
const bufferJs = `
@@ -129,14 +140,11 @@ export class OrcaTerminal {
* @param {number} maxId — highest PTY ID to probe
* @param {string} screenshotPath — where to save the screenshot
*/
probePtyIdWithScreenshot(maxId = 10, screenshotPath = '/tmp/orca-pty-probe.png') {
probePtyIdWithScreenshot(maxId = 10, screenshotPath = tempScreenshotPath('orca-pty-probe.png')) {
for (let i = 1; i <= maxId; i++) {
evalInRenderer(
this.port,
`window.api.pty.write('${i}', '\\x03\\x15echo PTY_ID_${i}\\r')`
)
evalInRenderer(this.port, `window.api.pty.write('${i}', '\\x03\\x15echo PTY_ID_${i}\\r')`)
}
execFileSync('sleep', ['2'])
sleep(2_000)
this.screenshot(screenshotPath)
return screenshotPath
}
@@ -179,7 +187,7 @@ export class OrcaTerminal {
* @param {string} path — output file path
* @returns {string} the screenshot path
*/
screenshot(path = '/tmp/orca-terminal.png') {
screenshot(path = tempScreenshotPath('orca-terminal.png')) {
ab(this.port, ['screenshot', path])
return path
}
@@ -190,7 +198,7 @@ export class OrcaTerminal {
*/
newTerminal() {
ab(this.port, ['click', '@e7']) // "New terminal (Cmd+T)" button
execFileSync('sleep', ['2'])
sleep(2_000)
}
/**
@@ -201,9 +209,9 @@ export class OrcaTerminal {
*/
readLang(ptyId) {
this.exec(ptyId, 'echo __LANG__=$LANG')
execFileSync('sleep', ['1'])
sleep(1_000)
// Screenshot and return for inspection
return this.screenshot('/tmp/orca-lang-check.png')
return this.screenshot(tempScreenshotPath('orca-lang-check.png'))
}
}
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { chmodSync, readFileSync, statSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
export function verifyPackageCliBin({
projectDir = path.resolve(import.meta.dirname, '..', '..'),
fixExecutable = false,
runHelp = false
} = {}) {
const packageJsonPath = path.join(projectDir, 'package.json')
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'))
const binTarget = packageJson.bin?.orca
if (typeof binTarget !== 'string' || binTarget.length === 0) {
throw new Error('package.json must declare bin.orca')
}
const binPath = path.resolve(projectDir, binTarget)
const stats = statSync(binPath)
if (!stats.isFile()) {
throw new Error(`bin.orca target is not a file: ${binTarget}`)
}
if (stats.size === 0) {
throw new Error(`bin.orca target is empty: ${binTarget}`)
}
const content = readFileSync(binPath, 'utf8')
if (!content.startsWith('#!/usr/bin/env node\n')) {
throw new Error(`bin.orca target must start with a Node shebang: ${binTarget}`)
}
if (process.platform !== 'win32' && (stats.mode & 0o111) === 0) {
if (!fixExecutable) {
throw new Error(`bin.orca target is not executable: ${binTarget}`)
}
chmodSync(binPath, stats.mode | 0o755)
}
if (runHelp) {
execFileSync(process.execPath, [binPath, '--help'], {
cwd: projectDir,
stdio: 'ignore'
})
}
return { binPath, size: statSync(binPath).size }
}
function main() {
const args = new Set(process.argv.slice(2))
const result = verifyPackageCliBin({
fixExecutable: args.has('--fix-executable'),
runHelp: args.has('--run-help')
})
console.log(
`[cli-bin] verified ${path.relative(process.cwd(), result.binPath)} (${result.size} bytes)`
)
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
main()
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exit(1)
}
}
+57
View File
@@ -0,0 +1,57 @@
import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { verifyPackageCliBin } from './verify-cli-bin.mjs'
function makeProjectWithCli(content, mode = 0o755) {
const projectDir = mkdtempSync(path.join(tmpdir(), 'orca-cli-bin-'))
const cliPath = path.join(projectDir, 'out', 'cli', 'index.js')
mkdirSync(path.dirname(cliPath), { recursive: true })
writeFileSync(
path.join(projectDir, 'package.json'),
JSON.stringify({ bin: { orca: './out/cli/index.js' } }),
'utf8'
)
writeFileSync(cliPath, content, 'utf8')
if (process.platform !== 'win32') {
chmodSync(cliPath, mode)
}
return { projectDir, cliPath }
}
describe('verifyPackageCliBin', () => {
it('accepts a non-empty Node entrypoint and can run help through Node', () => {
const { projectDir, cliPath } = makeProjectWithCli(
'#!/usr/bin/env node\nif (process.argv.includes("--help")) process.exit(0)\n'
)
expect(verifyPackageCliBin({ projectDir, runHelp: true })).toMatchObject({
binPath: cliPath
})
})
it('rejects an empty package bin target', () => {
const { projectDir } = makeProjectWithCli('')
expect(() => verifyPackageCliBin({ projectDir })).toThrow('bin.orca target is empty')
})
it('rejects package bin targets without a Node shebang', () => {
const { projectDir } = makeProjectWithCli('console.log("orca")\n')
expect(() => verifyPackageCliBin({ projectDir })).toThrow('Node shebang')
})
it.skipIf(process.platform === 'win32')('can repair the POSIX executable bit', () => {
const { projectDir, cliPath } = makeProjectWithCli(
'#!/usr/bin/env node\nconsole.log("orca")\n',
0o644
)
expect(() => verifyPackageCliBin({ projectDir })).toThrow('not executable')
verifyPackageCliBin({ projectDir, fixExecutable: true })
expect(statSync(cliPath).mode & 0o111).not.toBe(0)
expect(readFileSync(cliPath, 'utf8')).toContain('#!/usr/bin/env node')
})
})
@@ -170,9 +170,7 @@ function verifyAsar(asarPath) {
const writeKeyMatch = WRITE_KEY_RE.exec(indexJs)
if (!buildIdentityMatch) {
console.error(
`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`
)
console.error(`::error::BUILD_IDENTITY constant missing or unexpected value in ${asarPath}`)
const sample = indexJs.match(/.{0,80}BUILD_IDENTITY.{0,80}/g)?.slice(0, 5) ?? []
for (const line of sample) {
console.error(` ${line.slice(0, 200)}`)
@@ -213,9 +211,7 @@ for (const asarPath of asarMatches) {
const distinctIdentities = new Set(results.map((r) => r.buildIdentity))
if (distinctIdentities.size > 1) {
console.error(
`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`
)
console.error(`::error::asars disagree on BUILD_IDENTITY: ${[...distinctIdentities].join(', ')}`)
for (const r of results) {
console.error(` - ${r.asarPath}: ${r.buildIdentity}`)
}
+1
View File
@@ -22,6 +22,7 @@
"../src/main/gemini/hook-service.ts",
"../src/main/grok/hook-service.ts",
"../src/main/hermes/hook-service.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+2 -3
View File
@@ -10,10 +10,9 @@
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"baseUrl": "..",
"paths": {
"@renderer/*": ["src/renderer/src/*"],
"@/*": ["src/renderer/src/*"]
"@renderer/*": ["../src/renderer/src/*"],
"@/*": ["../src/renderer/src/*"]
}
}
}
+71 -69
View File
@@ -1,6 +1,6 @@
# Orca UI Style Guide
This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is *not* an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the *roles and rules* for using them.
This is the **UI/visual design** doc for Orca — color tokens, typography, component selection, and UX rules. It is _not_ an architecture doc; for system-level design see code and inline comments. Token values live in `src/renderer/src/assets/main.css` (canonical); this file documents the _roles and rules_ for using them.
## Overview
@@ -14,12 +14,12 @@ When in doubt:
## Source of truth
| Concern | Canonical location |
| -------------------- | ------------------------------------------------ |
| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) |
| Tailwind theme bindings | Same file, `@theme inline { … }` block |
| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) |
| App typography / scrollbars / titlebar chrome | Same `main.css` |
| Concern | Canonical location |
| --------------------------------------------- | ----------------------------------------------------- |
| Color tokens | `src/renderer/src/assets/main.css` (`:root`, `.dark`) |
| Tailwind theme bindings | Same file, `@theme inline { … }` block |
| Component primitives | `src/renderer/src/components/ui/` (shadcn-style) |
| App typography / scrollbars / titlebar chrome | Same `main.css` |
Never hardcode a hex value in component code if a variable already covers it. If a new token is needed, add it to `main.css` (both `:root` and `.dark`), expose it in the `@theme inline` block, then use it.
@@ -27,21 +27,21 @@ Never hardcode a hex value in component code if a variable already covers it. If
Tokens come in pairs: a **surface** and a **foreground** that meets contrast on it. Always use them together.
| Role | Use it for | Don't use it for |
| ---------------------------------- | ------------------------------------------------------------- | --------------------------------------------- |
| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) |
| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself |
| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI |
| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions |
| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action |
| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions |
| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) |
| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) |
| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` |
| `input` | Form field background only | Anywhere outside form fields |
| `ring` | Focus-visible outlines, active selection halos | Persistent decoration |
| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels |
| `editor-surface` | Background of Monaco / markdown editor panes | App chrome |
| Role | Use it for | Don't use it for |
| ---------------------------------------- | ----------------------------------------------------------- | --------------------------------------------------- |
| `background` / `foreground` | App canvas, default text | Cards, popovers, sidebar (have their own) |
| `card` / `card-foreground` | Panels lifted off the canvas | The canvas itself |
| `popover` / `popover-foreground` | Floating menus, dropdowns, hovercards | Inline UI |
| `primary` / `primary-foreground` | The single affirmative action in a flow (Save, Confirm) | Decorative accents; hover states; secondary actions |
| `secondary` / `secondary-foreground` | Lower-emphasis actions next to a primary | The affirmative action |
| `muted` / `muted-foreground` | De-emphasized text, captions, placeholders, disabled chrome | Body copy; primary actions |
| `accent` / `accent-foreground` | Hover/active backgrounds for ghost buttons and list rows | Solid filled buttons (use `secondary` instead) |
| `destructive` / `destructive-foreground` | Delete, discard, irreversible-action buttons; error states | Cancel buttons (Cancel is not destructive) |
| `border` | All hairlines: dividers, input outlines, card edges | Heavy emphasis; that's `ring` |
| `input` | Form field background only | Anywhere outside form fields |
| `ring` | Focus-visible outlines, active selection halos | Persistent decoration |
| `sidebar` (+ variants) | The worktree sidebar and its children | Other panels |
| `editor-surface` | Background of Monaco / markdown editor panes | App chrome |
The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, and `--sidebar-ring` — use them inside the worktree sidebar so its hover/selected/focus states stay consistent and don't bleed into other panels. `editor-surface` is its own token (not just `background`) because Monaco and the markdown editor have a slightly darker surface in dark mode to match VS Code conventions; reach for it whenever you're rendering an editor pane.
@@ -49,17 +49,17 @@ The `sidebar` family expands into `--sidebar`, `--sidebar-foreground`, `--sideba
For diff status, file-tree decorations, and the changes view, use the git decoration tokens (mirroring VS Code's palette so users transferring from VS Code aren't surprised):
| Token | State |
| ------------------------------ | ------------------------------ |
| `--git-decoration-added` | Added / new |
| `--git-decoration-modified` | Modified |
| `--git-decoration-deleted` | Deleted |
| `--git-decoration-renamed` | Renamed |
| `--git-decoration-untracked` | Untracked |
| `--git-decoration-copied` | Copied |
| `--git-decoration-ignored` | Ignored by git |
| Token | State |
| ---------------------------- | -------------- |
| `--git-decoration-added` | Added / new |
| `--git-decoration-modified` | Modified |
| `--git-decoration-deleted` | Deleted |
| `--git-decoration-renamed` | Renamed |
| `--git-decoration-untracked` | Untracked |
| `--git-decoration-copied` | Copied |
| `--git-decoration-ignored` | Ignored by git |
Use these *only* for git status. Don't reuse them for unrelated state colors — that breaks the convention.
Use these _only_ for git status. Don't reuse them for unrelated state colors — that breaks the convention.
### List rows: hover, selected, current
@@ -68,7 +68,7 @@ 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.
- **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.
- **Persistent "current" / "active" row** (e.g. the worktree the user is viewing): also `bg-accent`, _plus_ a `data-current="true"` attribute so CSS or future styling can distinguish it from the cmdk highlight.
- **Don't:** hardcode `bg-[#ededed]` / `bg-[#333333]` or invent a "selected" color. The accent token already adapts to light/dark and matches the rest of the app.
### Color mixing
@@ -118,14 +118,14 @@ Use the shadcn primitives in `src/renderer/src/components/ui/` before writing an
Variants in priority order:
| Variant | Use case |
| ------------- | ---------------------------------------------------------------- |
| `default` | The single affirmative action in a flow. |
| `secondary` | Lower-emphasis sibling next to a `default`. |
| `outline` | Toolbar / standalone actions where a filled button feels heavy. |
| Variant | Use case |
| ------------- | ------------------------------------------------------------------ |
| `default` | The single affirmative action in a flow. |
| `secondary` | Lower-emphasis sibling next to a `default`. |
| `outline` | Toolbar / standalone actions where a filled button feels heavy. |
| `ghost` | Icon buttons, list-row triggers, anywhere chrome should disappear. |
| `link` | Inline text actions inside paragraphs. |
| `destructive` | Delete, discard, irreversible. Never for Cancel. |
| `link` | Inline text actions inside paragraphs. |
| `destructive` | Delete, discard, irreversible. Never for Cancel. |
Sizes: `default` (36px), `sm` (32px), `xs` (24px), `lg` (40px), plus `icon`, `icon-xs`, `icon-sm`, `icon-lg`. Match the size to the surrounding row height — don't drop a `default` button into a 28px toolbar.
@@ -137,26 +137,26 @@ Browse `src/renderer/src/components/ui/` for the full list. Most wrap a Radix UI
When a control has multiple plausible primitives, use this fork:
| You want… | Reach for | Don't use |
| -------------------------------------------------------- | -------------------------- | ---------------------------------------- |
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
| You want… | Reach for | Don't use |
| ------------------------------------------------------------ | -------------------------------------------------------------------- | ------------------------------------- |
| Hover-only label on an icon-only button | `Tooltip` | `HoverCard` (too heavy), title attr |
| Hover preview of richer content (avatar + summary) | `HoverCard` | `Tooltip` (no rich content) |
| Click-revealed menu with actions | `DropdownMenu` | `Popover` with hand-rolled list |
| Right-click contextual actions | `ContextMenu` | `DropdownMenu` (different invocation) |
| Click-revealed surface with arbitrary content (form, picker) | `Popover` | `Dialog` (it traps focus and dims) |
| Modal that demands a decision before you continue | `Dialog` | `Popover`, inline overlay |
| Drawer / panel sliding in from an edge | `Sheet` | `Dialog` centered |
| Single choice from a known list | `Select` | Custom listbox |
| Single choice with search / fuzzy filtering | `Command` inside `Popover` | `Select` (no search) |
| Multi-select with search | `repo-multi-combobox` / `team-multi-combobox` (mirror their pattern) | Roll a new one |
| Transient confirmation ("Saved", "Copied") | `sonner` toast | `Dialog`, inline banner |
| Persistent inline status ("3 errors") | inline text + `Badge` | toast (toasts disappear) |
If you find yourself styling around a primitive (`<Popover>` to act like a `<Dialog>`, or vice versa), stop and reconsider — the focus-management semantics differ and a future contributor will be misled by the mismatch.
### Tooltips
Tooltips exist to *name* a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI.
Tooltips exist to _name_ a control whose meaning isn't obvious from its appearance. They are not the place to teach, persuade, or warn — anything users need to read while acting belongs in the visible UI.
- **Use a tooltip when:** an icon-only button or compact chip needs a label. Toolbar icons, badges with abbreviations, truncated paths.
- **Don't use a tooltip when:** the control already has a visible label, the content is interactive (links, buttons), or the message is critical (errors, blocking warnings — those go inline).
@@ -172,7 +172,9 @@ Tooltips exist to *name* a control whose meaning isn't obvious from its appearan
<Settings />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>Settings</TooltipContent>
<TooltipContent side="top" sideOffset={4}>
Settings
</TooltipContent>
</Tooltip>
```
@@ -185,11 +187,11 @@ Icons come from **`lucide-react`**. Don't import a second icon library.
- **`size-7`+:** for featured/empty-state hero icons only.
- **Stroke width:** lucide's default 2px. Don't override per-icon.
- **Color:** inherit from surrounding text — `text-muted-foreground` for secondary, `text-destructive` for destructive, etc. Don't apply a token to the SVG directly when the parent already carries the right color.
- **Spinner:** the canonical loading icon is `<Loader2 className="size-4 animate-spin" />`. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See *UX rule 1*.
- **Spinner:** the canonical loading icon is `<Loader2 className="size-4 animate-spin" />`. For 3s+ multi-step work, prefer a label that names the stage ("Cloning…" → "Installing…") over an unlabeled spinner. See _UX rule 1_.
### Keyboard shortcut chips
Use **`<ShortcutKeyCombo />`** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the *caller* picks the platform-appropriate labels and passes them in:
Use **`<ShortcutKeyCombo />`** from `src/renderer/src/components/ShortcutKeyCombo.tsx`. It renders a consistent key-cap style and inserts a `+` separator on Windows/Linux (Mac shows adjacent glyphs, no separator). It does **not** transform key strings — the _caller_ picks the platform-appropriate labels and passes them in:
```tsx
const isMac = navigator.userAgent.includes('Mac')
@@ -204,7 +206,7 @@ See `Landing.tsx` for the canonical pattern. Don't roll a one-off `<kbd>` — kb
- **Tooltips on icon buttons** — append the chip after the label, trailing.
- **Dropdown / context-menu items** — use `<DropdownMenuShortcut>` (or its context-menu equivalent) for the right-aligned chip; don't position one yourself.
- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see *UX rule 3*.
- **Never on Cancel, Dismiss, or `link`-variant inline actions** — see _UX rule 3_.
**The label MUST match the actual binding for the platform.** If the keyboard handler reads `metaKey` on Mac and `ctrlKey` elsewhere, the chip must show `⌘` on Mac and `Ctrl` elsewhere. Mismatched chips are worse than no chip.
@@ -235,25 +237,25 @@ These are the rules a contributor will most often get wrong if they're working i
### 1. Match in-flight feedback to perceived duration
The right question isn't *"should this control change while it's working?"* — it's *"how long does the action take, and what does the user need to know during that time?"*
The right question isn't _"should this control change while it's working?"_ — it's _"how long does the action take, and what does the user need to know during that time?"_
| Duration | Feedback |
| ------------------- | ------------------------------------------------------- |
| 0–100 ms | None. Anything visible reads as a glitch. |
| 100 ms–1 s | Disabled state only. |
| 1 s–3 s | Disabled + spinner or label swap. |
| 3 s+ or multi-step | Stage labels, progress, optional reassurance. |
| Duration | Feedback |
| ------------------ | --------------------------------------------- |
| 0–100 ms | None. Anything visible reads as a glitch. |
| 100 ms–1 s | Disabled state only. |
| 1 s–3 s | Disabled + spinner or label swap. |
| 3 s+ or multi-step | Stage labels, progress, optional reassurance. |
Two corollaries:
- **Pre-reserve any space you'll later occupy.** If a control may swap to a longer label or grow an icon, fix its footprint up front (use `width`, not `min-width`). A control that resizes mid-action looks broken even when the action succeeded.
- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the *disabled* state immediately (so double-clicks don't double-submit) and the *visible* state on a timer.
- **Don't pick worst-case feedback for everyone.** If the action is fast locally and slow remotely (SSH), defer the visible loading state by ~200ms. Local users see nothing; remote users get appropriate feedback. Bind the _disabled_ state immediately (so double-clicks don't double-submit) and the _visible_ state on a timer.
### 2. Look for sibling components before designing in isolation
If your component has a sibling — same domain, overlapping behavior, often visible at adjacent moments in the same flow — the two should read as one design. Same icons, same shortcut conventions, same submit semantics. A user moving between them shouldn't perceive a seam.
This is *not* "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about *adjacent* components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it).
This is _not_ "match every existing pattern." Some repo patterns are debt and copying them spreads the debt. The narrower claim is about _adjacent_ components. Diverging from a sibling needs a reason: either the sibling is wrong (fix both) or the new component has a real difference in role (commit to it).
When there's no sibling, match the surrounding chrome — button sizes, icon weights, copy tone — and don't manufacture a sibling from a screen the user will never correlate with this one.
@@ -268,7 +270,7 @@ Orca runs on macOS, Linux, and Windows. Every UI change must hold up on all thre
- **Modifier keys:** Never hardcode `e.metaKey`. Use `navigator.userAgent.includes('Mac')` to choose `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels:** Display `⌘` / `⇧` on Mac; display `Ctrl+` / `Shift+` on other platforms. The label must reflect the actual binding for that platform.
- **Window chrome:** macOS shows traffic lights; the titlebar reserves an 80px gutter (`titlebar-traffic-light-pad`) so they don't overlap content. Don't put hit targets in that band on Mac.
- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See *UX rules → 1*.
- **SSH:** Many users run Orca on a remote machine. Loading states, focus management, and animations must hold up under 50–200 ms of extra latency. Test under simulated latency (or actual SSH) — local-only verification isn't enough. See _UX rules → 1_.
## When this guide is silent
@@ -0,0 +1,73 @@
# Floating Terminal Panel Position Persistence
## Problem
The floating workspace panel appears in a different default location after app restart instead of returning to the user's last dragged or resized placement.
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:132` initializes panel `bounds` from `getDefaultFloatingTerminalBounds()` on every renderer mount.
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:349` only normalizes that in-memory initial state when the panel opens; it does not read or write durable position state.
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:1039` updates bounds while dragging, and `src/renderer/src/components/floating-terminal/FloatingTerminalResizeHandles.tsx:98` updates bounds while resizing, but both changes remain React state only.
- The toggle button already persists its own location through localStorage in `src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx:33`, so the inconsistency is isolated to the larger panel.
## Root Cause
Panel geometry is transient renderer state. Restarting Orca remounts `FloatingTerminalPanel`, so the panel recomputes from the current viewport instead of restoring the last user placement. The component also has no source tracking, so its legacy right-gap normalization cannot distinguish a default position from an intentional user drag.
## Non-Goals
- Do not change terminal, browser, or markdown tab persistence.
- Do not persist remote or SSH-specific state; panel geometry is local renderer chrome.
- Do not persist maximized panel bounds as the normal restored size.
- Do not add new settings UI or change design tokens.
## Design
1. Add floating panel bounds persistence helpers beside the existing panel bounds math:
- use a panel-specific versioned key, e.g. `orca-floating-terminal-panel-bounds-v1`;
- parse only finite `left`, `top`, `width`, and `height` numbers;
- distinguish `default` versus `user` bounds sources;
- expose a panel viewport-usability guard so saved user bounds are not clamped against Electron's transient zero-sized startup viewport;
- expand clamping to normalize both position and size. The current `clampFloatingTerminalBounds` only clamps `left` and `top`; persisted restores also need the resize-handle width/height caps.
2. Read and write storage defensively:
- `window` absence or `localStorage` get/set failures fall back to in-memory behavior for the session;
- malformed, partial, non-finite, or non-object JSON falls back to default bounds.
3. Initialize `FloatingTerminalPanel` once from persisted bounds when present, otherwise from the current default. Store the initial source in a ref, mirroring the toggle button pattern.
4. Reconcile bounds in `useLayoutEffect` before first paint and on viewport resize:
- default-sourced bounds re-anchor to the current bottom-right default;
- user-sourced bounds clamp into the visible viewport and persist the clamped result only after the viewport is usable;
- remove `normalizedInitialBoundsRef` and the `rightGap > 160` reset. That heuristic is superseded by source-aware reconciliation and would otherwise wipe valid saved left-side placements.
5. Replace direct `setBounds` calls for user-driven placement changes with a panel-local geometry updater:
- drag and resize pointer moves should clamp and update React state for smooth feedback, but stage the latest bounds in a ref;
- pointer up and pointer cancel commit the staged bounds to storage only after movement produced staged geometry. `localStorage.setItem` is synchronous, so do not write on every pointer move or after a titlebar click with no movement;
- the panel-level `onMouseUp` size capture must not convert a default-sourced panel into a user-sourced panel after an ordinary click. Commit measured dimensions only after a real geometry interaction, or when the panel was already user-sourced;
- resize handles should receive explicit preview/commit callbacks instead of the raw React setter, so resize, drag, and restore use the same persistence rules.
6. When entering maximized mode, store the pre-maximized bounds and source in memory only. Do not persist the maximized rectangle. While maximized, viewport resize should recompute maximized bounds without touching the stored normal bounds or source. On restore, return to the stored normal bounds, reconcile them through the same source-aware rules, and persist only if the restored source is `user`.
## Consistency Model
- Persistence is a restart seed, not live cross-window synchronization.
- Multiple renderer windows share the same localStorage key and therefore use last-writer-wins across restarts/reloads. Do not subscribe to `storage` events for live updates; another window or DevTools edit should not move an open panel mid-drag.
- External storage mutations are picked up on the next renderer mount or reload.
- The state is local renderer chrome. It must not go through worktree settings, terminal state, or SSH-backed runtime APIs.
## Edge Cases
- Malformed or partial localStorage JSON falls back to default bounds.
- Unavailable localStorage should not break the floating workspace; persistence simply becomes best-effort.
- Startup can briefly report a zero-sized renderer; saved user bounds must not be clamped to that unusable viewport.
- A saved position from a larger monitor should be clamped back on-screen on the current monitor.
- A saved size larger than the current viewport should shrink to the largest size that still leaves a small visible margin while respecting minimum panel dimensions.
- A viewport smaller than the minimum panel size should keep the panel at the minimum size and keep its top-left corner reachable.
- Maximized mode should not overwrite the normal saved size and position.
- Plain clicks inside a default-positioned panel should not make that default position sticky forever.
- Pointer cancellation should commit the last valid drag or resize bounds, matching pointer-up behavior.
- SSH-backed terminals keep using the same floating workspace UI; geometry persistence stays local and does not assume local command execution.
## Rollout
1. Extend `floating-terminal-panel-bounds.ts` with parse, source, viewport usability, resolve, and size-aware clamp helpers plus focused unit coverage.
2. Wire `FloatingTerminalPanel` to read, reconcile, and persist panel bounds through those helpers; remove the legacy right-gap normalization.
3. Update `FloatingTerminalResizeHandles` to use preview/commit callbacks so drag, resize, measured-size capture, and maximize restore share one persistence path.
4. Add component coverage for persisted startup, bad storage, zero-sized startup deferral, default re-anchoring, user clamping, and the "plain click does not persist default" case.
5. Run focused Vitest coverage, then `pnpm typecheck` and `pnpm lint`.
6. Verify in Electron by moving/resizing the panel, reloading or restarting the dev app, checking maximized restore, and confirming evidence screenshots are not committed.
+126
View File
@@ -0,0 +1,126 @@
# Linear Scope Selector
## Problem
- `src/renderer/src/components/TaskPage.tsx:4368` renders workspace selection, "open in Linear", and team selection as separate controls even though they define one Linear issue-list scope.
- `src/renderer/src/components/ui/team-multi-combobox.tsx:127` offers only "All teams" plus team rows. When a team is absent, there is no affordance that explains whether the key is team-limited, the user lacks private-team access, the team is archived/retired, or the backend failed to fetch every page.
- Settings and onboarding both say a workspace is connected, but the connect copy only points to personal API-key settings. It does not explain full-access keys, team-limited keys, member API-key restrictions, or private-team access.
- The backend already supports the core scope model: `workspaceId === 'all'` fans out across stored Linear clients, and team list calls are routed through the same runtime APIs used for local and SSH sessions. The gaps are renderer UX plus stale/incomplete data handling.
## Goal
Make Linear task scoping feel like one selector:
1. Replace the separate workspace dropdown and team dropdown in the Tasks toolbar with one Linear scope popover.
2. Preserve the existing state model: `selectedWorkspaceId` is one workspace id or `all`; `defaultLinearTeamSelection` remains a nullable global list where `null` means sticky-all and an array means an explicit subset of team IDs.
3. Add an "Add team access" path from the selector. It opens a reusable API-key dialog. Pasting a key for an already connected workspace replaces that workspace token because the workspace id is stable.
4. Make the copy accurate: one full-access personal API key for a Linear workspace can cover every team the key owner can access in that workspace; restricted/team-limited keys only expose permitted teams; private teams require the key owner to be a member or otherwise have access.
5. Link to the most specific Linear settings URL Orca can build from `organizationUrlKey`, with global fallbacks when the workspace is unknown or `all`.
## Non-goals
- Do not change Linear authentication storage, encryption, IPC/RPC method names, or the `LinearWorkspaceSelection` type.
- Do not switch Linear auth to OAuth.
- Do not create, join, unarchive, or grant access to Linear teams from Orca. "Add team access" means paste a key whose owner/scope can already see the team.
- Do not make team selection per-workspace in this change.
- Do not touch provider-generic review code.
## Required Fixes Before UI
1. Fix team-list completeness.
- `src/main/linear/teams.ts` currently calls `entry.client.teams()` once per workspace. The SDK query is paginated, so this can omit teams in larger workspaces.
- Fetch all pages before the selector relies on team absence as a meaningful signal. Use `teams({ first })` plus `fetchNext()` until `pageInfo.hasNextPage` is false, or an equivalent raw GraphQL query. Keep the existing `MAX_CONCURRENT` limiter and do not set `includeArchived`; retired teams should stay absent.
2. Invalidate caches when a Linear key is connected or replaced.
- Replacing a team-limited key with a full-access key must not leave the old `linearTeamCache` visible for up to `TEAM_CACHE_TTL`.
- On successful `connectLinear`, synchronously clear Linear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. Do this before publishing success, then await a forced status refresh before resolving so callers see the selected workspace and workspace list that came from the new key. The renderer `linearConnect` result is typed as `{ viewer }` only; do not rely on the main-process-only `workspace` field.
- Guard `fetchLinearIssue()` and `listLinearTeams()` cache writes with a request generation or entry identity check. Clearing their in-flight maps is not enough today: those promises write unconditionally after resolution and can repopulate old issue/team data after key replacement. `searchLinearIssues()` and `listLinearIssues()` already use in-flight entry identity checks for forced refreshes; keep them covered by the same generation if connect/disconnect clears their maps.
- Force a team refetch for the selected/connected workspace if the Linear Tasks view is open.
- Issue list caches also need invalidation because changing key scope can add or remove visible issues.
3. Handle auth-driven token removal explicitly.
- Backend calls can clear a workspace token on auth errors. In `all` workspace mode, `listTeams()`, `listIssues()`, and `searchIssues()` may return partial results instead of throwing.
- After any auth-driven clear, the renderer must get a forced Linear status refresh or a typed signal so disconnected workspaces disappear without waiting for a later manual check. The current array-returning issue/search/team APIs have no signal for `all`-mode partial auth clears, so either add an auth-cleared result envelope end-to-end or force a metadata-only status refresh after `all`-workspace issue/search/team reads. Do not set `linearStatus` to globally disconnected from a single request error; refresh status because multi-workspace failures may only remove one workspace.
- Strengthen `checkLinearConnection()` status equality. It currently compares workspace count, effective selected id, connected state, and viewer email. It misses active workspace id and workspace metadata changes when the count is unchanged. Compare a stable workspace signature including id, organization id/name/url key, display name, and email.
- When a forced status refresh observes a changed selected workspace or workspace signature, clear issue/search/team caches, in-flight issue/search/list/team requests, and metadata. `checkLinearConnection()` currently only updates `linearStatus`, so removed workspaces can leave stale rows in `all` caches.
4. Be honest about team filtering.
- Current Linear issue reads do not accept team IDs; team selection filters the already-limited issue/search result in the renderer after the `LINEAR_ITEM_LIMIT` page arrives. In `all` workspace mode, each workspace may fetch up to the limit, but the backend aggregates and trims back to `LINEAR_ITEM_LIMIT` before the renderer filters by team.
- This UI change should not imply exhaustive server-side team scope. Empty task states must say no fetched/current issues match the selected teams. True server-side team scoping needs a separate change to add team IDs to cache keys, runtime RPC params, and Linear GraphQL filters.
## Design
1. Add URL helpers in `src/shared/linear-links.ts`.
- `buildLinearPersonalApiKeySettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app/<slug>/settings/account/security` when a slug exists, otherwise `https://linear.app/settings/account/security`.
- `buildLinearWorkspaceApiSettingsUrl(organizationUrlKey?: string | null)` returns `https://linear.app/<slug>/settings/api` when a slug exists, otherwise `https://linear.app/settings/api`.
- Encode path segments consistently with `buildLinearTeamUrl`.
2. Add `LinearApiKeyDialog` in `src/renderer/src/components/linear-api-key-dialog.tsx`.
- Props: `open`, `onOpenChange`, optional `workspace`, optional `title`, optional `description`, optional `connectLabel`, optional `onConnected`, optional `overlayClassName`/`contentClassName`.
- Own input, connecting/error state, Enter-to-submit, and `connectLinear`. Fire `onConnected` only after `connectLinear` resolves with refreshed store status.
- Use org-specific URLs only when a single workspace context is known. For `all`, use the global fallback and copy that tells the user to choose the intended Linear workspace.
- Guidance:
- Create a Personal API key from Account > Security & Access.
- Prefer full access when Orca should show every team the account can access in that workspace.
- If member API keys are blocked, ask a workspace admin to allow them from workspace API settings. Do not imply the workspace API page creates the personal key.
- A key never grants teams the owner cannot access.
- Make storage copy runtime-aware. Local runtime keys use the local OS keychain when Electron encryption is available; SSH/remote-runtime keys are stored by that runtime, not necessarily on this machine.
3. Replace the Tasks toolbar Linear controls with `LinearScopeSelector`.
- Suggested file: `src/renderer/src/components/linear-scope-selector.tsx`.
- Keep state updates in `TaskPage`: workspaces, selected workspace id, teams, selected team ids, callbacks for workspace select, team select, select-all teams, open selected team URL, and open API-key dialog.
- Trigger label examples:
- One workspace + sticky-all: `All teams`
- One workspace + subset: `ENG, STA +1`
- Multiple workspaces + `all` + sticky-all: `All workspaces`
- Multiple workspaces + one workspace + sticky-all: `<Workspace name> / All teams`
- Multiple workspaces + subset: `<Workspace name> / ENG, STA +1` or `All workspaces / ENG +1`
- If the active scope is `all` and selected team keys are duplicated or span multiple workspaces, prefer `All workspaces / 2 teams` over an ambiguous key list.
- Popover content:
- Search filters team rows by team name, team key, and workspace name.
- Workspace section appears only when more than one workspace is connected. It includes "All workspaces" and each workspace.
- Selecting a workspace must call the existing `selectLinearWorkspace` flow, clear selected issue/list/error/loading state the same way `TaskPage` does now, and must not mutate `defaultLinearTeamSelection`.
- Team section includes "All teams" and selectable team rows. "All teams" persists `defaultLinearTeamSelection: null`; explicit subsets persist an array. Normalize a selection equal to all available teams back to `null`, even if the user selected teams one by one. Never persist an empty array; keep the last team selected or save `null`.
- Team rows show name, key, and workspace name when active workspace is `all` or when multiple workspaces exist.
- Footer has "Add team access" with a key/link icon. Close the popover before opening the API-key dialog.
- Keep the external-link button as an icon-only sibling. It remains an action, and should stay enabled only when exactly one selected team has a URL.
4. Keep team selection reconciliation, but document its limits.
- `reconcileLinearTeamSelection()` preserves sticky-all and drops stale team IDs.
- Because the saved team subset is global, switching workspaces may temporarily select all teams in the new workspace while preserving the old saved subset for later. Do not introduce per-workspace persistence here.
5. Update Settings and onboarding to use `LinearApiKeyDialog`.
- Rename "Add workspace" to "Add Linear access" when disconnected and "Update access" or "Add workspace access" when connected.
- Replace "Each workspace uses its own locally stored API key" with copy that says each connected Linear workspace has one key stored by the active runtime; a full-access key can cover all visible teams in that workspace, and a restricted key can be replaced any time.
- Keep existing per-workspace Test and Disconnect controls.
## Edge Cases
- No Linear workspaces connected: no Tasks selector; Settings/onboarding still show Connect.
- One workspace connected: hide the workspace section, but keep "Add team access" visible.
- Multiple workspaces + `all`: team keys/names can repeat across workspaces; always show workspace names in rows.
- Empty team result: show an empty state plus footer action. Do not claim the key is the only possible cause; absence can also come from private-team membership, archived teams, permissions, or a fetch failure.
- Empty issue result after team selection: say no fetched/current issues match the selected teams. Do not imply Linear was queried exhaustively for those teams.
- Legacy or missing `organizationUrlKey`: use global Linear settings URLs and keep the paste flow usable.
- Replacement key for an existing workspace: select that workspace, invalidate caches, refresh status, and refetch teams immediately.
- Auth errors: a backend call may clear a token. The selector must tolerate stale rows during the refresh, then drop disconnected workspaces when status updates.
- Multi-window/external mutations: there is no real-time renderer broadcast. Force `checkLinearConnection(true)` after connect/disconnect/test flows and when opening the selector if stale status would be visible. Forced refresh must still update state and invalidate caches when workspace metadata changed but the workspace count did not. A same-workspace key replacement with identical viewer/workspace metadata is invisible to status today; do not claim that case is handled unless this change adds a non-secret credential revision or a renderer broadcast.
- Remote runtime/SSH: continue routing through `connectLinear`, `selectLinearWorkspace`, `listLinearTeams`, and runtime RPC. Do not call local Electron APIs directly except for opening Linear URLs.
- Accessibility: trigger is a combobox-style button, rows are keyboard-selectable, icon buttons have labels/tooltips, and the dialog traps focus.
## Tests
- `src/shared/linear-links.test.ts`: URL helpers, fallback behavior, and path encoding.
- `src/main/linear/teams.test.ts`: paginated team fetching and `all` workspace aggregation.
- `src/renderer/src/store/slices/linear.test.ts`: successful connect invalidates issue/search/team caches, drops stale in-flight writes, awaits refreshed status, and does not mark all Linear disconnected for one workspace auth failure.
- `src/renderer/src/store/slices/linear.test.ts`: forced status refresh detects changed workspace ids/metadata with the same workspace count and invalidates Linear caches when the scope signature changes.
- Component-independent label and selection-persistence helper tests for `LinearScopeSelector`, including selecting every visible team persisting sticky-all (`null`) rather than a frozen full-team array.
- Avoid brittle popover DOM tests unless matching an existing selector test pattern.
## Rollout
1. Fix backend/store correctness: team pagination, connect invalidation, and auth-clear status refresh.
2. Add Linear settings URL helpers and tests.
3. Add `LinearApiKeyDialog`; migrate Settings, onboarding, and the existing Tasks connect dialog.
4. Add `LinearScopeSelector` and replace only the Linear toolbar selector block in `TaskPage`.
5. Run focused Linear tests, then `pnpm typecheck` and `pnpm lint`.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Ejecuta Claude Code, OpenClaude, Codex, Grok, Antigravity u OpenCode en paralelo entre repositorios — cada uno en su propio worktree, todo administrado desde un solo lugar.<br/>
Disponible para <strong>macOS, Windows y Linux</strong>.
</p>
@@ -32,6 +32,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca es compatible con cualquier agente CLI (_no solo los de esta lista_).
## Características
- **Sin login** — Usa tu propia suscripción de Claude Code, Codex, Grok o Antigravity.
- **Sin login** — Usa tu propia suscripción de Claude Code, OpenClaude, Codex, Grok o Antigravity.
- **Nativo con worktrees** — Cada feature vive en su propio worktree. Nada de stash ni malabares entre ramas. Crea y cambia al instante.
- **Terminales multi-agente** — Ejecuta varios agentes de IA en paralelo en pestañas y paneles. Mira de un vistazo cuáles están activos.
- **Control de versiones integrado** — Revisa los diffs generados por IA, haz ediciones rápidas y haz commit sin salir de Orca.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
Claude Code、OpenClaude、Codex、Grok、Antigravity、OpenCode をリポジトリをまたいで並行実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。<br/>
<strong>macOS、Windows、Linux</strong> で利用できます。
</p>
@@ -32,6 +32,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca は任意の CLI エージェントに対応しています(_このリス
## 機能
- **ログイン不要** — お持ちの Claude Code、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ログイン不要** — お持ちの Claude Code、OpenClaude、Codex、Grok、Antigravity サブスクリプションをそのまま利用できます。
- **ワークツリーネイティブ** — 各機能は専用のワークツリーで開発できます。スタッシュやブランチ切り替えに悩まず、すぐに作成して切り替えられます。
- **マルチエージェントターミナル** — 複数の AI エージェントをタブやペインで並行実行できます。どれがアクティブかを一目で確認できます。
- **組み込みソース管理** — AI が生成した Diff を確認し、すばやく編集して、Orca から離れずにコミットできます。
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
Claude Code, OpenClaude, Codex, Grok, Antigravity, OpenCode를 여러 리포지토리에서 나란히 실행하세요. 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.<br/>
<strong>macOS, Windows, Linux</strong>에서 사용할 수 있습니다.
</p>
@@ -32,6 +32,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca는 모든 CLI 에이전트를 지원합니다(_아래 목록에만 한정
## 기능
- **로그인 불필요** — 보유한 Claude Code, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **로그인 불필요** — 보유한 Claude Code, OpenClaude, Codex, Grok 또는 Antigravity 구독을 그대로 사용하세요.
- **Worktree 네이티브** — 모든 기능은 자체 worktree를 가집니다. stash나 브랜치 전환에 얽매이지 않고 즉시 만들고 전환할 수 있습니다.
- **멀티 에이전트 터미널** — 여러 AI 에이전트를 탭과 패널에서 나란히 실행하세요. 어떤 에이전트가 활성 상태인지 한눈에 볼 수 있습니다.
- **내장 소스 관리** — AI가 생성한 diff를 검토하고, 빠르게 수정하고, Orca를 떠나지 않고 커밋할 수 있습니다.
+3 -2
View File
@@ -14,7 +14,7 @@
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
跨仓库并排运行 Claude Code、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
跨仓库并排运行 Claude Code、OpenClaude、Codex、Grok、Antigravity 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。<br/>
支持 <strong>macOS、Windows 和 Linux</strong>。
</p>
@@ -32,6 +32,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
<p>
<a href="https://docs.anthropic.com/claude/docs/claude-code"><kbd><img src="../assets/claude-logo.svg" width="16" valign="middle" /> Claude Code</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://github.com/openai/codex"><kbd><img src="https://www.google.com/s2/favicons?domain=openai.com&sz=64" width="16" valign="middle" /> Codex</kbd></a> &nbsp;
<a href="https://x.ai/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=x.ai&sz=64" width="16" valign="middle" /> Grok</kbd></a> &nbsp;
<a href="https://github.com/google-gemini/gemini-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=gemini.google.com&sz=64" width="16" valign="middle" /> Gemini</kbd></a> &nbsp;
@@ -64,7 +65,7 @@ Orca 支持任何 CLI 智能体(_不仅限于以下列表_)。
## 特性
- **无需登录** — 直接使用你自己的 Claude Code、Codex、Grok 或 Antigravity 订阅。
- **无需登录** — 直接使用你自己的 Claude Code、OpenClaude、Codex、Grok 或 Antigravity 订阅。
- **原生 worktree 工作流** — 每个功能都有自己的 worktree。无需 stash,也不用来回切分支。立即创建,快速切换。
- **多智能体终端** — 在标签页和面板中并排运行多个 AI 智能体。一眼就能看到哪些正在活跃。
- **内置源码管理** — 查看 AI 生成的 diff,快速编辑,并且无需离开 Orca 就能提交。
@@ -43,17 +43,14 @@ The behavior must work from both the titlebar tab strip and split-group tab stri
`TabBar` owns input text, pending state, focus, and dropdown close behavior. It does not create browser tabs or files. `groupId` should be `groupId ?? worktreeId`, matching the existing `resolvedGroupId` fallback.
3. Add a small hook/helper pair instead of duplicating Quick Open logic:
- `useTabEntryFileList` loads the file list when the menu opens, using the same inputs as `QuickOpen`: active worktree path, `getConnectionId(worktreeId)`, nested worktree exclusions, and active SSH target status. It cancels stale requests on close or key changes.
- A pure classifier/opening helper accepts the query, file-list snapshot, load/error state, worktree metadata, runtime context, and target group.
4. Wire the callback in both owners:
- `Terminal.tsx` titlebar fallback resolves the current active worktree and target group the same way `handleNewTab` / `handleNewBrowserTab` do.
- `useTabGroupWorkspaceModel` passes its explicit `worktreeId` and `groupId`. Do not rely on ambient `activeGroupIdByWorktree`; split-group `+` can be invoked from an unfocused group.
5. Classify submissions in this order:
- Empty after trim: reject inline.
- Explicit URL: accept only `http://` and `https://` URLs with a parseable host.
- Existing file: once the file-list snapshot is ready, normalize query separators for matching, prefer exact relative-path match, then exact basename match, then `rankQuickOpenFiles`. Before opening, `statRuntimePath` the matched absolute path and reject directories/stale missing matches instead of blindly opening stale list entries.
@@ -61,14 +58,12 @@ The behavior must work from both the titlebar tab strip and split-group tab stri
- New file: only after file listing has completed successfully with no existing-file match and no host-like URL match. Treat the query as a relative worktree path. If listing fails, allow explicit `http://` / `https://` URLs only; keep bare host-like inputs blocked because they cannot be disambiguated from files.
6. Validate new file paths before joining:
- Reject POSIX absolute paths, Windows drive paths, UNC paths, `~`, empty path, trailing slash, control characters, `.` / `..` segments, and empty raw segments such as `a//b`.
- Normalize `\` to `/` only after absolute-path checks, then run segment validation on the normalized path too so traversal like `a\..\b` is still rejected.
- Build the absolute path with `joinPath(worktreePath, relativePath)`, then create with `createRuntimePath(context, absolutePath, 'file')`.
- On `EEXIST` / "exists", immediately `statRuntimePath`; if it is now a file, open it. If it is a directory, show an error. This handles another window/process winning the create race.
7. Open actions:
- URL: in paired web clients, call `createWebRuntimeSessionBrowserTab({ worktreeId, url, targetGroupId: groupId })`; otherwise call `createBrowserTab(worktreeId, url, { activate: true, targetGroupId: groupId, title: url })`. Do not use `openNewBrowserTabInActiveWorkspace`; it only opens the default URL.
- Existing/new file: call `openFile(fileInfo, { preview: false, targetGroupId: groupId })` with `language: detectLanguage(relativePath)`. Include the active runtime environment owner as `runtimeEnvironmentId` through the normal `openFile` fallback; do not suppress runtime ownership.
@@ -21,37 +21,37 @@ Do not upload this state as broad analytics. Product analytics should continue t
## Feature Catalog
| Feature | Interaction ID | Record when | Education use |
| --- | --- | --- | --- |
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. |
| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. |
| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. |
| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. |
| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. |
| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. |
| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. |
| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. |
| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. |
| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. |
| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. |
| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. |
| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. |
| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. |
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. |
| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. |
| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. |
| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. |
| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. |
| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
| Feature | Interaction ID | Record when | Education use |
| ------------------------------ | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| Review notes to agent | `review-notes` | A diff or markdown review note is added, or review notes are marked sent to an agent. | Suppress or target a future review-notes tour/tip about adding line notes and sending focused feedback back to an agent. |
| AI commit generation | `ai-commit-generation` | AI commit-message generation is enabled or an AI commit message is generated. | Suppress future education about AI commit generation. No contextual tour is planned for this branch. |
| AI PR generation | `ai-pr-generation` | AI pull-request title/body/draft fields are generated. | Suppress future education about AI PR generation. No contextual tour is planned for this branch. |
| Floating Workspace | `floating-workspace` | The floating workspace opens, is enabled, or is configured. | Suppress future tips about the global terminal/browser/markdown workspace. No contextual tour is planned for this branch. |
| Quick Commands | `quick-commands` | A terminal quick command is created or edited. | Suppress future tips about saved terminal commands. No contextual tour is planned for this branch. |
| Computer Use setup | `computer-use-setup` | Computer Use is selected in onboarding, a permission setup is opened, or the skill setup terminal opens. | Suppress setup-focused tips once the user has started setup. |
| Computer Use | `computer-use` | A successful `computer.*` runtime method other than capability probing is handled. | Suppress future usage tips once an agent has actually invoked Computer Use. |
| Mobile pairing | `mobile-pairing` | Mobile is enabled or a mobile pairing QR/code is generated. | Suppress future mobile-pairing tips. No contextual tour is planned for this branch. |
| Browser element grab | `browser-grab` | Browser grab mode is started, an element is copied, or an element screenshot is copied. | Suppress future tips about grabbing page context once the user has used the element picker. |
| Browser annotations | `browser-annotations` | Browser annotation mode is started, an annotation is added, browser annotations are copied, or browser annotations are cleared. | Suppress future tips about annotating local pages and sending concrete UI feedback to an agent. |
| Cookie import | `cookie-import` | Browser cookies are imported from another browser or file, imported through runtime browser profile methods, or default imported cookies are cleared. | Suppress future cookie-import education without conflating cookie setup with Agent Browser Use itself. |
| Workspace board actions | `workspace-board-actions` | A card/status action, lane configuration, density change, pin drop, or board drag action is used. | Keep the existing workspace-board tour, and avoid repeating deeper board-action education after real use. |
| Automation creation | `automation-created` | A local automation or external Hermes cron is created. | Keep the existing Automations tour, and suppress creation-focused education after the user creates one. |
| Automation run | `automation-run` | A local or external automation run is manually queued. | Keep the existing Automations tour, and suppress run/inspection education after the user queues a run. |
| Resource Manager | `resource-manager` | The Resource Manager status-bar popover is opened, or its status-bar visibility is toggled in Appearance/status-bar controls. | Suppress future tips about CPU, memory, session, daemon, and workspace disk-scan controls after the user has found the manager. |
| Workspace cleanup / disk space | `workspace-cleanup` | The Space page opens, a workspace disk scan starts/cancels, or cleanup removes scanned workspace rows. | Suppress future tips about scanning workspace disk usage and reclaiming old workspace storage. |
| Ports | `ports` | The Ports popover opens, the Ports status-bar item is configured, external ports are expanded, or a port is opened/copied/stopped. | Suppress future tips about discovering and acting on workspace ports. |
| SSH | `ssh` | SSH status opens, SSH status-bar visibility changes, a target is added/imported/tested, or an SSH target is connected/disconnected. | Suppress future SSH setup/status tips after the user has interacted with remote target controls. |
| Provider usage tracking | `usage-tracking` | Stats & Usage is opened, Claude/Codex/OpenCode usage analytics are enabled, provider usage details are opened from the status bar, Gemini usage/OAuth is configured, or provider usage status-bar toggles are changed. | Suppress future tips about where to find token/rate-limit/usage tracking for Claude, Codex, Gemini, OpenCode, and related providers. |
| Claude account switching | `claude-account-switching` | A Claude managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about using multiple Claude accounts once the user has started account management. |
| Codex account switching | `codex-account-switching` | A Codex managed account is added, selected, reauthenticated, removed, or selected from the status bar. | Suppress future tips about Codex account switching and restart follow-up flows. |
| Workspace tabs | `terminal-tabs` | A workspace tab is created, reordered, renamed, recolored, pinned/unpinned, moved, or closed. | Suppress future tips about tab-level workspace organization. These are real workspace tabs, not workspace board cards. |
| Split panes | `terminal-panes` | A split group is created from the pane shortcut/menu, a split is resized, or panes are merged. | Suppress future tips about pane-level split workflows. |
| Tab splits | `tab-splits` | A workspace tab is moved into another pane or split into a new pane from a tab split/drop action. | Suppress future tips about tab-level split workflows separately from pane creation/resizing. |
| Agent Browser Use setup | `agent-browser-setup` | Browser Use is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Browser Use setup. |
| Agent Browser Use | `agent-browser-use` | A successful non-profile `browser.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually driven Orca's browser. |
| Agent Orchestration setup | `agent-orchestration-setup` | Orchestration is selected in onboarding, enabled in settings, or its setup terminal opens. | Suppress future setup tips once the user has started Orchestration setup. |
| Agent Orchestration | `agent-orchestration` | A successful `orchestration.*` runtime method is handled. | Suppress future usage tips once an agent/runtime has actually used orchestration. |
| Notifications | `notifications` | Notifications are enabled in onboarding/settings or a test notification is sent. | Suppress future notification setup tips. |
## Surface-Level Features
@@ -1,51 +0,0 @@
# Memory Leak Audit Pass 2
Started: 2026-05-29 PDT
Branch: `nwparker/mem-leak-audit-pass-2`, based on latest `origin/main` after the first leak-fix PRs were merged. The held mobile notification PR #2887 is tracked separately and is not duplicated here.
Objective: re-scan the entire Orca codebase for missed listener, timer, observer, worker, socket, watcher, subscription, and disposable leaks; open scoped PRs for confirmed new misses.
## Codebase Inventory
Tracked code files, from `git ls-files`:
| Bucket | Files | Status |
| ----------------- | ----: | ------- |
| `src/renderer` | 1575 | Checked |
| `src/main` | 856 | Checked |
| `src/shared` | 245 | Checked |
| `mobile/src` | 100 | Checked |
| `tests/e2e` | 72 | Checked |
| `src/relay` | 65 | Checked |
| `src/cli` | 65 | Checked |
| `config/scripts` | 33 | Checked |
| `mobile/app` | 16 | Checked |
| `mobile/packages` | 12 | Checked |
| `other-code` | 10 | Checked |
| `native` | 6 | Checked |
| `src/preload` | 6 | Checked |
| Total | 3061 | Checked |
## Scan Log
- 2026-05-29: Created fresh audit branch from latest `origin/main`; confirmed held mobile PR #2887 remains open and is not duplicated.
- 2026-05-29: Counted 3532 broad risk-pattern hits across `src`, `mobile`, `tests`, `config`, and `native`.
- 2026-05-29: Re-ran broad listener/timer/observer/worker heuristics after fixes. Remaining raw candidates are expected lifetime-bound listeners: React Native subscriptions with `.remove()`, WebView document-lifetime scripts, preload document-level file-drop handlers, pane DOM listeners whose pane elements are disposed, and singleton window focus cache invalidation.
- 2026-05-29: Checked React `useEffect`/`useLayoutEffect` candidates with risky APIs. Remaining hits are either direct cleanup returns, one-frame focus `requestAnimationFrame` calls, or effects whose teardown is handled by adjacent owner lifecycle.
- 2026-05-29: Checked main/runtime/browser/SSH/daemon/rate-limit/relay/shared/CLI/test/native buckets for unpaired timers, sockets, startup listeners, file watches, and abort listeners.
- 2026-05-29: Opened and merged low-risk follow-up PRs #3043, #3045, #3046, #3047, and #3049 for the confirmed misses.
## Findings
- `src/main/speech/model-manager.ts`: cancelling a stalled speech-model download only flipped an `aborted` flag; if HTTPS never delivered another chunk, the request and promise could stay alive indefinitely. Fixed by wiring `AbortController` through the download request and redirect chain. Risk: low. PR: #3043, merged.
- `src/main/runtime/rpc/unix-socket-transport.ts`: a long-poll request keepalive interval was only cleared on reply; if the Unix socket closed first and the handler never replied after abort, the interval retained the socket/request closure. Fixed by making connection close run per-dispatch cleanup. Risk: low. PR: #3045, merged.
- `src/relay/dispatcher.ts`: relay disposal stopped keepalives but did not abort in-flight request contexts, so stale SSH-side scans/watchers could continue after the dispatcher could no longer send responses. Fixed by aborting all tracked request controllers on dispose. Risk: low. PR: #3046, merged.
- `src/preload/index.ts`: notification sound playback registered `ended`/`error` listeners for each play, but a failed `audio.play()` or replaced cached audio could leave those listeners attached. Fixed by tracking and running explicit playback listener cleanup. Risk: low. PR: #3047, merged.
- `src/main/browser/cdp-ws-proxy.ts`: CDP proxy startup errors after debugger attach rejected without releasing the debugger/server objects. Fixed the startup failure path to close partial servers and detach the debugger. Risk: low. PR: #3049, merged.
## Validation
- `pnpm vitest run --config config/vitest.config.ts src/main/browser/cdp-ws-proxy.test.ts src/main/speech/model-manager.test.ts src/main/runtime/rpc/unix-socket-transport.test.ts src/relay/dispatcher.test.ts`
- `pnpm run typecheck:node`
- `pnpm exec oxlint src/main/browser/cdp-ws-proxy.ts src/main/runtime/rpc/unix-socket-transport.ts src/main/runtime/rpc/unix-socket-transport.test.ts src/relay/client-request-aborts.ts src/relay/dispatcher.ts src/relay/dispatcher.test.ts src/preload/index.ts src/main/speech/model-manager.ts src/main/speech/model-manager.test.ts`
-74
View File
@@ -1,74 +0,0 @@
# Memory Leak Audit
Started: 2026-05-26 18:36 PDT
Objective: scan the entire Orca codebase for memory leaks, fix confirmed leaks, and keep durable coverage notes until the repository-wide scan is complete.
## Codebase Inventory
Tracked code files, from `git ls-files`:
| Bucket | Files | Status |
| ----------------- | ----: | ------------------------------------------- |
| `src/renderer` | 1534 | Checked |
| `src/main` | 838 | Checked |
| `src/shared` | 220 | Checked |
| `mobile/src` | 94 | Checked |
| `tests/e2e` | 71 | Checked |
| `src/relay` | 65 | Checked |
| `src/cli` | 64 | Checked |
| `config/scripts` | 27 | Checked |
| `mobile/app` | 16 | Checked |
| `other-code` | 13 | Checked |
| `mobile/packages` | 12 | Checked |
| `native` | 6 | Checked |
| `src/preload` | 6 | Checked by IPC subscription heuristic |
| Total | 2966 | Repository-wide risk-pattern scan completed |
## Scan Log
- 2026-05-26: Loaded `$memory-leak-audit` checklist and built an initial inventory from `git ls-files`.
- 2026-05-26: Ran a broad listener/timer/observer search across `src`, `mobile`, `tests`, and `config`.
- 2026-05-26: Ran a React `useEffect` cleanup heuristic over 1845 TS/TSX files. Manual follow-up identified one confirmed issue in `src/renderer/src/components/editor/PdfViewer.tsx`: `EventBus.on('scalechanging', ...)` had no matching `off`.
- 2026-05-26: Ran a preload IPC heuristic. `src/preload/index.ts` and `src/preload/runtime-environment-subscriptions.ts` had apparent cleanup for their `ipcRenderer.on` subscriptions.
- 2026-05-26: Ran an `ipcMain.on` cleanup heuristic over `src/main`. The only unpaired hit was `src/main/ipc/session.ts`, which is a process-lifetime registration via `registerSessionHandlers`.
- 2026-05-26: Manually reviewed main-process interval/service candidates. Confirmed `src/main/speech/stt-service.ts` retained a timed-out, terminated speech worker in service state until idle teardown.
- 2026-05-26: Ran a Promise/EventEmitter heuristic over main, relay, and CLI code. Confirmed `src/main/ssh/ssh-connection.ts` left ssh2 startup listeners attached after a successful long-lived connection.
- 2026-05-26: Confirmed `src/main/browser/cdp-ws-proxy.ts` retained its startup HTTP server `error` listener after successful bind.
- 2026-05-26: Continued Promise/EventEmitter follow-up in daemon startup paths. Confirmed startup listeners were retained after successful bind/readiness in `src/main/daemon/daemon-server.ts`, `src/main/daemon/daemon-init.ts`, and `src/main/daemon/production-launcher.ts`.
- 2026-05-26: Reviewed BrowserWindow/session listeners. Confirmed `src/main/window/attach-main-window-services.ts` stacked a persistent browser-session `will-download` listener on every main-window services attach.
- 2026-05-26: Reviewed browser-session profile lifecycle. Confirmed deleted isolated/imported profiles left partition policy callbacks/listeners and configured-partition bookkeeping behind in `src/main/browser/browser-session-registry.ts`.
- 2026-05-26: Reviewed mobile RPC subscriptions. Confirmed `mobile/src/notifications/mobile-notifications.ts` retained a local streaming callback when notification subscription cleanup ran before the desktop returned `ready`.
- 2026-05-26: Reviewed speech model extraction timers. Confirmed `src/main/speech/model-manager.ts` left the abort polling interval alive when extraction abort/timeout rejected before the child emitted `close` or `error`.
- 2026-05-26: Manually followed up renderer high-risk surfaces from the broad scan: pane manager, browser webviews, terminal pane lifecycle, editor integrations, sidebar/global document listeners, observers, and shared clocks. Remaining hits had cleanup, were process-lifetime globals, or were element-owned listeners that are collected with their DOM nodes.
- 2026-05-26: Manually followed up main-process long-lived services from the broad scan: runtime RPC, SSH services, speech worker/model manager, rate-limit polling, filesystem watchers, BrowserWindow-scoped IPC/listeners, browser-session handlers, daemon startup, and relay-backed flows.
- 2026-05-26: Checked mobile React Native subscriptions, WebView injected listeners, RPC subscription teardown, repeated polling intervals, and app/session screens. Confirmed one notifications subscription cleanup issue and no additional unpaired repeated-mount listener leaks.
- 2026-05-26: Checked `src/shared`, `src/relay`, `src/cli`, `config/scripts`, `tests/e2e`, `mobile/packages`, and native computer-use runtimes for listener/timer/socket/watch patterns. Follow-up found existing cleanup or process/test-lifetime ownership.
## Confirmed Fixes
- `src/renderer/src/components/editor/PdfViewer.tsx`: unregister the pdf.js `scalechanging` listener on cleanup to avoid retaining stale component state after repeated PDF opens.
- `src/main/speech/stt-service.ts`: when dictation stop times out, remove worker listeners, terminate the worker, and clear the warm-worker slot so later dictation cannot retain or reuse a dead worker.
- `src/main/ssh/ssh-connection.ts`: remove ssh2 startup `ready`/`error` listeners once the connection resolves or rejects; steady-state disconnect listeners remain scoped to the live connection.
- `src/main/browser/cdp-ws-proxy.ts`: remove the startup server `error` listener after the proxy binds so the resolved start promise closure is not retained for the proxy lifetime.
- `src/main/daemon/daemon-server.ts`: remove the startup server `error` listener after the daemon socket binds.
- `src/main/daemon/daemon-init.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure.
- `src/main/daemon/production-launcher.ts`: remove detached daemon child-process startup listeners after IPC readiness or startup failure.
- `src/main/window/attach-main-window-services.ts`: replace the named persistent browser-session `will-download` handler before registering it so window re-attach cannot stack handlers.
- `src/main/browser/browser-session-registry.ts`: clear deleted profile partition permission/display-media callbacks, remove the download listener, and drop configured-partition bookkeeping.
- `mobile/src/notifications/mobile-notifications.ts`: always drop the local notification stream on cleanup, even if the desktop has not returned a subscription id yet.
- `src/main/speech/model-manager.ts`: clear extraction timeout/abort pollers and remove child listeners directly on abort/timeout rejection, instead of depending on a later child `close`.
## Validation
- Passed: `pnpm vitest run --config config/vitest.config.ts src/main/speech/stt-service.test.ts src/main/speech/model-manager.test.ts src/main/ssh/ssh-connection.test.ts src/main/browser/cdp-ws-proxy.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts src/main/daemon/daemon-init.test.ts src/main/window/attach-main-window-services.test.ts src/main/browser/browser-session-registry.test.ts src/main/browser/browser-session-registry.persistence.test.ts` (10 files, 128 tests).
- Passed: `pnpm run typecheck:node`.
- Passed: `pnpm run typecheck:web`.
- Passed: focused `pnpm exec oxlint` over changed TypeScript files.
- Passed after final import cleanup: `pnpm vitest run --config config/vitest.config.ts src/main/daemon/daemon-server.test.ts src/main/daemon/production-launcher.test.ts`.
- Passed: `git diff --check`.
- Attempted: `./node_modules/.bin/vitest run mobile/src/notifications/mobile-notifications.test.ts`; blocked in this workspace because mobile dependencies are not installed (`expo/tsconfig.base` cannot be resolved). The mobile test file is included for the mobile package test environment.
## Residual Risk
- This was a repository-wide static/manual memory-leak audit of listener, timer, observer, worker, socket, watcher, subscription, and resource-pool patterns. It does not replace heap-snapshot profiling under a long interactive session, but no further confirmed leaks were found in the scanned patterns.
+89 -89
View File
@@ -15,11 +15,11 @@ This file records operational availability. Event contracts still live in `src/s
## Boundary Fields
| Field | Meaning |
| --- | --- |
| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. |
| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. |
| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. |
| Field | Meaning |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------- |
| `code_merged_at_utc` | When the implementation reached `main`. Useful for source history only. |
| `first_released_at_utc` | First release commit/build that contains the code. This is the earliest possible user exposure. |
| `first_seen_at_utc` | First observed PostHog row for the event/property. This proves at least one event flowed. |
| `dashboard_ready_at_utc` | Earliest lower bound for saved dashboards after all required signals have first-valid rows and pass any validation window. |
First-valid means the released app version has been observed, the required event/property fields are populated, joins used by the dashboard are available, and any validation window listed in the rollout entry has passed.
@@ -48,32 +48,32 @@ D1+/D3+/D7+ retention means the user fired `app_opened` at least once after 24/7
Scope: `nth_repo_added` on repo/activation/retention events. Current schemas declare it on `app_opened`, `repo_added`, `add_repo_setup_step_action`, `add_repo_existing_workspaces_detected`, `workspace_created`, `workspace_create_failed`, `setup_script_prompt_shown`, `setup_script_prompt_action`, `agent_started`, `agent_prompt_sent`, and `agent_error`. The original rollout covered `app_opened`, `repo_added`, `add_repo_setup_step_action`, `workspace_created`, `workspace_create_failed`, `agent_started`, and `agent_error`; later events have their own first-seen timestamps below.
| Field | Value |
| --- | --- |
| PR | `#1591` |
| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` |
| `code_merged_at_utc` | `2026-05-08T17:50:44Z` |
| First release | `v1.3.42-rc.1` |
| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` |
| `first_released_at_utc` | `2026-05-08T17:56:35Z` |
| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` |
| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. |
| Field | Value |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PR | `#1591` |
| Merge commit | `002e2acc38cad262d694049cc86c0962ffd381ce` |
| `code_merged_at_utc` | `2026-05-08T17:50:44Z` |
| First release | `v1.3.42-rc.1` |
| First release commit | `b6dab85aaa0e9b600ec93846824ba815542a1e1e` |
| `first_released_at_utc` | `2026-05-08T17:56:35Z` |
| Earliest `first_seen_at_utc` | `2026-05-08T18:40:00.354Z` on `app_opened` |
| `dashboard_ready_at_utc` | Event-dependent. Use `2026-05-08T18:40:00.354Z` for general `app_opened` repo-count cuts; use `2026-05-08T20:01:06.364Z` or later for first-repo activation cuts requiring `repo_added`. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
| Event | First seen with `nth_repo_added` |
| --- | --- |
| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) |
| `workspace_created` | `2026-05-08T19:15:06.913Z` |
| `agent_started` | `2026-05-08T19:15:07.130Z` |
| `agent_prompt_sent` | `TBD` |
| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) |
| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` |
| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` |
| `agent_error` | `2026-05-13T14:13:16.096Z` |
| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` |
| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` |
| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` |
| Event | First seen with `nth_repo_added` |
| --------------------------------------- | ------------------------------------------ |
| `app_opened` | `2026-05-08T18:40:00.354Z` (`1.3.42-rc.1`) |
| `workspace_created` | `2026-05-08T19:15:06.913Z` |
| `agent_started` | `2026-05-08T19:15:07.130Z` |
| `agent_prompt_sent` | `TBD` |
| `repo_added` | `2026-05-08T20:01:06.364Z` (`1.3.42`) |
| `add_repo_setup_step_action` | `2026-05-08T20:01:20.897Z` |
| `workspace_create_failed` | `2026-05-08T23:04:32.315Z` |
| `agent_error` | `2026-05-13T14:13:16.096Z` |
| `add_repo_existing_workspaces_detected` | `2026-05-20T05:33:20.160Z` |
| `setup_script_prompt_shown` | `2026-05-21T04:09:04.231Z` |
| `setup_script_prompt_action` | `2026-05-21T04:10:31.616Z` |
Dashboard caveats:
@@ -87,26 +87,26 @@ Scope: `cohort` on onboarding events. Current schemas declare it on `onboarding_
The original `#1608` rollout covered `onboarding_started`, `onboarding_step_viewed`, `onboarding_step_completed`, `onboarding_step_skipped`, `onboarding_step4_path_clicked`, `onboarding_step4_path_failed`, `onboarding_completed`, `onboarding_dismissed`, `onboarding_agent_picked`, and onboarding import events. Later onboarding events joined the roster by declaring `cohort` in their schemas.
| Field | Value |
| --- | --- |
| PR | `#1608` |
| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` |
| `code_merged_at_utc` | `2026-05-09T02:57:18Z` |
| First release | `v1.3.44` |
| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` |
| `first_released_at_utc` | `2026-05-09T08:05:39Z` |
| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) |
| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. |
| Field | Value |
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| PR | `#1608` |
| Merge commit | `318e2b4c2c501b50280dc9e5b11ed34bd899bdd9` |
| `code_merged_at_utc` | `2026-05-09T02:57:18Z` |
| First release | `v1.3.44` |
| First release commit | `e602decf1d35dc54c84eea516fc2b9afdc065cce` |
| `first_released_at_utc` | `2026-05-09T08:05:39Z` |
| Earliest `first_seen_at_utc` | `2026-05-09T09:54:03.038Z` on `onboarding_started { cohort: 'fresh_install' }` (`1.3.45`) |
| `dashboard_ready_at_utc` | `2026-05-09T09:54:03.038Z` for fresh-install onboarding-start cohorts; use later event-specific first-seen timestamps where a tile requires another event. |
Later onboarding cohort events:
| Event | PR | First release | `first_seen_at_utc` |
| --- | --- | --- | --- |
| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) |
| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) |
| Event | PR | First release | `first_seen_at_utc` |
| ---------------------------------------------- | ---------------------------------------------------- | --------------------------------------- | ----------------------------------------- |
| `onboarding_feature_setup_run` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.248Z` (`1.4.2-rc.4`) |
| `onboarding_feature_setup_terminal_opened` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T05:55:37.261Z` (`1.4.2-rc.4`) |
| `onboarding_feature_setup_terminal_interacted` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-15T11:47:28.022Z` (`1.4.2-rc.6`) |
| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) |
| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) |
| `onboarding_feature_setup_toggled` | `#1853` (`2e5ac1c8ebdc93f6281622d9e4ef60ab14c71096`) | `v1.4.2-rc.1` at `2026-05-14T22:23:46Z` | `2026-05-17T03:03:54.535Z` (`1.4.3`) |
| `onboarding_task_sources_snapshot` | `#2275` (`ececd27fd8203a7acb3e430fa016fb4653aaa93c`) | `v1.4.7-rc.1` at `2026-05-19T00:47:11Z` | `2026-05-19T04:18:23.872Z` (`1.4.7`) |
Dashboard caveats:
@@ -121,26 +121,26 @@ Dashboard caveats:
Scope: first feature-wall tour event family before the inline onboarding tour. This added base open/close and tile activity telemetry. Historical `feature_wall_closed` rows from this rollout have `dwell_ms`; the source/depth fields needed for tour cohort retention only arrive in the later tour telemetry rollout.
| Field | Value |
| --- | --- |
| PR | `#1772` |
| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` |
| `code_merged_at_utc` | `2026-05-14T22:20:50Z` |
| First release | `v1.4.2-rc.1` |
| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` |
| `first_released_at_utc` | `2026-05-14T22:23:46Z` |
| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) |
| Field | Value |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| PR | `#1772` |
| Merge commit | `fdf7d9e97a46f27b3667d9bae6c8aca5d11345d8` |
| `code_merged_at_utc` | `2026-05-14T22:20:50Z` |
| First release | `v1.4.2-rc.1` |
| First release commit | `c66e9801fe5f159cac23564ec7c028957973692a` |
| `first_released_at_utc` | `2026-05-14T22:23:46Z` |
| `first_seen_at_utc` | `2026-05-15T06:15:30.263Z` on `feature_wall_opened { source: 'popup' }` (`1.4.2-rc.4`) |
| `dashboard_ready_at_utc` | Event-dependent. Useful for base feature-wall usage after first-seen rows, not for inline-tour cohort retention. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
| Event/property | `first_seen_at_utc` |
| --- | --- |
| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) |
| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) |
| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` |
| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) |
| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) |
| Event/property | `first_seen_at_utc` |
| ------------------------------------------------- | ----------------------------------------- |
| `feature_wall_opened { source: 'popup' }` | `2026-05-15T06:15:30.263Z` (`1.4.2-rc.4`) |
| `feature_wall_tile_focused` | `2026-05-15T06:15:31.073Z` (`1.4.2-rc.4`) |
| `feature_wall_closed` with legacy `dwell_ms` only | `2026-05-15T06:18:22.677Z` |
| `feature_wall_tile_clicked` | `2026-05-17T04:35:04.142Z` (`1.4.3`) |
| `feature_wall_opened { source: 'help_menu' }` | `2026-05-17T07:02:23.278Z` (`1.4.3`) |
Dashboard caveats:
@@ -151,16 +151,16 @@ Dashboard caveats:
Scope: optional "Explore Orca" tour during onboarding and the Help menu entry point. This shipped the product surface, not the full retention cohort instrumentation. It also added `source = 'onboarding'` to the feature-wall open source enum and added feature-wall group/feature/docs click events with source.
| Field | Value |
| --- | --- |
| PR | `#2652` |
| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` |
| `code_merged_at_utc` | `2026-05-23T19:21:14Z` |
| First release | `v1.4.23-rc.0` |
| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` |
| `first_released_at_utc` | `2026-05-23T20:14:38Z` |
| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` |
| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. |
| Field | Value |
| -------------------------- | ---------------------------------------------------------------------------- |
| PR | `#2652` |
| Merge commit | `669ade23133b9905fff1b6ed22755ee2911ea517` |
| `code_merged_at_utc` | `2026-05-23T19:21:14Z` |
| First release | `v1.4.23-rc.0` |
| First release commit | `1f1171e0dee5d0fbf969de56845882bf8deb9037` |
| `first_released_at_utc` | `2026-05-23T20:14:38Z` |
| First app version observed | `1.4.23-rc.0` at `2026-05-23T20:54:12.343Z` |
| `dashboard_ready_at_utc` | `TBD`; do not use this surface-only rollout for tour cohort retention tiles. |
Dashboard caveats:
@@ -179,32 +179,32 @@ Added/changed signals:
- `feature_wall_closed` optional `source`, `exit_action`, `furthest_step`, `last_group_id`, and bounded visited/completed depth counts.
- `feature_wall_opened { source: 'onboarding' }` through the inline tour surface.
| Field | Value |
| --- | --- |
| PR | `#2713` |
| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` |
| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` |
| `code_merged_at_utc` | `2026-05-23T21:15:11Z` |
| First release | `v1.4.23-rc.1` |
| First release commit | `c74765922e770c6540496735a5a2be838457256a` |
| `first_released_at_utc` | `2026-05-23T21:16:38Z` |
| `first_seen_at_utc` | `TBD` |
| `dashboard_ready_at_utc` | `TBD` |
| Field | Value |
| ------------------------ | ------------------------------------------ |
| PR | `#2713` |
| Branch commit | `9115143927dce38547545a07e58b85d17796fd7a` |
| Merge commit | `b9c55bb07127f799528761ec31d7e2b7f8598c07` |
| `code_merged_at_utc` | `2026-05-23T21:15:11Z` |
| First release | `v1.4.23-rc.1` |
| First release commit | `c74765922e770c6540496735a5a2be838457256a` |
| `first_released_at_utc` | `2026-05-23T21:16:38Z` |
| `first_seen_at_utc` | `TBD` |
| `dashboard_ready_at_utc` | `TBD` |
Required readiness signals:
| Signal | `first_seen_at_utc` |
| --- | --- |
| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` |
| `onboarding_step_viewed { value_kind: 'tour' }` | `TBD` |
| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` |
| Signal | `first_seen_at_utc` |
| -------------------------------------------------------------------------------------- | ------------------- |
| `onboarding_tour_outcome` with valid `outcome` and joinable fresh-install start | `TBD` |
| `onboarding_step_viewed { value_kind: 'tour' }` | `TBD` |
| `feature_wall_closed` with non-null `source`, `exit_action`, and expected depth fields | `TBD` |
QA/opportunistic signals:
| Signal | `first_seen_at_utc` |
| --- | --- |
| Signal | `first_seen_at_utc` |
| ------------------------------------------------ | --------------------------------------------------------------- |
| `onboarding_step_skipped { value_kind: 'tour' }` | `TBD`; do not gate dashboard readiness on natural skip traffic. |
| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. |
| `feature_wall_opened { source: 'onboarding' }` | `TBD`; surface availability only. |
PostHog evidence checked at `2026-05-23T23:34:32Z`:
+61
View File
@@ -0,0 +1,61 @@
# SSH Config Target Compatibility
## Problem
- `src/main/ssh/ssh-connection-utils.ts:108` resolves OpenSSH config with `ssh -G`, but then prefers the persisted `target.host` over `resolved.hostname`.
- `src/main/ssh/ssh-connection-utils.ts:109` treats persisted port `22` as an explicit override, so a config-host target can ignore a resolved non-default `Port`.
- `src/main/ssh/ssh-config-parser.ts:210` imports config aliases with `host` set to the alias when the concrete `Host` block lacks an inline `HostName`; later `ssh -G` may know the real host, but the connection path ignores it.
- `src/renderer/src/components/settings/SshPane.tsx:63` requires both host and username, even though OpenSSH config aliases can resolve the user and users commonly paste `user@host:port` targets.
- `src/main/ssh/ssh-config-parser.ts:330` parses `ForwardAgent`, but `src/main/ssh/ssh-connection-utils.ts:112` did not pass it into ssh2, so remote git commands that rely on the local agent could fail.
## Goal
Make Orca behave like a mature SSH client for common config-host flows: aliases imported from `~/.ssh/config`, aliases with inherited `HostName`/`Port`, and pasted SSH targets should connect without users re-entering information that OpenSSH can resolve.
## Non-goals
- Do not add persistent secret storage for SSH passwords or key passphrases.
- Do not redesign the whole SSH settings page.
- Do not change relay deployment or remote PTY lease semantics.
- Do not add a known-host trust UI in this patch.
## Design
1. Preserve explicit target overrides while letting config aliases use resolved values.
- In `buildConnectConfig`, prefer `resolved.hostname` only when the persisted host is blank, the same as `configHost`, or the same as the label.
- Prefer `resolved.port` when the target is a config-host target still on the default `22`; keep non-default target ports as explicit overrides.
- Continue using target username first, then resolved user.
2. Honor resolved agent forwarding where ssh2 can support it.
- Set `agentForward` only when resolved config requested forwarding and an agent is actually configured.
- Leave system-SSH transport unchanged because it already delegates to OpenSSH config.
3. Normalize settings form drafts before save.
- Accept `ssh://user@host:port`, `user@host:port`, and plain aliases in the Host field.
- Auto-fill username and port from pasted inputs only when the dedicated fields are still empty/default.
- Allow username to be omitted; `ssh -G` can provide it during connect.
4. Keep UI changes small.
- Rename copy only where needed to avoid implying username is mandatory.
- Render username-less targets without a leading `@`.
- Do not introduce new colors, typography, or layout patterns.
5. Cover behavior with focused tests.
- Add connection-config tests for config-host resolved hostname/port precedence.
- Add connection-config tests for `ForwardAgent yes`.
- Add renderer utility tests for pasted SSH target normalization.
## Edge Cases
- Explicit non-default port in Orca still wins over `ssh -G`.
- Empty or unparsable host input remains invalid.
- IPv6 bracket syntax is accepted for `ssh://` URLs and preserved conservatively for scp-like inputs.
- Plain config aliases remain valid even without a username.
## Rollout
1. Add the renderer draft-normalization helper and tests.
2. Wire the SSH settings form save path and labels to the helper.
3. Update `buildConnectConfig` precedence/agent-forwarding and tests.
4. Clean up username-less target display.
5. Run focused tests and typecheck/lint where feasible.
+1
View File
@@ -1,3 +1,4 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"rules": {}
}
+32 -19
View File
@@ -9,7 +9,7 @@ import {
TextInput
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import {
Search,
X,
@@ -62,6 +62,11 @@ import {
loadPreferences,
savePreferences
} from '../../../src/storage/preferences'
import {
createInitialHostRouteActionState,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from '../../../src/host-route-action-state'
// Why: locally-typed subset of the desktop's RuntimeStatus we read from
// `status.get`. Only the version fields matter to mobile today; everything
@@ -340,7 +345,9 @@ export default function HostScreen() {
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
const [showNewWorktree, setShowNewWorktree] = useState(false)
const [routeActionState, setRouteActionState] = useState(() =>
createInitialHostRouteActionState(action)
)
const [sleptIds, setSleptIds] = useState<Set<string>>(new Set())
// Persisted pin state
@@ -348,9 +355,16 @@ export default function HostScreen() {
const [_prefsLoaded, setPrefsLoaded] = useState(false)
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
useEffect(() => {
if (action === 'newWorktree') setShowNewWorktree(true)
}, [action])
const resolvedRouteActionState = resolveHostRouteActionState(routeActionState, action)
// Why: `action=newWorktree` is a route-derived open edge. Resolve it before
// commit, but don't reopen after the user closes while the same URL remains.
if (resolvedRouteActionState !== routeActionState) {
setRouteActionState(resolvedRouteActionState)
}
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const setShowNewWorktreeVisible = useCallback((visible: boolean) => {
setRouteActionState((current) => setHostRouteNewWorktreeVisible(current, visible))
}, [])
// Load persisted pins and preferences
useEffect(() => {
@@ -481,12 +495,6 @@ export default function HostScreen() {
}
}, [client, connState, hostId])
useEffect(() => {
if (connState === 'connected') {
void fetchWorktrees()
}
}, [connState, fetchWorktrees])
// Why: read desktop's protocol version from status.get on every connect
// and re-evaluate compatibility. If the desktop declares this mobile
// build too old (or vice versa via the local minimum), the host detail
@@ -528,13 +536,18 @@ export default function HostScreen() {
}
}, [connState, client])
useEffect(() => {
if (connState !== 'connected') return
const interval = setInterval(() => {
useFocusEffect(
useCallback(() => {
if (connState !== 'connected') return
void fetchWorktrees()
}, 3000)
return () => clearInterval(interval)
}, [connState, fetchWorktrees])
// Why: React Navigation keeps previous stack screens mounted; only
// poll the host list while this route is visible.
const interval = setInterval(() => {
void fetchWorktrees()
}, 3000)
return () => clearInterval(interval)
}, [connState, fetchWorktrees])
)
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
@@ -871,7 +884,7 @@ export default function HostScreen() {
<Pressable
style={styles.newButton}
onPress={() => setShowNewWorktree(true)}
onPress={() => setShowNewWorktreeVisible(true)}
disabled={connState !== 'connected'}
>
<Plus
@@ -1246,7 +1259,7 @@ export default function HostScreen() {
const params = new URLSearchParams({ name: worktreeName, created: '1' })
router.push(`/h/${hostId}/session/${encodeURIComponent(worktreeId)}?${params.toString()}`)
}}
onClose={() => setShowNewWorktree(false)}
onClose={() => setShowNewWorktreeVisible(false)}
/>
</SafeAreaView>
)
File diff suppressed because it is too large Load Diff
@@ -27,17 +27,32 @@ import {
MoreHorizontal,
Plus,
RefreshCw,
Trash2
Trash2,
X
} from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import type { RpcClient } from '../../../../src/transport/rpc-client'
import type { RpcSuccess } from '../../../../src/transport/types'
import {
ActionSheetModal,
type ActionSheetAction
} from '../../../../src/components/ActionSheetModal'
import { ConfirmModal } from '../../../../src/components/ConfirmModal'
import { BottomDrawer } from '../../../../src/components/BottomDrawer'
import { triggerError, triggerSelection, triggerSuccess } from '../../../../src/platform/haptics'
import { colors, radii, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
buildMobileDiffLines,
type MobileDiffLine
} from '../../../../src/session/mobile-diff-lines'
import {
buildMobileBranchCompareSection,
canOpenMobileBranchCompareDiff,
formatMobileBranchCompareSummary,
type MobileGitBranchChangeEntry,
type MobileGitBranchCompareResult,
type MobileGitBranchCompareSummary
} from '../../../../src/source-control/mobile-branch-compare'
import {
MOBILE_GIT_STATUS_LABELS,
buildMobileSourceControlSections,
@@ -86,6 +101,43 @@ type MobileGitStatusEntryView = MobileGitStatusEntry & {
unstageActionId: string
}
type MobileBranchCompareState =
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'ready'; result: MobileGitBranchCompareResult }
| { kind: 'error'; message: string }
type MobileBranchEntryView = MobileGitBranchChangeEntry & {
canOpen: boolean
}
type MobileBranchDiffPreviewState =
| { kind: 'loading'; entry: MobileGitBranchChangeEntry }
| {
kind: 'ready'
entry: MobileGitBranchChangeEntry
summary: MobileGitBranchCompareSummary
lines: MobileDiffLine[]
truncated: boolean
}
| { kind: 'error'; entry: MobileGitBranchChangeEntry; message: string }
type RuntimeRepoSummary = {
id: string
worktreeBaseRef?: string | null
}
type RepoBaseRefDefaultResult = {
defaultBaseRef: string | null
remoteCount: number
}
type GitDiffTextResult = {
kind: 'text'
originalContent: string
modifiedContent: string
}
const KEYBOARD_COMMIT_BAR_CLEARANCE = 10
const SELECTOR_RETRY_COUNT = 3
const SELECTOR_RETRY_DELAY_MS = 250
@@ -98,6 +150,45 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function getRepoIdFromMobileWorktreeId(id: string): string {
// Why: mobile cannot import desktop shared modules in its standalone tsc run,
// but the runtime worktree id wire format is still `${repoId}::${path}`.
const separatorIdx = id.indexOf('::')
return separatorIdx === -1 ? id : id.slice(0, separatorIdx)
}
async function resolveMobileBranchCompareBaseRef(
client: RpcClient,
worktreeId: string
): Promise<string | null> {
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
if (!repoId) {
return null
}
let repoBaseRef: string | null = null
const repoResponse = await client.sendRequest('repo.list')
if (repoResponse.ok) {
const repos = ((repoResponse as RpcSuccess).result as { repos?: RuntimeRepoSummary[] }).repos
const repo = repos?.find((candidate) => candidate.id === repoId)
repoBaseRef = repo?.worktreeBaseRef?.trim() || null
}
if (repoBaseRef) {
return repoBaseRef
}
const defaultResponse = await client.sendRequest('repo.baseRefDefault', { repo: `id:${repoId}` })
if (!defaultResponse.ok) {
if (isMobileGitUnavailable(defaultResponse.error?.code, defaultResponse.error?.message)) {
return null
}
throw new Error(defaultResponse.error?.message || 'Unable to resolve branch base')
}
const result = (defaultResponse as RpcSuccess).result as RepoBaseRefDefaultResult
return result.defaultBaseRef?.trim() || null
}
function getWorktreeLabel(name: string | undefined, worktreeId: string): string {
if (name?.trim()) {
return name.trim()
@@ -133,6 +224,27 @@ function statusColor(status: MobileGitFileStatus): string {
}
}
function formatBranchEntryMeta(entry: MobileGitBranchChangeEntry): string | null {
const stats =
entry.added !== undefined || entry.removed !== undefined
? `+${entry.added ?? 0} -${entry.removed ?? 0}`
: null
if (entry.oldPath) {
return stats ? `from ${entry.oldPath}; ${stats}` : `from ${entry.oldPath}`
}
return stats
}
function diffLinePrefix(kind: MobileDiffLine['kind']): string {
if (kind === 'add') return '+'
if (kind === 'delete') return '-'
return ' '
}
function diffLineNumber(line: MobileDiffLine): string {
return String(line.newLineNumber ?? line.oldLineNumber ?? '')
}
export default function MobileSourceControlScreen() {
const params = useLocalSearchParams<{
hostId?: string | string[]
@@ -148,6 +260,12 @@ export default function MobileSourceControlScreen() {
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const [screenState, setScreenState] = useState<ScreenState>({ kind: 'loading' })
const [branchCompareState, setBranchCompareState] = useState<MobileBranchCompareState>({
kind: 'idle'
})
const [branchDiffPreview, setBranchDiffPreview] = useState<MobileBranchDiffPreviewState | null>(
null
)
const [busyAction, setBusyAction] = useState<string | null>(null)
const [commitMessage, setCommitMessage] = useState('')
const [discardTarget, setDiscardTarget] = useState<MobileGitStatusEntry | null>(null)
@@ -155,21 +273,31 @@ export default function MobileSourceControlScreen() {
const [actionError, setActionError] = useState<string | null>(null)
const [keyboardLift, setKeyboardLift] = useState(0)
const [openingPath, setOpeningPath] = useState<string | null>(null)
const [openingBranchPath, setOpeningBranchPath] = useState<string | null>(null)
const busyActionRef = useRef<string | null>(null)
const currentStatusIdentityRef = useRef('')
const currentBranchCompareIdentityRef = useRef('')
const loadGenerationRef = useRef(0)
const branchCompareGenerationRef = useRef(0)
const mountedRef = useRef(true)
const openingPathRef = useRef<string | null>(null)
const openingBranchPathRef = useRef<string | null>(null)
const statusLoadInFlightRef = useRef<StatusLoadInFlight | null>(null)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
const statusIdentityKey = `${hostId}\0${worktreeId}`
currentStatusIdentityRef.current = statusIdentityKey
currentBranchCompareIdentityRef.current = statusIdentityKey
useEffect(() => {
return () => {
mountedRef.current = false
loadGenerationRef.current += 1
const setMobileSourceControlRootRef = useCallback((node: View | null): void => {
if (node !== null) {
mountedRef.current = true
return
}
// Why: source-control RPC loads can outlive the route; invalidate pending
// writes when the screen detaches without a passive cleanup-only Effect.
mountedRef.current = false
loadGenerationRef.current += 1
branchCompareGenerationRef.current += 1
}, [])
useEffect(() => {
@@ -188,6 +316,63 @@ export default function MobileSourceControlScreen() {
}
}, [insets.bottom])
const loadBranchCompare = useCallback(
async (options?: { preserveReadyOnFailure?: boolean }) => {
const loadKey = statusIdentityKey
const generation = branchCompareGenerationRef.current + 1
branchCompareGenerationRef.current = generation
const isCurrentLoad = () =>
mountedRef.current &&
branchCompareGenerationRef.current === generation &&
currentBranchCompareIdentityRef.current === loadKey
if (!worktreeId || !client || connState !== 'connected') {
if (isCurrentLoad()) {
setBranchCompareState({ kind: 'idle' })
}
return false
}
setBranchCompareState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
try {
const baseRef = await resolveMobileBranchCompareBaseRef(client, worktreeId)
if (!isCurrentLoad()) return false
if (!baseRef) {
setBranchCompareState({ kind: 'idle' })
return true
}
const response = await client.sendRequest('git.branchCompare', {
worktree: `id:${worktreeId}`,
baseRef
})
if (!isCurrentLoad()) return false
if (!response.ok) {
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
setBranchCompareState({ kind: 'idle' })
return false
}
throw new Error(response.error?.message || 'Unable to load committed changes')
}
setBranchCompareState({
kind: 'ready',
result: (response as RpcSuccess).result as MobileGitBranchCompareResult
})
return true
} catch (err) {
if (!isCurrentLoad()) return false
const message = err instanceof Error ? err.message : 'Unable to load committed changes'
setBranchCompareState((prev) => {
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
return prev
}
return { kind: 'error', message }
})
return false
}
},
[client, connState, statusIdentityKey, worktreeId]
)
const loadStatus = useCallback(
async (options?: LoadStatusOptions) => {
const loadKey = statusIdentityKey
@@ -230,6 +415,7 @@ export default function MobileSourceControlScreen() {
if (response.ok) {
const result = (response as RpcSuccess).result as MobileGitStatusResult
setScreenState({ kind: 'ready', status: result })
void loadBranchCompare({ preserveReadyOnFailure: true })
if (options?.clearActionErrorOnSuccess !== false) {
setActionError(null)
}
@@ -278,7 +464,7 @@ export default function MobileSourceControlScreen() {
}
}
},
[client, connState, statusIdentityKey, worktreeId]
[client, connState, loadBranchCompare, statusIdentityKey, worktreeId]
)
useEffect(() => {
@@ -301,6 +487,31 @@ export default function MobileSourceControlScreen() {
[entries]
)
const sections = useMemo(() => buildMobileSourceControlSections(derivedEntries), [derivedEntries])
const branchCompareResult = branchCompareState.kind === 'ready' ? branchCompareState.result : null
const branchCompareSection = useMemo(
() => buildMobileBranchCompareSection(branchCompareResult?.entries ?? []),
[branchCompareResult]
)
const branchCompareSummaryText = branchCompareResult
? formatMobileBranchCompareSummary(branchCompareResult.summary)
: null
const branchCompareCanOpen = branchCompareResult
? canOpenMobileBranchCompareDiff(branchCompareResult.summary)
: false
const branchEntries = useMemo<MobileBranchEntryView[]>(
() =>
(branchCompareSection?.data ?? []).map((entry) => ({
...entry,
canOpen: branchCompareCanOpen
})),
[branchCompareCanOpen, branchCompareSection]
)
const shouldShowBranchCompareSection =
branchEntries.length > 0 ||
branchCompareState.kind === 'loading' ||
branchCompareState.kind === 'error' ||
(branchCompareResult !== null && branchCompareResult.summary.status !== 'ready')
const hasVisibleChanges = sections.length > 0 || shouldShowBranchCompareSection
const stageablePaths = useMemo(() => getStageablePaths(entries), [entries])
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
@@ -622,13 +833,81 @@ export default function MobileSourceControlScreen() {
[client, connState, hostId, name, origin, router, worktreeId]
)
const openBranchDiff = useCallback(
async (entry: MobileGitBranchChangeEntry) => {
if (openingBranchPathRef.current || openingPathRef.current || busyActionRef.current) return
if (!client || connState !== 'connected') {
if (!mountedRef.current) return
setActionError('Waiting for desktop...')
return
}
if (branchCompareState.kind !== 'ready') {
return
}
const summary = branchCompareState.result.summary
if (!canOpenMobileBranchCompareDiff(summary) || !summary.headOid || !summary.mergeBase) {
return
}
openingBranchPathRef.current = entry.path
setOpeningBranchPath(entry.path)
setBranchDiffPreview({ kind: 'loading', entry })
try {
const response = await client.sendRequest('git.branchDiff', {
worktree: `id:${worktreeId}`,
filePath: entry.path,
...(entry.oldPath ? { oldPath: entry.oldPath } : {}),
compare: {
baseRef: summary.baseRef,
...(summary.baseOid ? { baseOid: summary.baseOid } : {}),
headOid: summary.headOid,
mergeBase: summary.mergeBase
}
})
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to load committed diff')
}
const result = (response as RpcSuccess).result as GitDiffTextResult | { kind: 'binary' }
if (result.kind !== 'text') {
throw new Error('Binary branch diff preview unavailable on mobile')
}
const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent)
if (!mountedRef.current) return
setBranchDiffPreview({
kind: 'ready',
entry,
summary,
lines: diff.lines,
truncated: diff.truncated
})
triggerSelection()
} catch (err) {
if (!mountedRef.current) return
triggerError()
setBranchDiffPreview({
kind: 'error',
entry,
message: err instanceof Error ? err.message : 'Unable to load committed diff'
})
} finally {
if (openingBranchPathRef.current === entry.path) {
openingBranchPathRef.current = null
if (mountedRef.current) {
setOpeningBranchPath(null)
}
}
}
},
[branchCompareState, client, connState, worktreeId]
)
const actionSheetActions = useMemo<ActionSheetAction[]>(() => {
const hasMessage = commitMessage.trim().length > 0
const hasStaged = stagedCount > 0
const hasUpstream = upstream?.hasUpstream === true
const ahead = upstream?.ahead ?? 0
const behind = upstream?.behind ?? 0
const busy = busyAction !== null
const busy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const commitHint = !hasStaged
? 'Stage at least one file'
: !hasMessage
@@ -749,6 +1028,8 @@ export default function MobileSourceControlScreen() {
}, [
busyAction,
commitMessage,
openingBranchPath,
openingPath,
runActionSheetCommit,
runActionSheetCommitSequence,
runActionSheetCommitSync,
@@ -771,7 +1052,8 @@ export default function MobileSourceControlScreen() {
busyAction === item.unstageActionId ||
busyAction === item.discardActionId ||
openingPath === item.path
const rowDisabled = !item.canOpen || busyAction !== null || openingPath !== null
const rowDisabled =
!item.canOpen || busyAction !== null || openingPath !== null || openingBranchPath !== null
return (
<Pressable
style={({ pressed }) => [
@@ -817,10 +1099,11 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
onPress={() =>
void runGitAction(item.unstageActionId, 'git.unstage', { filePath: item.path })
}
@@ -835,10 +1118,13 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={
busyAction !== null || openingPath !== null || openingBranchPath !== null
}
onPress={() =>
void runGitAction(item.stageActionId, 'git.stage', { filePath: item.path })
}
@@ -852,10 +1138,13 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.iconButton,
(busyAction !== null || openingPath !== null) && styles.iconButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.iconButtonDisabled,
pressed && styles.iconButtonPressed
]}
disabled={busyAction !== null || openingPath !== null}
disabled={
busyAction !== null || openingPath !== null || openingBranchPath !== null
}
onPress={() => setDiscardTarget(item)}
hitSlop={8}
accessibilityLabel={`Discard ${item.path}`}
@@ -868,7 +1157,7 @@ export default function MobileSourceControlScreen() {
</Pressable>
)
},
[busyAction, openFile, openingPath, runGitAction]
[busyAction, openFile, openingBranchPath, openingPath, runGitAction]
)
const keyExtractor = useCallback(
@@ -886,8 +1175,172 @@ export default function MobileSourceControlScreen() {
[]
)
const renderBranchCompareFooter = useCallback(() => {
if (!shouldShowBranchCompareSection) {
return null
}
return (
<View style={styles.branchCompareBlock}>
<View style={styles.sectionHeader}>
<View style={styles.branchSectionTitleBlock}>
<Text style={styles.sectionTitle}>Committed on Branch</Text>
{branchCompareSummaryText ? (
<Text style={styles.branchSectionSubtitle} numberOfLines={1}>
{branchCompareSummaryText}
</Text>
) : null}
</View>
<Text style={styles.sectionCount}>{branchEntries.length}</Text>
</View>
{branchCompareState.kind === 'loading' ? (
<View style={styles.branchStateRow}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.branchStateText}>Loading committed changes...</Text>
</View>
) : branchCompareState.kind === 'error' ? (
<View style={styles.branchStateRow}>
<Text style={styles.branchStateText}>{branchCompareState.message}</Text>
</View>
) : branchCompareResult && branchCompareResult.summary.status !== 'ready' ? (
<View style={styles.branchStateRow}>
<Text style={styles.branchStateText}>
{branchCompareResult.summary.errorMessage ?? 'Committed changes unavailable.'}
</Text>
</View>
) : (
branchEntries.map((entry) => {
const rowBusy = openingBranchPath === entry.path
const rowDisabled =
!entry.canOpen ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null
const meta = formatBranchEntryMeta(entry)
return (
<Pressable
key={`${entry.path}:${entry.oldPath ?? ''}`}
style={({ pressed }) => [
styles.fileRow,
pressed && entry.canOpen && styles.fileRowPressed,
rowDisabled && styles.fileRowDisabled,
!entry.canOpen && styles.fileRowUnavailable
]}
onPress={() => void openBranchDiff(entry)}
disabled={rowDisabled}
accessibilityLabel={`Open committed change ${entry.path}`}
>
<View style={styles.statusBadge}>
<Text style={[styles.statusBadgeText, { color: statusColor(entry.status) }]}>
{MOBILE_GIT_STATUS_LABELS[entry.status]}
</Text>
</View>
<FileText
size={16}
color={entry.canOpen ? colors.textSecondary : colors.textMuted}
strokeWidth={2.1}
/>
<View style={styles.fileTextBlock}>
<Text
style={[styles.filePath, !entry.canOpen && styles.filePathDisabled]}
numberOfLines={1}
>
{entry.path}
</Text>
{meta ? (
<Text style={styles.fileMeta} numberOfLines={1}>
{meta}
</Text>
) : null}
</View>
{rowBusy ? <ActivityIndicator size="small" color={colors.textSecondary} /> : null}
</Pressable>
)
})
)}
</View>
)
}, [
branchCompareResult,
branchCompareState,
branchCompareSummaryText,
branchEntries,
busyAction,
openBranchDiff,
openingBranchPath,
openingPath,
shouldShowBranchCompareSection
])
const renderBranchDiffPreview = useCallback(() => {
if (!branchDiffPreview) {
return null
}
const entry = branchDiffPreview.entry
return (
<BottomDrawer
visible={branchDiffPreview !== null}
onClose={() => setBranchDiffPreview(null)}
dragContentToDismiss={false}
zIndex={1100}
>
<View style={styles.diffDrawerHeader}>
<View style={styles.diffDrawerTitleBlock}>
<Text style={styles.diffDrawerTitle} numberOfLines={1}>
{entry.path}
</Text>
<Text style={styles.diffDrawerMeta} numberOfLines={1}>
{branchDiffPreview.kind === 'ready'
? `${branchDiffPreview.summary.baseRef}..HEAD`
: 'Committed on branch'}
</Text>
</View>
<Pressable
style={({ pressed }) => [styles.diffCloseButton, pressed && styles.iconButtonPressed]}
onPress={() => setBranchDiffPreview(null)}
hitSlop={8}
accessibilityLabel="Close committed diff preview"
>
<X size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
</View>
{branchDiffPreview.kind === 'loading' ? (
<View style={styles.diffState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : branchDiffPreview.kind === 'error' ? (
<View style={styles.diffState}>
<Text style={styles.stateTitle}>Unable to Load Diff</Text>
<Text style={styles.stateText}>{branchDiffPreview.message}</Text>
</View>
) : (
<View style={styles.diffLines}>
{branchDiffPreview.truncated ? (
<Text style={styles.diffTruncatedText}>Diff truncated for mobile preview.</Text>
) : null}
{branchDiffPreview.lines.map((line, index) => (
<View
key={`${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}`}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffLineAdd,
line.kind === 'delete' && styles.diffLineDelete
]}
>
<Text style={styles.diffLineNumber}>{diffLineNumber(line)}</Text>
<Text style={styles.diffLinePrefix}>{diffLinePrefix(line.kind)}</Text>
<Text style={styles.diffLineText}>{line.text || ' '}</Text>
</View>
))}
</View>
)}
</BottomDrawer>
)
}, [branchDiffPreview])
return (
<View style={styles.container}>
<View ref={setMobileSourceControlRootRef} style={styles.container}>
<SafeAreaView style={styles.header} edges={['top']}>
<View style={styles.topBar}>
<Pressable
@@ -909,11 +1362,12 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.refreshButton,
(busyAction !== null || openingPath !== null) && styles.refreshButtonDisabled,
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.refreshButtonDisabled,
pressed && styles.refreshButtonPressed
]}
onPress={() => void loadStatus()}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
hitSlop={8}
accessibilityLabel="Refresh source control"
>
@@ -953,6 +1407,9 @@ export default function MobileSourceControlScreen() {
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchEntries.length > 0 ? (
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
) : null}
{status && status.conflictOperation !== 'unknown' ? (
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
) : null}
@@ -968,13 +1425,19 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(stageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
(stageablePaths.length === 0 ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null) &&
styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void stageAll()}
disabled={
busyAction !== null || openingPath !== null || stageablePaths.length === 0
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null ||
stageablePaths.length === 0
}
>
{busyAction === 'stage-all' ? (
@@ -987,13 +1450,19 @@ export default function MobileSourceControlScreen() {
<Pressable
style={({ pressed }) => [
styles.bulkButton,
(unstageablePaths.length === 0 || busyAction !== null || openingPath !== null) &&
(unstageablePaths.length === 0 ||
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null) &&
styles.bulkButtonDisabled,
pressed && styles.bulkButtonPressed
]}
onPress={() => void unstageAll()}
disabled={
busyAction !== null || openingPath !== null || unstageablePaths.length === 0
busyAction !== null ||
openingPath !== null ||
openingBranchPath !== null ||
unstageablePaths.length === 0
}
>
{busyAction === 'unstage-all' ? (
@@ -1007,10 +1476,11 @@ export default function MobileSourceControlScreen() {
style={({ pressed }) => [
styles.bulkMenuButton,
pressed && styles.bulkButtonPressed,
(busyAction !== null || openingPath !== null) && styles.bulkButtonDisabled
(busyAction !== null || openingPath !== null || openingBranchPath !== null) &&
styles.bulkButtonDisabled
]}
onPress={() => setShowActionSheet(true)}
disabled={busyAction !== null || openingPath !== null}
disabled={busyAction !== null || openingPath !== null || openingBranchPath !== null}
hitSlop={8}
accessibilityLabel="Open source control actions"
>
@@ -1019,7 +1489,7 @@ export default function MobileSourceControlScreen() {
</View>
</View>
{entries.length === 0 ? (
{!hasVisibleChanges ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>No Changes</Text>
<Text style={styles.stateText}>Working tree is clean.</Text>
@@ -1030,6 +1500,7 @@ export default function MobileSourceControlScreen() {
renderItem={renderItem}
keyExtractor={keyExtractor}
renderSectionHeader={renderSectionHeader}
ListFooterComponent={renderBranchCompareFooter}
stickySectionHeadersEnabled={false}
contentContainerStyle={styles.listContent}
/>
@@ -1062,7 +1533,9 @@ export default function MobileSourceControlScreen() {
onChangeText={setCommitMessage}
placeholder="Commit message"
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null}
editable={
busyAction === null && openingPath === null && openingBranchPath === null
}
returnKeyType="done"
onSubmitEditing={() => void commit()}
/>
@@ -1073,7 +1546,8 @@ export default function MobileSourceControlScreen() {
(!commitMessage.trim() ||
stagedCount === 0 ||
busyAction !== null ||
openingPath !== null) &&
openingPath !== null ||
openingBranchPath !== null) &&
styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
@@ -1082,7 +1556,8 @@ export default function MobileSourceControlScreen() {
!commitMessage.trim() ||
stagedCount === 0 ||
busyAction !== null ||
openingPath !== null
openingPath !== null ||
openingBranchPath !== null
}
>
{busyAction === 'commit' ? (
@@ -1096,6 +1571,8 @@ export default function MobileSourceControlScreen() {
</>
)}
{renderBranchDiffPreview()}
<ActionSheetModal
visible={showActionSheet}
title="Source Control"
@@ -1298,6 +1775,33 @@ const styles = StyleSheet.create({
fontSize: typography.metaSize,
fontWeight: '600'
},
branchCompareBlock: {
paddingBottom: spacing.sm
},
branchSectionTitleBlock: {
flex: 1,
minWidth: 0
},
branchSectionSubtitle: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginTop: 2
},
branchStateRow: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
branchStateText: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18
},
fileRow: {
minHeight: 50,
flexDirection: 'row',
@@ -1446,5 +1950,83 @@ const styles = StyleSheet.create({
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
diffDrawerHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingBottom: spacing.md,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
diffDrawerTitleBlock: {
flex: 1,
minWidth: 0
},
diffDrawerTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
diffDrawerMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginTop: 2
},
diffCloseButton: {
width: 34,
height: 34,
borderRadius: radii.button,
alignItems: 'center',
justifyContent: 'center'
},
diffState: {
minHeight: 160,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.lg
},
diffLines: {
paddingTop: spacing.md,
paddingBottom: spacing.lg
},
diffTruncatedText: {
color: colors.textMuted,
fontSize: typography.metaSize,
marginBottom: spacing.sm
},
diffLine: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.xs,
paddingVertical: 2,
paddingHorizontal: spacing.xs,
borderRadius: radii.row
},
diffLineAdd: {
backgroundColor: colors.diffAddedBg
},
diffLineDelete: {
backgroundColor: colors.diffDeletedBg
},
diffLineNumber: {
width: 40,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
textAlign: 'right'
},
diffLinePrefix: {
width: 12,
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
diffLineText: {
flex: 1,
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize,
lineHeight: 17
}
})
+137 -109
View File
@@ -50,7 +50,7 @@ import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo'
import {
buildGitHubPrFileDiffLines,
buildGitHubPrFileDiffPreview,
type GitHubPrFileDiffLine
} from '../../../src/tasks/github-pr-file-diff'
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
@@ -59,6 +59,7 @@ import {
filterWorkspaceAgents,
isWorkspaceAgentEnabled,
pickWorkspaceAgent,
resolveWorkspaceAgentSelection,
workspaceAgentLabel,
type WorkspaceAgentChoice
} from '../../../src/tasks/workspace-agent-selection'
@@ -112,6 +113,10 @@ import {
} from '../../../src/tasks/mobile-task-providers'
import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents'
import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection'
import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
import type {
BaseRefSearchResult,
PersistedTrustedOrcaHooks,
@@ -851,6 +856,10 @@ type ProjectSortOverride = { fieldId: string; direction: GitHubProjectSortDirect
type ProjectListEntry =
| { type: 'group'; group: ProjectGroup; collapsed: boolean }
| { type: 'row'; row: GitHubProjectRow }
type LinearIssueSection = { key: string; label: string; color: string; issues: LinearIssue[] }
type LinearListEntry =
| { type: 'section'; section: LinearIssueSection }
| { type: 'issue'; issue: LinearIssue }
const PROJECT_VIEW_DEFAULT_SORT = '__view_default__'
const GITHUB_REPO_CONCURRENCY = 3
@@ -1497,7 +1506,7 @@ function groupLinearIssues(
issues: LinearIssue[],
groupBy: LinearGroupBy,
orderBy: LinearOrderBy
): Array<{ key: string; label: string; color: string; issues: LinearIssue[] }> {
): LinearIssueSection[] {
const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy))
if (groupBy === 'none') {
return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }]
@@ -1864,14 +1873,19 @@ function GitHubPrFileDiff({
onCommentDraftChange: (key: string, value: string) => void
onSubmitComment: (line: number) => void
}): ReactNode {
const diffLines = useMemo(
() => buildGitHubPrFileDiffLines(contents.original, contents.modified),
const diffPreview = useMemo(
() =>
buildGitHubPrFileDiffPreview(
contents.original,
contents.modified,
MAX_RENDERED_PR_DIFF_LINES
),
[contents.modified, contents.original]
)
const visibleDiffLines = diffLines.slice(0, MAX_RENDERED_PR_DIFF_LINES)
const hiddenDiffLineCount = Math.max(0, diffLines.length - visibleDiffLines.length)
const visibleDiffLines = diffPreview.lines
const hiddenDiffLineCount = Math.max(0, diffPreview.totalLineCount - visibleDiffLines.length)
if (diffLines.length === 0) {
if (diffPreview.totalLineCount === 0) {
return <Text style={styles.detailMuted}>No text changes found.</Text>
}
@@ -1879,7 +1893,7 @@ function GitHubPrFileDiff({
<View style={styles.fileDiff}>
{hiddenDiffLineCount > 0 ? (
<Text style={styles.detailMuted}>
Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffLines.length} diff lines.
Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffPreview.totalLineCount} diff lines.
</Text>
) : null}
{visibleDiffLines.map((line) => {
@@ -2145,6 +2159,7 @@ export default function MobileTasksScreen() {
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [expandedResolvedCommentGroups, setExpandedResolvedCommentGroups] = useState<Set<string>>(
() => new Set()
)
@@ -2266,6 +2281,13 @@ export default function MobileTasksScreen() {
const [projectMutating, setProjectMutating] = useState(false)
const [projectRepoNotInOrca, setProjectRepoNotInOrca] =
useState<ProjectRepoNotInOrcaPrompt | null>(null)
// Why: project detail text inputs rerender this screen while comments stay
// unchanged; keep grouping out of the typing path.
const projectDetailCommentGroups = useMemo(
() =>
groupDetailComments(projectRowDetail?.provider === 'github' ? projectRowDetail.comments : []),
[projectRowDetail]
)
const requestedTaskSource = useMemo(
() => (isTaskProvider(taskSource) ? taskSource : undefined),
[taskSource]
@@ -3737,6 +3759,15 @@ export default function MobileTasksScreen() {
return () => clearTimeout(timer)
}, [githubKind, provider, query, taskStateHydrated])
const setTaskCopyFeedbackRootRef = useCallback((node: View | null): void => {
if (node !== null) {
return
}
// Why: copied-link feedback is screen-local; clear the pending reset when
// the Tasks screen detaches without a passive cleanup-only Effect.
clearMobileTaskCopyFeedbackTimer(copiedLinkResetTimerRef)
}, [])
useEffect(() => {
if (!taskUiReady || provider !== 'github' || githubMode !== 'items') return
const trimmed = appliedQuery.trim()
@@ -4998,42 +5029,22 @@ export default function MobileTasksScreen() {
workspaceCreateTargetRepo
])
useEffect(() => {
if (!tasksSupported || !workspaceCreateDraft || workspaceAgentOverridden) return
setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds))
}, [
runtimeTaskSettings,
tasksSupported,
workspaceAgentOverridden,
workspaceCreateDraft,
workspaceDetectedAgentIds
])
useEffect(() => {
if (
!workspaceCreateDraft ||
!tasksSupported ||
workspaceDetectedAgentIds === null ||
!workspaceAgent ||
workspaceAgent === 'blank' ||
(workspaceDetectedAgentIds.has(workspaceAgent) &&
isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents))
) {
return
}
// Why: the drawer can open before SSH/local detection settles. If the user
// picked an agent that is not actually available on that host, fall back to
// the same detected-agent rule desktop uses instead of launching a bad CLI.
setWorkspaceAgent(pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds))
setWorkspaceAgentOverridden(false)
}, [
runtimeTaskSettings,
tasksSupported,
workspaceAgent,
workspaceCreateDraft,
workspaceDetectedAgentIds,
runtimeTaskSettings.disabledTuiAgents
])
const workspaceAgentSelection = resolveWorkspaceAgentSelection({
selectionActive: tasksSupported && workspaceCreateDraft !== null,
settings: runtimeTaskSettings,
detectedAgentIds: workspaceDetectedAgentIds,
agent: workspaceAgent,
overridden: workspaceAgentOverridden
})
if (
workspaceAgentSelection.agent !== workspaceAgent ||
workspaceAgentSelection.overridden !== workspaceAgentOverridden
) {
// Why: the drawer can open before SSH/local detection settles. Resolve the
// visible agent before commit so users do not see an unavailable override.
setWorkspaceAgent(workspaceAgentSelection.agent)
setWorkspaceAgentOverridden(workspaceAgentSelection.overridden)
}
const resolvedWorkspaceAgent = useMemo(
() => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds),
@@ -6828,9 +6839,7 @@ export default function MobileTasksScreen() {
try {
await Clipboard.setStringAsync(url)
setCopiedLinkKey(key)
setTimeout(() => {
setCopiedLinkKey((current) => (current === key ? null : current))
}, 1500)
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to copy link')
}
@@ -6840,9 +6849,7 @@ export default function MobileTasksScreen() {
try {
await Clipboard.setStringAsync(value)
setCopiedLinkKey(key)
setTimeout(() => {
setCopiedLinkKey((current) => (current === key ? null : current))
}, 1500)
scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to copy text')
}
@@ -7866,26 +7873,29 @@ export default function MobileTasksScreen() {
)
}
const createTargetOptions: PickerOption<string>[] =
provider === 'github' || provider === 'gitlab'
? hostedRepos.map((repo) => ({
value: repo.id,
label: repo.displayName,
subtitle: repo.path,
renderIcon: () => (
<View
style={[
styles.pickerRepoDot,
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
]}
/>
)
}))
: linearTeams.map((team) => ({
value: team.id,
label: team.name,
subtitle: team.workspaceName
}))
const createTargetOptions = useMemo<PickerOption<string>[]>(
() =>
provider === 'github' || provider === 'gitlab'
? hostedRepos.map((repo) => ({
value: repo.id,
label: repo.displayName,
subtitle: repo.path,
renderIcon: () => (
<View
style={[
styles.pickerRepoDot,
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
]}
/>
)
}))
: linearTeams.map((team) => ({
value: team.id,
label: team.name,
subtitle: team.workspaceName
})),
[hostedRepos, linearTeams, provider]
)
const selectedCreateTarget =
provider === 'github' || provider === 'gitlab'
? (hostedRepos.find((repo) => repo.id === createRepoId) ?? hostedRepos[0] ?? null)
@@ -7940,19 +7950,23 @@ export default function MobileTasksScreen() {
: `${selectedHostedRepos.length} repos`
const repoPickerSelectedRepo =
selectedRepoIds.size > 0 && selectedHostedRepos.length === 1 ? selectedHostedRepos[0]! : null
const workspaceRepoOptions: PickerOption<string>[] = workspaceRepos.map((repo) => ({
value: repo.id,
label: repo.displayName,
subtitle: repo.path,
renderIcon: () => (
<View
style={[
styles.pickerRepoDot,
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
]}
/>
)
}))
const workspaceRepoOptions = useMemo<PickerOption<string>[]>(
() =>
workspaceRepos.map((repo) => ({
value: repo.id,
label: repo.displayName,
subtitle: repo.path,
renderIcon: () => (
<View
style={[
styles.pickerRepoDot,
{ backgroundColor: getRepoBadgeColor(repo, repo.displayName) }
]}
/>
)
})),
[workspaceRepos]
)
const sortedItems = useMemo(() => {
const next = [...items]
if (taskSort === 'repository') {
@@ -8022,6 +8036,17 @@ export default function MobileTasksScreen() {
],
[githubProjectFields, githubProjectSortOverride, githubProjectViewSort]
)
const githubProjectViewOptions = useMemo<PickerOption<string>[]>(
() =>
githubProjectViews.map((view) => ({
value: view.id,
label: view.name,
subtitle:
view.layout === 'TABLE_LAYOUT' ? `View #${view.number}` : 'Unsupported layout on mobile',
disabled: view.layout !== 'TABLE_LAYOUT'
})),
[githubProjectViews]
)
const githubPresetOptions = githubKind === 'prs' ? PR_PRESETS : ISSUE_PRESETS
const githubPresetPickerOptions = useMemo(
() =>
@@ -8052,6 +8077,16 @@ export default function MobileTasksScreen() {
linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId)
?.displayName ??
'Workspace')
const linearWorkspaceOptions = useMemo<PickerOption<string>[]>(
() => [
{ value: 'all', label: 'All workspaces' },
...linearWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.organizationName ?? workspace.displayName ?? workspace.id
}))
],
[linearWorkspaces]
)
const linearTeamLabel =
selectedLinearTeamIds.size === 0 || selectedLinearTeamIds.size === linearTeams.length
? 'All teams'
@@ -8090,6 +8125,20 @@ export default function MobileTasksScreen() {
() => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy),
[linearGroupBy, linearIssuesForView, linearOrderBy]
)
// Why: FlatList treats data identity as meaningful; unrelated renders should
// not rebuild the section/item wrapper array.
const linearListEntries = useMemo<LinearListEntry[]>(
() =>
linearIssueSections.flatMap((section) =>
linearGroupBy === 'none'
? section.issues.map((issue) => ({ type: 'issue' as const, issue }))
: [
{ type: 'section' as const, section },
...section.issues.map((issue) => ({ type: 'issue' as const, issue }))
]
),
[linearGroupBy, linearIssueSections]
)
const linearBoardSections = useMemo(
() =>
groupLinearIssues(
@@ -8200,7 +8249,7 @@ export default function MobileTasksScreen() {
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.topChrome}>
<View ref={setTaskCopyFeedbackRootRef} style={styles.topChrome}>
<View style={styles.statusBar}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textPrimary} />
@@ -8949,14 +8998,7 @@ export default function MobileTasksScreen() {
</ScrollView>
) : (
<FlatList
data={linearIssueSections.flatMap((section) =>
linearGroupBy === 'none'
? section.issues.map((issue) => ({ type: 'issue' as const, issue }))
: [
{ type: 'section' as const, section },
...section.issues.map((issue) => ({ type: 'issue' as const, issue }))
]
)}
data={linearListEntries}
keyExtractor={(entry) =>
entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id
}
@@ -9759,15 +9801,7 @@ export default function MobileTasksScreen() {
<PickerModal
visible={taskUiReady && showGitHubProjectViewPicker}
title={pendingGitHubProjectViewSelection ? 'Choose Project View' : 'Project View'}
options={githubProjectViews.map((view) => ({
value: view.id,
label: view.name,
subtitle:
view.layout === 'TABLE_LAYOUT'
? `View #${view.number}`
: 'Unsupported layout on mobile',
disabled: view.layout !== 'TABLE_LAYOUT'
}))}
options={githubProjectViewOptions}
selected={pendingGitHubProjectViewSelection ? '' : (activeGitHubProjectViewId ?? '')}
onSelect={(viewId) => {
const view = githubProjectViews.find((candidate) => candidate.id === viewId)
@@ -9898,13 +9932,7 @@ export default function MobileTasksScreen() {
<PickerModal
visible={taskUiReady && showLinearWorkspacePicker}
title="Linear Workspace"
options={[
{ value: 'all', label: 'All workspaces' },
...linearWorkspaces.map((workspace) => ({
value: workspace.id,
label: workspace.organizationName ?? workspace.displayName ?? workspace.id
}))
]}
options={linearWorkspaceOptions}
selected={selectedLinearWorkspaceId ?? ''}
onSelect={(workspaceId) => {
setSelectedLinearWorkspaceId(workspaceId)
@@ -11824,7 +11852,7 @@ export default function MobileTasksScreen() {
{projectRowDetail.comments.length === 0 ? (
<Text style={styles.detailMuted}>No comments.</Text>
) : (
groupDetailComments(projectRowDetail.comments).map((group) => {
projectDetailCommentGroups.map((group) => {
const groupId = detailCommentGroupId(group)
const root = detailCommentGroupRoot(group)
const count = detailCommentGroupCount(group)
+6 -6
View File
@@ -305,12 +305,12 @@ export default function HomeScreen() {
if (hosts.length > 0) primeHosts(hosts)
}, [hosts, primeHosts])
const allClientsRef = useRef<Array<{ hostId: string; client: RpcClient }>>([])
useEffect(() => {
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
}, [allClients])
// Why: the focus callback stays stable to avoid refetching on every
// client-store render, but it still needs the latest host clients.
allClientsRef.current = allClients.map((entry) => ({
hostId: entry.hostId,
client: entry.client
}))
// Why: hydrate the home page from a persisted snapshot on cold-start so
// Resume + Account-usage cards paint immediately with last-known data
+55 -31
View File
@@ -1,12 +1,16 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { parsePairingCode } from '../src/transport/pairing'
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -23,7 +27,6 @@ export default function PairConfirmScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [offer, setOffer] = useState<PairingOffer | null>(null)
const [status, setStatus] = useState<Status>('awaiting-confirm')
const [errorMessage, setErrorMessage] = useState('')
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
@@ -31,6 +34,17 @@ export default function PairConfirmScreen() {
// over the initial state setter) always sees the freshest list and we
// batch fewer setState calls when entries arrive in bursts.
const logsRef = useRef<ConnectionLogEntry[]>([])
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const routeState = resolvePairConfirmRouteState(params.code)
const offer = routeState.offer
const resolvedStatus =
status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status
const resolvedErrorMessage =
status === 'awaiting-confirm' && routeState.kind === 'error'
? routeState.errorMessage
: errorMessage
const cancel = useCallback(() => {
router.replace('/')
@@ -46,20 +60,17 @@ export default function PairConfirmScreen() {
}, [cancel])
)
useEffect(() => {
if (!params.code) {
setStatus('error')
setErrorMessage('Missing pairing code')
const setPairConfirmRootRef = useCallback((node: View | null): void => {
if (node !== null) {
mountedRef.current = true
return
}
const parsed = parsePairingCode(params.code)
if (!parsed) {
setStatus('error')
setErrorMessage('Not a valid pairing code')
return
}
setOffer(parsed)
}, [params.code])
// Why: pairing attempts can outlive the visible route; dispose them when
// the confirm screen detaches without a passive cleanup-only Effect.
mountedRef.current = false
activePairingAttemptRef.current?.dispose()
activePairingAttemptRef.current = null
}, [])
async function confirm() {
if (!offer) return
@@ -67,29 +78,40 @@ export default function PairConfirmScreen() {
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect" error.
let response: RpcResponse
let timedOut = false
const overallTimer = setTimeout(() => {
timedOut = true
client?.close()
}, PAIRING_OVERALL_TIMEOUT_MS)
const attempt = startPairingConnectionAttempt({
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
clearTimeout(overallTimer)
client.close()
client = null
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
activePairingAttemptRef.current = null
}
if (!mountedRef.current || !attemptIsCurrent) return
} catch (err) {
clearTimeout(overallTimer)
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
activePairingAttemptRef.current = null
}
if (!mountedRef.current || !attemptIsCurrent) return
console.warn('[pair-confirm] connect failed', err)
setStatus('error')
setErrorMessage(
@@ -97,11 +119,11 @@ export default function PairConfirmScreen() {
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
: 'Cannot connect — check that your computer is on the same network'
)
client?.close()
return
}
if (!response.ok) {
if (!mountedRef.current) return
setStatus('error')
setErrorMessage(
response.error.code === 'unauthorized'
@@ -122,8 +144,10 @@ export default function PairConfirmScreen() {
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) return
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) return
console.warn('[pair-confirm] save failed', err)
setStatus('error')
setErrorMessage(
@@ -135,13 +159,13 @@ export default function PairConfirmScreen() {
const containerPadding = { paddingTop: insets.top + spacing.sm }
return (
<View style={[styles.container, containerPadding]}>
<View ref={setPairConfirmRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={cancel}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.content}>
{offer && status === 'awaiting-confirm' && (
{offer && resolvedStatus === 'awaiting-confirm' && (
<>
<Text style={styles.title}>Pair with this desktop?</Text>
<Text style={styles.subtitle}>
@@ -156,7 +180,7 @@ export default function PairConfirmScreen() {
</>
)}
{status === 'connecting' && (
{resolvedStatus === 'connecting' && (
<>
<ActivityIndicator size="large" color={colors.textSecondary} />
<Text style={styles.connectingText}>Connecting…</Text>
@@ -166,9 +190,9 @@ export default function PairConfirmScreen() {
</>
)}
{status === 'error' && (
{resolvedStatus === 'error' && (
<>
<Text style={styles.errorText}>{errorMessage}</Text>
<Text style={styles.errorText}>{resolvedErrorMessage}</Text>
{logs.length > 0 && (
<View style={styles.logSlot}>
<ConnectionLog entries={logs} title="Pairing log" />
+44 -13
View File
@@ -5,6 +5,10 @@ import { CameraView, useCameraPermissions } from 'expo-camera'
import { useRouter } from 'expo-router'
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
import {
startPairingConnectionAttempt,
type PairingConnectionAttempt
} from '../src/transport/pairing-connection-attempt'
import { connect } from '../src/transport/rpc-client'
import { saveHost, getNextHostName } from '../src/transport/host-store'
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
@@ -38,6 +42,20 @@ export default function PairScanScreen() {
const [logs, setLogs] = useState<ConnectionLogEntry[]>([])
const logsRef = useRef<ConnectionLogEntry[]>([])
const processingRef = useRef(false)
const mountedRef = useRef(true)
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
const setPairScanRootRef = useCallback((node: View | null): void => {
if (node !== null) {
mountedRef.current = true
return
}
// Why: pairing attempts can outlive the visible route; dispose them when
// the scan screen detaches without a passive cleanup-only Effect.
mountedRef.current = false
activePairingAttemptRef.current?.dispose()
activePairingAttemptRef.current = null
}, [])
const handleBarCodeScanned = useCallback(
({ data }: { data: string }) => {
@@ -78,30 +96,41 @@ export default function PairScanScreen() {
logsRef.current = []
setLogs([])
let client: ReturnType<typeof connect> | null = null
activePairingAttemptRef.current?.dispose()
// Why: split the try/catch around the network call vs the local save
// so a Keychain or AsyncStorage failure doesn't masquerade as a
// "Cannot connect — same network?" error. Pairing reached the
// desktop fine; the failure is local persistence.
let response: RpcResponse
let timedOut = false
const overallTimer = setTimeout(() => {
timedOut = true
client?.close()
}, PAIRING_OVERALL_TIMEOUT_MS)
const attempt = startPairingConnectionAttempt({
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
closeClient: () => client?.close()
})
activePairingAttemptRef.current = attempt
try {
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
onLog: (entry) => {
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) return
logsRef.current = [...logsRef.current, entry]
setLogs(logsRef.current)
}
})
response = await client.sendRequest('status.get')
clearTimeout(overallTimer)
client.close()
client = null
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
activePairingAttemptRef.current = null
}
if (!mountedRef.current || !attemptIsCurrent) return
} catch (err) {
clearTimeout(overallTimer)
const timedOut = attempt.timedOut
const attemptIsCurrent = activePairingAttemptRef.current === attempt
attempt.dispose()
if (activePairingAttemptRef.current === attempt) {
activePairingAttemptRef.current = null
}
if (!mountedRef.current || !attemptIsCurrent) return
console.warn('[pair] connect failed', err)
setStatus('error')
setErrorMessage(
@@ -110,11 +139,11 @@ export default function PairScanScreen() {
: 'Cannot connect — check that your computer is on the same network'
)
processingRef.current = false
client?.close()
return
}
if (!response.ok) {
if (!mountedRef.current) return
if (response.error.code === 'unauthorized') {
setStatus('error')
setErrorMessage('Authentication failed — token may be expired')
@@ -138,8 +167,10 @@ export default function PairScanScreen() {
publicKeyB64: offer.publicKeyB64,
lastConnected: Date.now()
})
if (!mountedRef.current) return
router.replace(`/h/${hostId}`)
} catch (err) {
if (!mountedRef.current) return
console.warn('[pair] save failed', err)
setStatus('error')
setErrorMessage(
@@ -167,7 +198,7 @@ export default function PairScanScreen() {
if (!permission) {
return (
<View style={[styles.container, containerPadding]}>
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
@@ -176,7 +207,7 @@ export default function PairScanScreen() {
if (!permission.granted) {
const canAskAgain = permission.canAskAgain !== false
return (
<View style={[styles.container, containerPadding]}>
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
@@ -219,7 +250,7 @@ export default function PairScanScreen() {
}
return (
<View style={[styles.container, containerPadding]}>
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
+19 -16
View File
@@ -35,6 +35,7 @@ import {
saveTerminalAccessoryLayout,
setTerminalAccessoryBuiltInVisible
} from '../src/terminal/terminal-accessory-layout'
import { setTerminalAutoRestoreFitMsForHost } from '../src/terminal/terminal-auto-restore-fit-state'
type RestoreValue = 'indefinite' | '60s' | '5m' | '30m'
@@ -136,6 +137,10 @@ export default function TerminalSettingsScreen() {
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const hostClientsById = useMemo(
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
[hostClients]
)
const [customKeys, setCustomKeys] = useState<CustomKey[]>([])
const [showCustomKeyModal, setShowCustomKeyModal] = useState(false)
@@ -224,47 +229,45 @@ export default function TerminalSettingsScreen() {
useEffect(() => {
let cancelled = false
for (const host of hosts) {
const entry = hostClients.find((e) => e.hostId === host.id)
const client = entry?.client ?? null
const client = hostClientsById.get(host.id) ?? null
if (!client) continue
void client
.sendRequest('terminal.getAutoRestoreFit')
.then((resp) => {
if (cancelled) return
const value = (resp as { ms?: number | null } | null)?.ms
setHostMs((prev) => ({ ...prev, [host.id]: value === undefined ? null : value }))
// Why: reconnect/status ticks can replay the same value; preserving
// object identity avoids rerendering every settings row again.
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, value))
})
.catch(() => {
if (!cancelled) setHostMs((prev) => ({ ...prev, [host.id]: null }))
if (!cancelled) {
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, host.id, null))
}
})
}
return () => {
cancelled = true
}
}, [hosts, hostClients])
}, [hosts, hostClientsById])
async function selectValue(hostId: string, value: RestoreValue) {
const entry = hostClients.find((e) => e.hostId === hostId)
const client = entry?.client ?? null
const client = hostClientsById.get(hostId) ?? null
if (!client) return
const opt = AUTO_RESTORE_FIT_OPTIONS.find((o) => o.value === value)
if (!opt) return
setHostMs((prev) => ({ ...prev, [hostId]: opt.ms }))
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, opt.ms))
try {
const resp = (await client.sendRequest('terminal.setAutoRestoreFit', {
ms: opt.ms
})) as { ms?: number | null } | null
const finalMs = resp?.ms === undefined ? null : resp.ms
setHostMs((prev) => ({ ...prev, [hostId]: finalMs }))
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
} catch {
try {
const resp = (await client.sendRequest('terminal.getAutoRestoreFit')) as {
ms?: number | null
} | null
setHostMs((prev) => ({
...prev,
[hostId]: resp?.ms === undefined ? null : resp.ms
}))
setHostMs((prev) => setTerminalAutoRestoreFitMsForHost(prev, hostId, resp?.ms))
} catch {
// give up silently — the next mount retries
}
@@ -301,12 +304,12 @@ export default function TerminalSettingsScreen() {
) : (
<View style={[styles.section, styles.sectionTopGap]}>
{hosts.map((host, idx) => {
const entry = hostClients.find((e) => e.hostId === host.id)
const client = hostClientsById.get(host.id) ?? null
return (
<View key={host.id}>
{idx > 0 && <View style={styles.separator} />}
<HostFitRow
client={entry?.client ?? null}
client={client}
hostName={host.name}
ms={hostMs[host.id]}
onPress={() => setPickerHostId(host.id)}
+41 -47
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useState, useCallback, useRef } from 'react'
import {
View,
Text,
@@ -26,6 +26,11 @@ import {
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import {
startDiagnosticFetchTimeout,
type DiagnosticFetchTimeout
} from '../src/diagnostics/diagnostic-fetch-timeout'
import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability'
type DiagnosticStatus = 'idle' | 'running' | 'done'
@@ -113,11 +118,19 @@ export default function TroubleshootScreen() {
const [diagnosticStatus, setDiagnosticStatus] = useState<DiagnosticStatus>('idle')
const [checks, setChecks] = useState<CheckResult[]>([])
const abortRef = useRef(false)
const diagnosticRunRef = useRef(0)
const activeInternetCheckRef = useRef<DiagnosticFetchTimeout | null>(null)
useEffect(() => {
return () => {
abortRef.current = true
const setTroubleshootRootRef = useCallback((node: View | null): void => {
if (node !== null) {
return
}
// Why: diagnostics can outlive the screen; cancel the active run when the
// route detaches without a passive cleanup-only Effect.
abortRef.current = true
diagnosticRunRef.current += 1
activeInternetCheckRef.current?.dispose()
activeInternetCheckRef.current = null
}, [])
const toggleSection = useCallback((id: string) => {
@@ -125,11 +138,16 @@ export default function TroubleshootScreen() {
}, [])
const runDiagnostics = useCallback(async () => {
const runId = diagnosticRunRef.current + 1
diagnosticRunRef.current = runId
abortRef.current = false
activeInternetCheckRef.current?.dispose()
activeInternetCheckRef.current = null
setDiagnosticStatus('running')
setChecks([])
const results: CheckResult[] = []
const isCurrentRun = () => !abortRef.current && diagnosticRunRef.current === runId
try {
const hosts = await loadHosts()
@@ -142,33 +160,40 @@ export default function TroubleshootScreen() {
results.push({ label: 'Paired hosts', status: 'warn', detail: 'Could not read host data' })
}
if (abortRef.current) return
if (!isCurrentRun()) return
setChecks([...results])
const internetCheck = startDiagnosticFetchTimeout(5000)
activeInternetCheckRef.current = internetCheck
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const resp = await fetch('https://dns.google/resolve?name=example.com&type=A', {
signal: controller.signal
signal: internetCheck.signal
})
clearTimeout(timeout)
if (!isCurrentRun()) return
results.push(
resp.ok
? { label: 'Internet', status: 'pass', detail: 'Connected' }
: { label: 'Internet', status: 'warn', detail: 'Unexpected response' }
)
} catch {
if (!isCurrentRun()) return
results.push({ label: 'Internet', status: 'fail', detail: 'No connection' })
} finally {
internetCheck.dispose()
if (activeInternetCheckRef.current === internetCheck) {
activeInternetCheckRef.current = null
}
}
if (abortRef.current) return
if (!isCurrentRun()) return
setChecks([...results])
try {
const hosts = await loadHosts()
for (const host of hosts) {
if (abortRef.current) return
if (!isCurrentRun()) return
const reachable = await testHostReachability(host.endpoint)
if (!isCurrentRun()) return
results.push({
label: host.name,
status: reachable ? 'pass' : 'fail',
@@ -182,7 +207,7 @@ export default function TroubleshootScreen() {
results.push({ label: 'Hosts', status: 'warn', detail: 'Could not test' })
}
if (abortRef.current) return
if (!isCurrentRun()) return
results.push({
label: 'Platform',
@@ -195,7 +220,10 @@ export default function TroubleshootScreen() {
}, [])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View
ref={setTroubleshootRootRef}
style={[styles.container, { paddingTop: insets.top + spacing.sm }]}
>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
@@ -288,40 +316,6 @@ export default function TroubleshootScreen() {
)
}
// Why: WebSocket reachability is tested by opening a connection and waiting for
// the server to respond with any frame, or timing out after 4 seconds.
// We don't complete the E2EE handshake — just verify the endpoint is listening.
async function testHostReachability(endpoint: string): Promise<boolean> {
return new Promise((resolve) => {
const timeout = setTimeout(() => {
ws.close()
resolve(false)
}, 4000)
const ws = new WebSocket(endpoint)
ws.onopen = () => {
clearTimeout(timeout)
ws.close()
resolve(true)
}
ws.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
}
function formatEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
+2 -2
View File
@@ -52,8 +52,8 @@
"@types/react-native": "^0.73.0",
"@types/ws": "^8.18.1",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.47.0",
"oxlint": "^1.63.0",
"oxfmt": "^0.52.0",
"oxlint": "^1.67.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vitest": "^4.1.5"
@@ -328,11 +328,6 @@ class AudioEngine (context: Context) {
audioSampleQueue.add(data)
playbackEvents += 1
playbackQueuedBytes += data.size.toLong()
Log.d(
"AudioEngine",
"playPCMData queued bytes=${data.size} queueSize=${audioSampleQueue.size} " +
"head=${data.take(12).joinToString(" ") { byte -> "%02x".format(byte.toInt() and 0xff) }}"
)
flushBridgeStats("queue")
if (playbackRunning.compareAndSet(false, true)) {
playAudioFromSampleQueue()
@@ -367,11 +362,6 @@ class AudioEngine (context: Context) {
val written = audioTrack.write(data, 0, data.size)
playbackWrites += 1
playbackWriteBytes += written.coerceAtLeast(0).toLong()
Log.d(
"AudioEngine",
"playSample wrote=$written requested=${data.size} playState=${audioTrack.playState} " +
"queueSize=${audioSampleQueue.size}"
)
flushBridgeStats("write")
}
@@ -455,18 +445,18 @@ class AudioEngine (context: Context) {
private fun calculateRMSLevel(buffer: ByteArray): Float {
val epsilon = 1e-5f // To avoid log(0)
// Convert ByteArray to FloatArray by treating each pair of bytes as a single 16-bit PCM sample
val floatBuffer = FloatArray(buffer.size / 2)
for (i in floatBuffer.indices) {
// Combine two bytes into a 16-bit signed integer
val sampleCount = buffer.size / 2
var sumSquares = 0.0f
for (i in 0 until sampleCount) {
// Why: preserve Android's existing signed-byte decode while avoiding
// a temporary sample array on every mic/playback audio buffer.
val sample = (buffer[i * 2].toInt() or (buffer[i * 2 + 1].toInt() shl 8)).toShort()
// Normalize sample to -1.0 to 1.0 range for FloatArray
floatBuffer[i] = sample / 32768.0f
val normalizedSample = sample / 32768.0f
sumSquares += normalizedSample * normalizedSample
}
// Calculate RMS value
val rmsValue = kotlin.math.sqrt(floatBuffer.fold(0f) { acc, sample -> acc + sample * sample } / floatBuffer.size)
val rmsValue = kotlin.math.sqrt(sumSquares / sampleCount)
// Convert to decibels
val dbValue = 20 * kotlin.math.log10(maxOf(rmsValue, epsilon))
@@ -12,15 +12,21 @@ export const useMicrophonePermissions = createPermissionHook({
requestMethod: requestMicrophonePermissionsAsync
})
export function useIsRecording() {
const subscribe = (cb: () => void) => {
const sub = addExpoTwoWayAudioEventListener('onRecordingChange', cb)
return () => sub.remove()
}
const getSnapshot = () => isRecording()
const getServerSnapshot = () => false
// Why: useSyncExternalStore resubscribes when these identities change; keep
// the native recording listener stable across component re-renders.
const subscribeToRecordingChanges = (cb: () => void) => {
const sub = addExpoTwoWayAudioEventListener('onRecordingChange', cb)
return () => sub.remove()
}
const getRecordingSnapshot = () => isRecording()
const getServerRecordingSnapshot = () => false
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
export function useIsRecording() {
return useSyncExternalStore(
subscribeToRecordingChanges,
getRecordingSnapshot,
getServerRecordingSnapshot
)
}
export function useExpoTwoWayAudioEventListener<K extends keyof ExpoTwoWayAudioEventMap>(
+189 -162
View File
@@ -121,11 +121,11 @@ importers:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.7)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.8.0))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.0)(@react-native/metro-config@0.85.2(@babel/core@7.29.0))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.0(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.47.0
version: 0.47.0
specifier: ^0.52.0
version: 0.52.0
oxlint:
specifier: ^1.63.0
version: 1.63.0
specifier: ^1.67.0
version: 1.67.0
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -1374,230 +1374,246 @@ packages:
'@oxc-project/types@0.129.0':
resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==}
'@oxfmt/binding-android-arm-eabi@0.47.0':
resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==}
'@oxfmt/binding-android-arm-eabi@0.52.0':
resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxfmt/binding-android-arm64@0.47.0':
resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==}
'@oxfmt/binding-android-arm64@0.52.0':
resolution: {integrity: sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxfmt/binding-darwin-arm64@0.47.0':
resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==}
'@oxfmt/binding-darwin-arm64@0.52.0':
resolution: {integrity: sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxfmt/binding-darwin-x64@0.47.0':
resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==}
'@oxfmt/binding-darwin-x64@0.52.0':
resolution: {integrity: sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxfmt/binding-freebsd-x64@0.47.0':
resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==}
'@oxfmt/binding-freebsd-x64@0.52.0':
resolution: {integrity: sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==}
'@oxfmt/binding-linux-arm-gnueabihf@0.52.0':
resolution: {integrity: sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==}
'@oxfmt/binding-linux-arm-musleabihf@0.52.0':
resolution: {integrity: sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==}
'@oxfmt/binding-linux-arm64-gnu@0.52.0':
resolution: {integrity: sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==}
'@oxfmt/binding-win32-arm64-msvc@0.52.0':
resolution: {integrity: sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==}
'@oxfmt/binding-win32-ia32-msvc@0.52.0':
resolution: {integrity: sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxfmt/binding-win32-x64-msvc@0.47.0':
resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==}
'@oxfmt/binding-win32-x64-msvc@0.52.0':
resolution: {integrity: sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxlint/binding-android-arm-eabi@1.63.0':
resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==}
'@oxlint/binding-android-arm-eabi@1.67.0':
resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.63.0':
resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==}
'@oxlint/binding-android-arm64@1.67.0':
resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.63.0':
resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==}
'@oxlint/binding-darwin-arm64@1.67.0':
resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.63.0':
resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==}
'@oxlint/binding-darwin-x64@1.67.0':
resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.63.0':
resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==}
'@oxlint/binding-freebsd-x64@1.67.0':
resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.63.0':
resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==}
'@oxlint/binding-linux-arm-gnueabihf@1.67.0':
resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.63.0':
resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==}
'@oxlint/binding-linux-arm-musleabihf@1.67.0':
resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.63.0':
resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==}
'@oxlint/binding-linux-arm64-gnu@1.67.0':
resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.63.0':
resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==}
'@oxlint/binding-linux-arm64-musl@1.67.0':
resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.63.0':
resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==}
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.63.0':
resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==}
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.63.0':
resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==}
'@oxlint/binding-linux-riscv64-musl@1.67.0':
resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.63.0':
resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==}
'@oxlint/binding-linux-s390x-gnu@1.67.0':
resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.63.0':
resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==}
'@oxlint/binding-linux-x64-gnu@1.67.0':
resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.63.0':
resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==}
'@oxlint/binding-linux-x64-musl@1.67.0':
resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.63.0':
resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==}
'@oxlint/binding-openharmony-arm64@1.67.0':
resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.63.0':
resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==}
'@oxlint/binding-win32-arm64-msvc@1.67.0':
resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.63.0':
resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==}
'@oxlint/binding-win32-ia32-msvc@1.67.0':
resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.63.0':
resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==}
'@oxlint/binding-win32-x64-msvc@1.67.0':
resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
@@ -4721,20 +4737,31 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxfmt@0.47.0:
resolution: {integrity: sha512-OFbkbzxKCpooQEnRmpTDnuwTX8KHXzZTQ4Df/hz85fpS67Pl+lxPEFvUtin56HIIS0B1k4X8oIzTXRZPufA2CA==}
oxfmt@0.52.0:
resolution: {integrity: sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
svelte: ^5.0.0
vite-plus: '*'
peerDependenciesMeta:
svelte:
optional: true
vite-plus:
optional: true
oxlint@1.63.0:
resolution: {integrity: sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg==}
oxlint@1.67.0:
resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=0.22.1'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
@@ -7638,118 +7665,118 @@ snapshots:
'@oxc-project/types@0.129.0': {}
'@oxfmt/binding-android-arm-eabi@0.47.0':
'@oxfmt/binding-android-arm-eabi@0.52.0':
optional: true
'@oxfmt/binding-android-arm64@0.47.0':
'@oxfmt/binding-android-arm64@0.52.0':
optional: true
'@oxfmt/binding-darwin-arm64@0.47.0':
'@oxfmt/binding-darwin-arm64@0.52.0':
optional: true
'@oxfmt/binding-darwin-x64@0.47.0':
'@oxfmt/binding-darwin-x64@0.52.0':
optional: true
'@oxfmt/binding-freebsd-x64@0.47.0':
'@oxfmt/binding-freebsd-x64@0.52.0':
optional: true
'@oxfmt/binding-linux-arm-gnueabihf@0.47.0':
'@oxfmt/binding-linux-arm-gnueabihf@0.52.0':
optional: true
'@oxfmt/binding-linux-arm-musleabihf@0.47.0':
'@oxfmt/binding-linux-arm-musleabihf@0.52.0':
optional: true
'@oxfmt/binding-linux-arm64-gnu@0.47.0':
'@oxfmt/binding-linux-arm64-gnu@0.52.0':
optional: true
'@oxfmt/binding-linux-arm64-musl@0.47.0':
'@oxfmt/binding-linux-arm64-musl@0.52.0':
optional: true
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
optional: true
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
optional: true
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
optional: true
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
optional: true
'@oxfmt/binding-linux-x64-gnu@0.47.0':
'@oxfmt/binding-linux-x64-gnu@0.52.0':
optional: true
'@oxfmt/binding-linux-x64-musl@0.47.0':
'@oxfmt/binding-linux-x64-musl@0.52.0':
optional: true
'@oxfmt/binding-openharmony-arm64@0.47.0':
'@oxfmt/binding-openharmony-arm64@0.52.0':
optional: true
'@oxfmt/binding-win32-arm64-msvc@0.47.0':
'@oxfmt/binding-win32-arm64-msvc@0.52.0':
optional: true
'@oxfmt/binding-win32-ia32-msvc@0.47.0':
'@oxfmt/binding-win32-ia32-msvc@0.52.0':
optional: true
'@oxfmt/binding-win32-x64-msvc@0.47.0':
'@oxfmt/binding-win32-x64-msvc@0.52.0':
optional: true
'@oxlint/binding-android-arm-eabi@1.63.0':
'@oxlint/binding-android-arm-eabi@1.67.0':
optional: true
'@oxlint/binding-android-arm64@1.63.0':
'@oxlint/binding-android-arm64@1.67.0':
optional: true
'@oxlint/binding-darwin-arm64@1.63.0':
'@oxlint/binding-darwin-arm64@1.67.0':
optional: true
'@oxlint/binding-darwin-x64@1.63.0':
'@oxlint/binding-darwin-x64@1.67.0':
optional: true
'@oxlint/binding-freebsd-x64@1.63.0':
'@oxlint/binding-freebsd-x64@1.67.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.63.0':
'@oxlint/binding-linux-arm-gnueabihf@1.67.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.63.0':
'@oxlint/binding-linux-arm-musleabihf@1.67.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.63.0':
'@oxlint/binding-linux-arm64-gnu@1.67.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.63.0':
'@oxlint/binding-linux-arm64-musl@1.67.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.63.0':
'@oxlint/binding-linux-ppc64-gnu@1.67.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.63.0':
'@oxlint/binding-linux-riscv64-gnu@1.67.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.63.0':
'@oxlint/binding-linux-riscv64-musl@1.67.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.63.0':
'@oxlint/binding-linux-s390x-gnu@1.67.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.63.0':
'@oxlint/binding-linux-x64-gnu@1.67.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.63.0':
'@oxlint/binding-linux-x64-musl@1.67.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.63.0':
'@oxlint/binding-openharmony-arm64@1.67.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.63.0':
'@oxlint/binding-win32-arm64-msvc@1.67.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.63.0':
'@oxlint/binding-win32-ia32-msvc@1.67.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.63.0':
'@oxlint/binding-win32-x64-msvc@1.67.0':
optional: true
'@pkgr/core@0.2.9': {}
@@ -11759,51 +11786,51 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
oxfmt@0.47.0:
oxfmt@0.52.0:
dependencies:
tinypool: 2.1.0
optionalDependencies:
'@oxfmt/binding-android-arm-eabi': 0.47.0
'@oxfmt/binding-android-arm64': 0.47.0
'@oxfmt/binding-darwin-arm64': 0.47.0
'@oxfmt/binding-darwin-x64': 0.47.0
'@oxfmt/binding-freebsd-x64': 0.47.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.47.0
'@oxfmt/binding-linux-arm-musleabihf': 0.47.0
'@oxfmt/binding-linux-arm64-gnu': 0.47.0
'@oxfmt/binding-linux-arm64-musl': 0.47.0
'@oxfmt/binding-linux-ppc64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-gnu': 0.47.0
'@oxfmt/binding-linux-riscv64-musl': 0.47.0
'@oxfmt/binding-linux-s390x-gnu': 0.47.0
'@oxfmt/binding-linux-x64-gnu': 0.47.0
'@oxfmt/binding-linux-x64-musl': 0.47.0
'@oxfmt/binding-openharmony-arm64': 0.47.0
'@oxfmt/binding-win32-arm64-msvc': 0.47.0
'@oxfmt/binding-win32-ia32-msvc': 0.47.0
'@oxfmt/binding-win32-x64-msvc': 0.47.0
'@oxfmt/binding-android-arm-eabi': 0.52.0
'@oxfmt/binding-android-arm64': 0.52.0
'@oxfmt/binding-darwin-arm64': 0.52.0
'@oxfmt/binding-darwin-x64': 0.52.0
'@oxfmt/binding-freebsd-x64': 0.52.0
'@oxfmt/binding-linux-arm-gnueabihf': 0.52.0
'@oxfmt/binding-linux-arm-musleabihf': 0.52.0
'@oxfmt/binding-linux-arm64-gnu': 0.52.0
'@oxfmt/binding-linux-arm64-musl': 0.52.0
'@oxfmt/binding-linux-ppc64-gnu': 0.52.0
'@oxfmt/binding-linux-riscv64-gnu': 0.52.0
'@oxfmt/binding-linux-riscv64-musl': 0.52.0
'@oxfmt/binding-linux-s390x-gnu': 0.52.0
'@oxfmt/binding-linux-x64-gnu': 0.52.0
'@oxfmt/binding-linux-x64-musl': 0.52.0
'@oxfmt/binding-openharmony-arm64': 0.52.0
'@oxfmt/binding-win32-arm64-msvc': 0.52.0
'@oxfmt/binding-win32-ia32-msvc': 0.52.0
'@oxfmt/binding-win32-x64-msvc': 0.52.0
oxlint@1.63.0:
oxlint@1.67.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.63.0
'@oxlint/binding-android-arm64': 1.63.0
'@oxlint/binding-darwin-arm64': 1.63.0
'@oxlint/binding-darwin-x64': 1.63.0
'@oxlint/binding-freebsd-x64': 1.63.0
'@oxlint/binding-linux-arm-gnueabihf': 1.63.0
'@oxlint/binding-linux-arm-musleabihf': 1.63.0
'@oxlint/binding-linux-arm64-gnu': 1.63.0
'@oxlint/binding-linux-arm64-musl': 1.63.0
'@oxlint/binding-linux-ppc64-gnu': 1.63.0
'@oxlint/binding-linux-riscv64-gnu': 1.63.0
'@oxlint/binding-linux-riscv64-musl': 1.63.0
'@oxlint/binding-linux-s390x-gnu': 1.63.0
'@oxlint/binding-linux-x64-gnu': 1.63.0
'@oxlint/binding-linux-x64-musl': 1.63.0
'@oxlint/binding-openharmony-arm64': 1.63.0
'@oxlint/binding-win32-arm64-msvc': 1.63.0
'@oxlint/binding-win32-ia32-msvc': 1.63.0
'@oxlint/binding-win32-x64-msvc': 1.63.0
'@oxlint/binding-android-arm-eabi': 1.67.0
'@oxlint/binding-android-arm64': 1.67.0
'@oxlint/binding-darwin-arm64': 1.67.0
'@oxlint/binding-darwin-x64': 1.67.0
'@oxlint/binding-freebsd-x64': 1.67.0
'@oxlint/binding-linux-arm-gnueabihf': 1.67.0
'@oxlint/binding-linux-arm-musleabihf': 1.67.0
'@oxlint/binding-linux-arm64-gnu': 1.67.0
'@oxlint/binding-linux-arm64-musl': 1.67.0
'@oxlint/binding-linux-ppc64-gnu': 1.67.0
'@oxlint/binding-linux-riscv64-gnu': 1.67.0
'@oxlint/binding-linux-riscv64-musl': 1.67.0
'@oxlint/binding-linux-s390x-gnu': 1.67.0
'@oxlint/binding-linux-x64-gnu': 1.67.0
'@oxlint/binding-linux-x64-musl': 1.67.0
'@oxlint/binding-openharmony-arm64': 1.67.0
'@oxlint/binding-win32-arm64-msvc': 1.67.0
'@oxlint/binding-win32-ia32-msvc': 1.67.0
'@oxlint/binding-win32-x64-msvc': 1.67.0
p-limit@2.3.0:
dependencies:
+27 -6
View File
@@ -47,6 +47,7 @@ import {
type BrowserZoomState
} from './browser-touch-geometry'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
export type MobileBrowserTab = {
type: 'browser'
@@ -135,6 +136,10 @@ export function MobileBrowserPane({
const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
focused: false,
url: tab.url
})
const [keyboardValue, setKeyboardValue] = useState('')
const [frameUri, setFrameUri] = useState<string | null>(cachedInitialFrame?.uri ?? null)
const [frameMetadata, setFrameMetadata] = useState<BrowserScreencastFrameMetadata | null>(
@@ -188,6 +193,17 @@ export function MobileBrowserPane({
}
}, [])
const setRootViewRef = useCallback(
(node: View | null) => {
// Why: long-press right-click timers belong to this responder surface;
// clearing from ref cleanup preserves the same unmount boundary.
if (node === null) {
clearLongPressTimer()
}
},
[clearLongPressTimer]
)
const resetBrowserZoomState = useCallback(() => {
clearLongPressTimer()
pinchRef.current = null
@@ -211,11 +227,18 @@ export function MobileBrowserPane({
}
}, [worktreeId])
useEffect(() => {
if (!addressFocused) {
const addressSync = resolveMobileBrowserAddressSync(addressSyncState, {
focused: addressFocused,
url: tab.url
})
if (addressSync.nextState !== addressSyncState) {
setAddressSyncState(addressSync.nextState)
if (addressSync.shouldSyncValue) {
// Why: keep browser stream/goto address updates intact, but avoid a
// stale post-blur paint when the tab URL is the source of truth.
setAddressValue(displayBrowserUrl(tab.url))
}
}, [addressFocused, tab.url])
}
useLayoutEffect(() => {
// Why: gesture and stream handlers need committed values before passive
@@ -694,8 +717,6 @@ export function MobileBrowserPane({
[client, flushPendingWheelCommand, pageParams]
)
useEffect(() => () => clearLongPressTimer(), [clearLongPressTimer])
const mapTouchPoint = useCallback((locationX: number, locationY: number): BrowserPoint | null => {
return mapScreenToBrowserPoint(
locationX,
@@ -1017,7 +1038,7 @@ export function MobileBrowserPane({
)
return (
<View style={styles.root}>
<View ref={setRootViewRef} style={styles.root}>
<View style={styles.toolbar}>
<ToolbarIconButton
disabled={controlsDisabled || !tab.canGoBack}
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
describe('resolveMobileBrowserAddressSync', () => {
it('syncs the tab URL when the input is not focused', () => {
const result = resolveMobileBrowserAddressSync(
{ focused: false, url: 'https://old.example/' },
{ focused: false, url: 'https://new.example/' }
)
expect(result).toEqual({
nextState: { focused: false, url: 'https://new.example/' },
shouldSyncValue: true
})
})
it('defers tab URL sync while the user is editing', () => {
const result = resolveMobileBrowserAddressSync(
{ focused: false, url: 'https://old.example/' },
{ focused: true, url: 'https://new.example/' }
)
expect(result).toEqual({
nextState: { focused: true, url: 'https://new.example/' },
shouldSyncValue: false
})
})
it('syncs the latest tab URL when editing ends', () => {
const result = resolveMobileBrowserAddressSync(
{ focused: true, url: 'https://new.example/' },
{ focused: false, url: 'https://new.example/' }
)
expect(result).toEqual({
nextState: { focused: false, url: 'https://new.example/' },
shouldSyncValue: true
})
})
it('preserves externally updated address text when focus and tab URL are unchanged', () => {
const previous = { focused: false, url: 'https://new.example/' }
const result = resolveMobileBrowserAddressSync(previous, {
focused: false,
url: 'https://new.example/'
})
expect(result).toEqual({
nextState: previous,
shouldSyncValue: false
})
})
})
@@ -0,0 +1,20 @@
export type MobileBrowserAddressSyncState = {
focused: boolean
url: string
}
export type MobileBrowserAddressSyncResult = {
nextState: MobileBrowserAddressSyncState
shouldSyncValue: boolean
}
export function resolveMobileBrowserAddressSync(
previous: MobileBrowserAddressSyncState,
next: MobileBrowserAddressSyncState
): MobileBrowserAddressSyncResult {
const changed = previous.focused !== next.focused || previous.url !== next.url
return {
nextState: changed ? next : previous,
shouldSyncValue: changed && !next.focused
}
}
+8 -6
View File
@@ -22,6 +22,7 @@ import Animated, {
Extrapolation
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
@@ -50,16 +51,17 @@ export function BottomDrawer({
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
useEffect(() => {
if (visible) {
setMounted(true)
}
}, [visible])
// Why: opening drawers should mount before commit; waiting for a passive
// Effect adds a null render before every drawer can animate in.
if (resolvedMounted !== mounted) {
setMounted(resolvedMounted)
}
// Why: hidden drawers are rendered by parent screens even while closed; keep
// their Reanimated/Gesture setup out of hot paths like commit-message typing.
if (!mounted) return null
if (!resolvedMounted) return null
return (
<MountedBottomDrawer
+2 -9
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import type { ConnectionLogEntry } from '../transport/types'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
@@ -36,14 +36,6 @@ function formatTime(ts: number, baseTs: number): string {
export function ConnectionLog({ entries, title }: Props) {
const scrollRef = useRef<ScrollView | null>(null)
// Why: keep the latest entry visible while logs grow during a slow
// connect. Skip on empty so the title row doesn't jump.
useEffect(() => {
if (entries.length === 0) return
const id = setTimeout(() => scrollRef.current?.scrollToEnd({ animated: true }), 16)
return () => clearTimeout(id)
}, [entries.length])
if (entries.length === 0) return null
const baseTs = entries[0]!.ts
@@ -55,6 +47,7 @@ export function ConnectionLog({ entries, title }: Props) {
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onContentSizeChange={() => scrollRef.current?.scrollToEnd({ animated: true })}
>
{entries.map((entry) => (
<View key={entry.id} style={styles.row}>
+1 -1
View File
@@ -12,7 +12,7 @@ import {
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
export type CustomKey = {
id: string
+27 -67
View File
@@ -17,7 +17,6 @@ import { BottomDrawer } from './BottomDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
import {
isSetupHookTrusted,
@@ -27,13 +26,19 @@ import {
type SetupHookTrust
} from '../tasks/setup-hook-trust'
import {
filterEnabledMobileTuiAgents,
isMobileTuiAgent,
isMobileTuiAgentEnabled,
MOBILE_TUI_AGENT_LAUNCH_COMMANDS
} from '../tasks/mobile-tui-agents'
import type { PersistedTrustedOrcaHooks, TuiAgent } from '../../../src/shared/types'
import type { SshConnectionState } from '../../../src/shared/ssh-types'
import {
NEW_WORKTREE_AGENT_OPTIONS as AGENT_OPTIONS,
NEW_WORKTREE_BLANK_AGENT as BLANK_TERMINAL,
pickPreferredNewWorktreeAgent,
resolveNewWorktreeAgentSelection,
type NewWorktreeAgentOption as AgentOption
} from './new-worktree-agent-selection'
type Repo = {
id: string
@@ -71,51 +76,6 @@ type SetupTrustPrompt = {
previouslyApproved: boolean
}
type AgentOption = {
id: TuiAgent | '__blank__'
label: string
faviconDomain?: string
}
const AGENT_OPTIONS: AgentOption[] = MOBILE_AGENT_CATALOG
const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' }
function agentOptionFor(id: string | null | undefined): AgentOption | null {
if (!id) return null
if (id === 'blank' || id === '__blank__') return BLANK_TERMINAL
return AGENT_OPTIONS.find((agent) => agent.id === id) ?? null
}
function pickPreferredAgent(
settings: RuntimeSettings | null,
detectedAgentIds: Set<string> | null
): AgentOption {
const preferred = agentOptionFor(settings?.defaultTuiAgent)
if (preferred?.id === '__blank__') {
return preferred
}
if (
preferred &&
isMobileTuiAgent(preferred.id) &&
isMobileTuiAgentEnabled(preferred.id, settings?.disabledTuiAgents) &&
(detectedAgentIds === null || detectedAgentIds.has(preferred.id))
) {
return preferred
}
const enabledAgents = filterEnabledMobileTuiAgents(
MOBILE_AGENT_CATALOG.map((agent) => agent.id),
settings?.disabledTuiAgents
)
const detectedOption = AGENT_OPTIONS.find(
(agent) =>
agent.id !== '__blank__' &&
enabledAgents.includes(agent.id) &&
(detectedAgentIds === null || detectedAgentIds.has(agent.id))
)
return detectedOption ?? BLANK_TERMINAL
}
function repoColor(name: string): string {
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
let hash = 0
@@ -209,10 +169,10 @@ export function NewWorktreeModal({
const [repos, setRepos] = useState<Repo[]>([])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [selectedAgent, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<string> | null>(null)
const [agentOverridden, setAgentOverridden] = useState(false)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnecting, setSshConnecting] = useState(false)
@@ -246,6 +206,23 @@ export function NewWorktreeModal({
state: sshState,
connecting: sshConnecting
})
const selectedAgentResolution = resolveNewWorktreeAgentSelection({
visible,
selectedAgent: selectedAgentState,
agentOverridden: agentOverriddenState,
runtimeSettings,
detectedAgentIds
})
// Why: agent preference repair is pure render dataflow; doing it here
// avoids a stale selected-agent commit while preserving user overrides.
if (
selectedAgentState.id !== selectedAgentResolution.selectedAgent.id ||
agentOverriddenState !== selectedAgentResolution.agentOverridden
) {
setSelectedAgent(selectedAgentResolution.selectedAgent)
setAgentOverridden(selectedAgentResolution.agentOverridden)
}
const selectedAgent = selectedAgentResolution.selectedAgent
useEffect(() => {
if (!visible) {
@@ -383,23 +360,6 @@ export function NewWorktreeModal({
}
}, [client, selectedRepoConnectionId, sshGate.status, visible])
useEffect(() => {
if (!visible || agentOverridden) return
setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds))
}, [agentOverridden, detectedAgentIds, runtimeSettings, visible])
useEffect(() => {
if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return
if (
detectedAgentIds.has(selectedAgent.id) &&
isMobileTuiAgentEnabled(selectedAgent.id, runtimeSettings?.disabledTuiAgents)
) {
return
}
setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds))
setAgentOverridden(false)
}, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible])
useEffect(() => {
if (!client || !selectedRepo) {
setSetupCommand(null)
@@ -526,7 +486,7 @@ export function NewWorktreeModal({
selectedAgent.id !== '__blank__' &&
!isMobileTuiAgentEnabled(selectedAgent.id, latestRuntimeSettings?.disabledTuiAgents)
) {
setSelectedAgent(pickPreferredAgent(latestRuntimeSettings, detectedAgentIds))
setSelectedAgent(pickPreferredNewWorktreeAgent(latestRuntimeSettings, detectedAgentIds))
setAgentOverridden(false)
setError('Selected agent is disabled. Choose an enabled agent before creating.')
return
+68 -43
View File
@@ -23,6 +23,11 @@ type Props<T extends string = string> = {
zIndex?: number
}
type PickerModalContentProps<T extends string = string> = Pick<
Props<T>,
'options' | 'selected' | 'onSelect' | 'onLongSelect' | 'onClose'
>
export function PickerModal<T extends string = string>({
visible,
title,
@@ -39,53 +44,73 @@ export function PickerModal<T extends string = string>({
<Text style={styles.title}>{title}</Text>
</View>
<View style={styles.group}>
{options.map((opt, i) => {
const isSelected = opt.value === selected
return (
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
disabled={opt.disabled}
style={({ pressed }) => [
styles.row,
pressed && !opt.disabled && styles.rowPressed,
opt.disabled && styles.rowDisabled
]}
onPress={() => {
if (opt.disabled) return
onSelect(opt.value)
onClose()
}}
onLongPress={
onLongSelect
? () => {
if (opt.disabled) return
onLongSelect(opt.value)
onClose()
}
: undefined
}
>
{opt.renderIcon ? (
<View style={styles.rowIcon}>{opt.renderIcon(isSelected)}</View>
) : null}
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
{opt.label}
</Text>
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
</View>
{isSelected && <Check size={16} color={colors.textPrimary} />}
</Pressable>
</View>
)
})}
</View>
<PickerModalContent
options={options}
selected={selected}
onSelect={onSelect}
onLongSelect={onLongSelect}
onClose={onClose}
/>
</BottomDrawer>
)
}
function PickerModalContent<T extends string = string>({
options,
selected,
onSelect,
onLongSelect,
onClose
}: PickerModalContentProps<T>) {
// Why: closed BottomDrawer instances return null, so keeping option rows in
// this child avoids rebuilding hidden picker contents on every parent render.
return (
<View style={styles.group}>
{options.map((opt, i) => {
const isSelected = opt.value === selected
return (
<View key={opt.value}>
{i > 0 && <View style={styles.separator} />}
<Pressable
disabled={opt.disabled}
style={({ pressed }) => [
styles.row,
pressed && !opt.disabled && styles.rowPressed,
opt.disabled && styles.rowDisabled
]}
onPress={() => {
if (opt.disabled) return
onSelect(opt.value)
onClose()
}}
onLongPress={
onLongSelect
? () => {
if (opt.disabled) return
onLongSelect(opt.value)
onClose()
}
: undefined
}
>
{opt.renderIcon ? (
<View style={styles.rowIcon}>{opt.renderIcon(isSelected)}</View>
) : null}
<View style={styles.rowContent}>
<Text style={[styles.rowLabel, isSelected && styles.rowLabelSelected]}>
{opt.label}
</Text>
{opt.subtitle ? <Text style={styles.rowSubtitle}>{opt.subtitle}</Text> : null}
</View>
{isSelected && <Check size={16} color={colors.textPrimary} />}
</Pressable>
</View>
)
})}
</View>
)
}
const styles = StyleSheet.create({
header: {
paddingHorizontal: spacing.xs,
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
describe('resolveBottomDrawerMounted', () => {
it('mounts before opening and stays mounted while closing', () => {
expect(resolveBottomDrawerMounted(true, false)).toBe(true)
expect(resolveBottomDrawerMounted(true, true)).toBe(true)
expect(resolveBottomDrawerMounted(false, true)).toBe(true)
expect(resolveBottomDrawerMounted(false, false)).toBe(false)
})
})
@@ -0,0 +1,3 @@
export function resolveBottomDrawerMounted(visible: boolean, mounted: boolean): boolean {
return visible || mounted
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
NEW_WORKTREE_BLANK_AGENT,
newWorktreeAgentOptionFor,
pickPreferredNewWorktreeAgent,
resolveNewWorktreeAgentSelection
} from './new-worktree-agent-selection'
describe('new worktree agent selection', () => {
it('picks the preferred detected agent when there is no user override', () => {
const selected = newWorktreeAgentOptionFor('claude')
const resolved = resolveNewWorktreeAgentSelection({
visible: true,
selectedAgent: selected,
agentOverridden: false,
runtimeSettings: { defaultTuiAgent: 'codex' },
detectedAgentIds: new Set(['claude', 'codex'])
})
expect(resolved).toEqual({
selectedAgent: newWorktreeAgentOptionFor('codex'),
agentOverridden: false
})
})
it('keeps an available user override', () => {
const selected = newWorktreeAgentOptionFor('codex')
const resolved = resolveNewWorktreeAgentSelection({
visible: true,
selectedAgent: selected,
agentOverridden: true,
runtimeSettings: { defaultTuiAgent: 'claude' },
detectedAgentIds: new Set(['claude', 'codex'])
})
expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true })
})
it('clears an unavailable user override after detection completes', () => {
const resolved = resolveNewWorktreeAgentSelection({
visible: true,
selectedAgent: newWorktreeAgentOptionFor('codex'),
agentOverridden: true,
runtimeSettings: { defaultTuiAgent: 'claude' },
detectedAgentIds: new Set(['claude'])
})
expect(resolved).toEqual({
selectedAgent: newWorktreeAgentOptionFor('claude'),
agentOverridden: false
})
})
it('clears a disabled user override after detection completes', () => {
const resolved = resolveNewWorktreeAgentSelection({
visible: true,
selectedAgent: newWorktreeAgentOptionFor('codex'),
agentOverridden: true,
runtimeSettings: { defaultTuiAgent: 'claude', disabledTuiAgents: ['codex'] },
detectedAgentIds: new Set(['claude', 'codex'])
})
expect(resolved).toEqual({
selectedAgent: newWorktreeAgentOptionFor('claude'),
agentOverridden: false
})
})
it('keeps blank terminal as an explicit override', () => {
const resolved = resolveNewWorktreeAgentSelection({
visible: true,
selectedAgent: NEW_WORKTREE_BLANK_AGENT,
agentOverridden: true,
runtimeSettings: { defaultTuiAgent: 'claude' },
detectedAgentIds: new Set(['claude'])
})
expect(resolved).toEqual({
selectedAgent: NEW_WORKTREE_BLANK_AGENT,
agentOverridden: true
})
})
it('leaves closed modal state untouched', () => {
const selected = newWorktreeAgentOptionFor('codex')
const resolved = resolveNewWorktreeAgentSelection({
visible: false,
selectedAgent: selected,
agentOverridden: true,
runtimeSettings: { defaultTuiAgent: 'claude' },
detectedAgentIds: new Set(['claude'])
})
expect(resolved).toEqual({ selectedAgent: selected, agentOverridden: true })
})
it('uses blank when no detected agent is known', () => {
expect(pickPreferredNewWorktreeAgent({ defaultTuiAgent: null }, new Set()).id).toBe('__blank__')
})
})
@@ -0,0 +1,90 @@
import type { TuiAgent } from '../../../src/shared/types'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { isMobileTuiAgentEnabled } from '../tasks/mobile-tui-agents'
import { pickWorkspaceAgent } from '../tasks/workspace-agent-selection'
export type NewWorktreeRuntimeSettings = {
defaultTuiAgent?: TuiAgent | 'blank' | null
disabledTuiAgents?: TuiAgent[]
}
export type NewWorktreeAgentOption = {
id: TuiAgent | '__blank__'
label: string
faviconDomain?: string
}
export const NEW_WORKTREE_AGENT_OPTIONS: NewWorktreeAgentOption[] = MOBILE_AGENT_CATALOG
export const NEW_WORKTREE_BLANK_AGENT: NewWorktreeAgentOption = {
id: '__blank__',
label: 'Blank Terminal'
}
export function newWorktreeAgentOptionFor(id: string | null | undefined): NewWorktreeAgentOption {
if (id === 'blank' || id === '__blank__') {
return NEW_WORKTREE_BLANK_AGENT
}
return NEW_WORKTREE_AGENT_OPTIONS.find((agent) => agent.id === id) ?? NEW_WORKTREE_BLANK_AGENT
}
export function pickPreferredNewWorktreeAgent(
settings: NewWorktreeRuntimeSettings | null,
detectedAgentIds: Set<string> | null
): NewWorktreeAgentOption {
return newWorktreeAgentOptionFor(
pickWorkspaceAgent(
{
defaultTuiAgent: settings?.defaultTuiAgent,
disabledTuiAgents: settings?.disabledTuiAgents
},
detectedAgentIds
)
)
}
function isSelectableAgent(
agent: NewWorktreeAgentOption,
settings: NewWorktreeRuntimeSettings | null,
detectedAgentIds: Set<string> | null
): boolean {
if (agent.id === '__blank__') {
return true
}
if (!isMobileTuiAgentEnabled(agent.id, settings?.disabledTuiAgents)) {
return false
}
return detectedAgentIds === null || detectedAgentIds.has(agent.id)
}
export function resolveNewWorktreeAgentSelection({
visible,
selectedAgent,
agentOverridden,
runtimeSettings,
detectedAgentIds
}: {
visible: boolean
selectedAgent: NewWorktreeAgentOption
agentOverridden: boolean
runtimeSettings: NewWorktreeRuntimeSettings | null
detectedAgentIds: Set<string> | null
}): { selectedAgent: NewWorktreeAgentOption; agentOverridden: boolean } {
if (!visible) {
return { selectedAgent, agentOverridden }
}
const preferred = pickPreferredNewWorktreeAgent(runtimeSettings, detectedAgentIds)
if (!agentOverridden) {
return { selectedAgent: preferred, agentOverridden: false }
}
if (
detectedAgentIds !== null &&
!isSelectableAgent(selectedAgent, runtimeSettings, detectedAgentIds)
) {
return { selectedAgent: preferred, agentOverridden: false }
}
return { selectedAgent, agentOverridden: true }
}
@@ -13,7 +13,7 @@ function stripTrailingSeparators(p: string): string {
// Why: cross-platform path basename — handles both POSIX ("/") and Windows
// ("\\") separators, mirroring src/renderer/src/lib/path.ts so the mobile
// suggestion logic agrees with the desktop's collision check.
export function pathBasename(p: string): string {
function pathBasename(p: string): string {
const normalized = stripTrailingSeparators(p)
const idx = Math.max(normalized.lastIndexOf('/'), normalized.lastIndexOf('\\'))
return idx === -1 ? normalized : normalized.slice(idx + 1)
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startDiagnosticFetchTimeout } from './diagnostic-fetch-timeout'
describe('diagnostic fetch timeout', () => {
afterEach(() => {
vi.useRealTimers()
})
it('aborts when the timeout fires', () => {
vi.useFakeTimers()
const attempt = startDiagnosticFetchTimeout(5000)
expect(attempt.signal.aborted).toBe(false)
expect(attempt.timedOut).toBe(false)
vi.advanceTimersByTime(5000)
expect(attempt.signal.aborted).toBe(true)
expect(attempt.timedOut).toBe(true)
})
it('clears the timeout when disposed early', () => {
vi.useFakeTimers()
const attempt = startDiagnosticFetchTimeout(5000)
attempt.dispose()
expect(attempt.signal.aborted).toBe(true)
expect(attempt.timedOut).toBe(false)
vi.advanceTimersByTime(5000)
expect(attempt.timedOut).toBe(false)
})
})
@@ -0,0 +1,36 @@
export type DiagnosticFetchTimeout = {
readonly signal: AbortSignal
readonly timedOut: boolean
dispose: () => void
}
export function startDiagnosticFetchTimeout(timeoutMs: number): DiagnosticFetchTimeout {
const controller = new AbortController()
let disposed = false
let timedOut = false
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
timer = null
timedOut = true
controller.abort()
}, timeoutMs)
function dispose() {
if (disposed) return
disposed = true
if (timer) {
clearTimeout(timer)
timer = null
}
if (!controller.signal.aborted) {
controller.abort()
}
}
return {
signal: controller.signal,
get timedOut() {
return timedOut
},
dispose
}
}
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability } from './host-reachability'
describe('testHostReachability', () => {
it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => {
vi.useFakeTimers()
vi.stubGlobal(
'WebSocket',
class {
constructor() {
throw new TypeError('Invalid URL')
}
}
)
try {
await expect(testHostReachability('not-a-url')).resolves.toBe(false)
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.unstubAllGlobals()
vi.useRealTimers()
}
})
})
@@ -0,0 +1,58 @@
const HOST_REACHABILITY_TIMEOUT_MS = 4000
// Why: troubleshooting needs a cheap endpoint probe without completing the
// encrypted mobile runtime handshake.
export async function testHostReachability(endpoint: string): Promise<boolean> {
return new Promise((resolve) => {
let ws: WebSocket
try {
ws = new WebSocket(endpoint)
} catch {
resolve(false)
return
}
let settled = false
let timeout: ReturnType<typeof setTimeout> | null = null
const finish = (reachable: boolean, closeSocket: boolean): void => {
if (settled) {
return
}
settled = true
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (closeSocket) {
try {
ws.close()
} catch {
// The probe is already complete; close failures should not change the diagnostic result.
}
}
resolve(reachable)
}
timeout = setTimeout(() => {
finish(false, true)
}, HOST_REACHABILITY_TIMEOUT_MS)
ws.onopen = () => {
finish(true, true)
}
ws.onerror = () => {
finish(false, true)
}
})
}
export function formatEndpoint(endpoint: string): string {
try {
const url = new URL(endpoint)
return url.host
} catch {
return endpoint
}
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
describe('MobileDictationPendingAudioBudget', () => {
it('caps pending raw PCM audio at five seconds', () => {
expect(MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES).toBe(160_000)
const budget = new MobileDictationPendingAudioBudget()
expect(budget.tryReserve(159_999)).toBe(true)
expect(budget.tryReserve(1)).toBe(true)
expect(budget.pendingAudioBytes).toBe(160_000)
expect(budget.tryReserve(1)).toBe(false)
expect(budget.pendingAudioBytes).toBe(160_000)
})
it('releases completed chunks and clamps stale releases after reset', () => {
const budget = new MobileDictationPendingAudioBudget(10)
expect(budget.tryReserve(6)).toBe(true)
budget.release(4)
expect(budget.pendingAudioBytes).toBe(2)
expect(budget.tryReserve(8)).toBe(true)
budget.reset()
budget.release(6)
expect(budget.pendingAudioBytes).toBe(0)
expect(budget.tryReserve(10)).toBe(true)
})
})
@@ -0,0 +1,46 @@
export const MOBILE_DICTATION_PCM_SAMPLE_RATE = 16000
const PCM_BYTES_PER_SAMPLE = 2
const MAX_PENDING_AUDIO_SECONDS = 5
// Why: dictation chunk RPCs can wait through reconnect before timing out, so
// cap retained raw microphone audio before it is expanded into base64 payloads.
export const MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES =
MOBILE_DICTATION_PCM_SAMPLE_RATE * PCM_BYTES_PER_SAMPLE * MAX_PENDING_AUDIO_SECONDS
export const MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE =
'Connection is too slow for voice dictation. Try again when the connection improves.'
export class MobileDictationPendingAudioBudget {
private pendingBytes = 0
constructor(private readonly maxPendingBytes = MOBILE_DICTATION_MAX_PENDING_AUDIO_BYTES) {}
get pendingAudioBytes(): number {
return this.pendingBytes
}
tryReserve(byteLength: number): boolean {
const normalizedByteLength = normalizeByteLength(byteLength)
if (this.pendingBytes + normalizedByteLength > this.maxPendingBytes) {
return false
}
this.pendingBytes += normalizedByteLength
return true
}
release(byteLength: number): void {
this.pendingBytes = Math.max(0, this.pendingBytes - normalizeByteLength(byteLength))
}
reset(): void {
this.pendingBytes = 0
}
}
function normalizeByteLength(byteLength: number): number {
if (!Number.isFinite(byteLength) || byteLength <= 0) {
return 0
}
return Math.floor(byteLength)
}
@@ -0,0 +1,51 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8')
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
expect(start).toBeGreaterThanOrEqual(0)
const end = source.indexOf(endPattern, start)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('useMobileDictation source invariants', () => {
it('publishes live option refs from committed renders before passive Effects flush', () => {
const refDeclarations = sliceBetween(
'const clientRef = useRef(client)',
'useLayoutEffect(() => {'
)
expect(refDeclarations).not.toContain('.current =')
const mirrorEffect = sliceBetween('useLayoutEffect(() => {', 'const reportError =')
expect(mirrorEffect).toContain('clientRef.current = client')
expect(mirrorEffect).toContain('enabledRef.current = enabled')
expect(mirrorEffect).toContain('onTranscriptRef.current = onTranscript')
expect(mirrorEffect).toContain('onErrorRef.current = onError')
expect(mirrorEffect).toContain('}, [client, enabled, onTranscript, onError])')
})
it('reserves pending audio bytes before encoding microphone chunks', () => {
const microphoneEffect = sliceBetween(
"addExpoTwoWayAudioEventListener('onMicrophoneData'",
'return () => sub.remove()'
)
const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)')
const encodeIndex = microphoneEffect.indexOf('bytesToBase64(bytes)')
expect(reserveIndex).toBeGreaterThanOrEqual(0)
expect(encodeIndex).toBeGreaterThanOrEqual(0)
expect(reserveIndex).toBeLessThan(encodeIndex)
expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE')
expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)')
})
it('resets pending audio bytes whenever pending chunk tracking is cleared', () => {
const pendingChunkClears = source.match(/pendingChunksRef\.current\.clear\(\)/g) ?? []
const pendingAudioResets = source.match(/pendingAudioBudgetRef\.current\.reset\(\)/g) ?? []
expect(pendingAudioResets).toHaveLength(pendingChunkClears.length)
})
})
+24 -4
View File
@@ -1,7 +1,7 @@
/* oxlint-disable max-lines -- Why: mobile dictation keeps permission, recording,
* chunk upload, completion, and cancellation in one hook so native audio state
* cannot drift from the runtime RPC lifecycle. */
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Buffer } from 'buffer'
import {
addExpoTwoWayAudioEventListener,
@@ -11,6 +11,11 @@ import {
toggleRecording
} from '@orca/expo-two-way-audio'
import type { RpcClient } from '../transport/rpc-client'
import {
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
MOBILE_DICTATION_PCM_SAMPLE_RATE,
MobileDictationPendingAudioBudget
} from './mobile-dictation-pending-audio-budget'
type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
@@ -32,7 +37,6 @@ export type UseMobileDictationResult = {
cancel: () => Promise<void>
}
const MOBILE_PCM_SAMPLE_RATE = 16000
const DICTATION_FINISH_TIMEOUT_MS = 75_000
function bytesToBase64(bytes: Uint8Array): string {
@@ -53,11 +57,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
const onTranscriptRef = useRef(onTranscript)
const onErrorRef = useRef(onError)
const pendingChunksRef = useRef<Set<Promise<void>>>(new Set())
const pendingAudioBudgetRef = useRef(new MobileDictationPendingAudioBudget())
const acceptingChunksRef = useRef(false)
const generationRef = useRef(0)
const finishingIdRef = useRef<string | null>(null)
useEffect(() => {
useLayoutEffect(() => {
// Native audio events can arrive before passive Effects flush, but refs
// should only expose options from a committed render.
clientRef.current = client
enabledRef.current = enabled
onTranscriptRef.current = onTranscript
@@ -80,6 +87,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
if (client && dictationId) {
void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
@@ -98,11 +106,16 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
}
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) {
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
return
}
const sendChunk = client
.sendRequest('speech.dictation.chunk', {
dictationId,
audioBase64: bytesToBase64(bytes),
sampleRate: MOBILE_PCM_SAMPLE_RATE
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
})
.then((response) => {
if (!response.ok) {
@@ -111,6 +124,9 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
})
.catch((err) => failActiveDictation(dictationId, err))
.finally(() => {
if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) {
pendingAudioBudgetRef.current.release(byteLength)
}
pendingChunksRef.current.delete(sendChunk)
})
pendingChunksRef.current.add(sendChunk)
@@ -183,6 +199,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
acceptingChunksRef.current = true
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(true)
setStatus('recording')
}, [])
@@ -231,6 +248,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
activeIdRef.current = null
finishingIdRef.current = null
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
setStatus('idle')
if (text) {
onTranscriptRef.current(text)
@@ -254,6 +272,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
if (client && dictationId) {
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
@@ -285,6 +304,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
finishingIdRef.current = null
acceptingChunksRef.current = false
pendingChunksRef.current.clear()
pendingAudioBudgetRef.current.reset()
toggleRecording(false)
void tearDown()
if (clientRef.current && dictationId) {
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
createInitialHostRouteActionState,
resolveHostRouteActionState,
setHostRouteNewWorktreeVisible
} from './host-route-action-state'
describe('host route action state', () => {
it('opens new worktree modal on an initial newWorktree action', () => {
expect(createInitialHostRouteActionState('newWorktree')).toEqual({
routeAction: 'newWorktree',
showNewWorktree: true
})
})
it('keeps initial non-action routes closed', () => {
expect(createInitialHostRouteActionState(undefined)).toEqual({
routeAction: undefined,
showNewWorktree: false
})
})
it('opens once when route action changes to newWorktree', () => {
expect(
resolveHostRouteActionState({ routeAction: undefined, showNewWorktree: false }, 'newWorktree')
).toEqual({
routeAction: 'newWorktree',
showNewWorktree: true
})
})
it('does not reopen after user closes while route action is unchanged', () => {
const closed = setHostRouteNewWorktreeVisible(
{ routeAction: 'newWorktree', showNewWorktree: true },
false
)
expect(resolveHostRouteActionState(closed, 'newWorktree')).toBe(closed)
})
it('preserves an already-open modal when route action changes away', () => {
expect(
resolveHostRouteActionState({ routeAction: 'newWorktree', showNewWorktree: true }, undefined)
).toEqual({
routeAction: undefined,
showNewWorktree: true
})
})
})
+36
View File
@@ -0,0 +1,36 @@
export type HostRouteActionState = {
routeAction: string | undefined
showNewWorktree: boolean
}
export function createInitialHostRouteActionState(
routeAction: string | undefined
): HostRouteActionState {
return {
routeAction,
showNewWorktree: routeAction === 'newWorktree'
}
}
export function resolveHostRouteActionState(
current: HostRouteActionState,
routeAction: string | undefined
): HostRouteActionState {
if (current.routeAction === routeAction) {
return current
}
return {
routeAction,
showNewWorktree: current.showNewWorktree || routeAction === 'newWorktree'
}
}
export function setHostRouteNewWorktreeVisible(
current: HostRouteActionState,
showNewWorktree: boolean
): HostRouteActionState {
if (current.showNewWorktree === showNewWorktree) {
return current
}
return { ...current, showNewWorktree }
}
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
describe('subscribeToDesktopNotifications', () => {
it('drops the local stream when disposed before the desktop returns ready', () => {
const unsubscribeStream = vi.fn()
const client = {
subscribe: vi.fn(() => unsubscribeStream),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
const unsubscribe = subscribeToDesktopNotifications(client, 'host-1')
unsubscribe()
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
@@ -114,8 +114,10 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
// unmount races with disconnect). sendRequest rejects immediately on a
// closed client — swallow it since server-side cleanup happens via
// connection-close anyway.
// Always drop the local stream first; readiness can race unmount and we
// must not retain the callback while waiting for a subscription id.
unsubscribeStream()
if (subscriptionId) {
unsubscribeStream()
unsubscribeServer(subscriptionId)
}
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import type { DiffComment } from '../../../src/shared/types'
import {
addMobileDiffComment,
formatDiffComments,
normalizeMobileDiffComments,
removeDeliveredMobileDiffComments,
removeMobileDiffComments
} from './mobile-diff-comments'
function comment(overrides: Partial<DiffComment> & Pick<DiffComment, 'id'>): DiffComment {
return {
id: overrides.id,
worktreeId: 'wt-1',
filePath: 'src/app.ts',
source: 'diff',
lineNumber: 4,
body: 'check this',
createdAt: 100,
side: 'modified',
...overrides
}
}
describe('mobile diff comments', () => {
it('normalizes persisted worktree metadata for mobile rendering', () => {
expect(
normalizeMobileDiffComments(
[
comment({ id: 'a' }),
{ id: 'missing-body', filePath: 'src/app.ts', lineNumber: 1, body: ' ' },
null
],
'wt-1'
)
).toEqual([comment({ id: 'a' })])
})
it('creates trimmed modified-side comments', () => {
const result = addMobileDiffComment([], {
id: 'mobile-1',
worktreeId: 'wt-1',
filePath: 'src/app.ts',
lineNumber: 8,
body: ' Needs tests ',
createdAt: 200
})
expect(result.comment).toMatchObject({
id: 'mobile-1',
worktreeId: 'wt-1',
filePath: 'src/app.ts',
source: 'diff',
lineNumber: 8,
body: 'Needs tests',
side: 'modified'
})
expect(result.comments).toHaveLength(1)
})
it('rejects blank comment bodies', () => {
const existing = [comment({ id: 'a' })]
const result = addMobileDiffComment(existing, {
id: 'mobile-1',
worktreeId: 'wt-1',
filePath: 'src/app.ts',
lineNumber: 8,
body: ' ',
createdAt: 200
})
expect(result.comment).toBeNull()
expect(result.comments).toEqual(existing)
})
it('removes delivered comments by snapshot id without touching new notes', () => {
expect(
removeMobileDiffComments(
[comment({ id: 'a' }), comment({ id: 'b' })],
new Set(['a', 'missing'])
)
).toEqual([comment({ id: 'b' })])
})
it('keeps a changed note when clearing an older delivered snapshot', () => {
const delivered = comment({ id: 'a', body: 'old note' })
expect(
removeDeliveredMobileDiffComments(
[comment({ id: 'a', body: 'new note' }), comment({ id: 'b' })],
[delivered]
)
).toEqual([comment({ id: 'a', body: 'new note' }), comment({ id: 'b' })])
})
it('uses the desktop-compatible prompt format', () => {
expect(formatDiffComments([comment({ id: 'a', body: 'quote "this"' })])).toBe(
['File: src/app.ts', 'Line: 4', 'User comment: "quote \\"this\\""'].join('\n')
)
})
})
+132
View File
@@ -0,0 +1,132 @@
import type { DiffComment } from '../../../src/shared/types'
export type CreateMobileDiffCommentInput = {
worktreeId: string
filePath: string
lineNumber: number
body: string
id: string
createdAt: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
function isMarkdownComment(comment: Pick<DiffComment, 'source'>): boolean {
return comment.source === 'markdown'
}
// Why: mobile Vitest/Metro run from the mobile package and cannot transform
// runtime imports from root src/shared. Keep this byte-for-byte compatible with
// the desktop shared formatter contract.
export function formatDiffComment(c: DiffComment): string {
const escaped = c.body
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
const lineLabel =
c.startLine !== undefined && c.startLine !== c.lineNumber
? `Lines: ${c.startLine}-${c.lineNumber}`
: `Line: ${c.lineNumber}`
if (!isMarkdownComment(c)) {
return [`File: ${c.filePath}`, lineLabel, `User comment: "${escaped}"`].join('\n')
}
return [`File: ${c.filePath}`, 'Source: markdown', lineLabel, `User comment: "${escaped}"`].join(
'\n'
)
}
export function formatDiffComments(comments: readonly DiffComment[]): string {
return comments.map(formatDiffComment).join('\n\n')
}
export function normalizeMobileDiffComments(value: unknown, worktreeId: string): DiffComment[] {
if (!Array.isArray(value)) return []
return value.flatMap((candidate): DiffComment[] => {
if (!isRecord(candidate)) return []
const id = typeof candidate.id === 'string' ? candidate.id : ''
const filePath = typeof candidate.filePath === 'string' ? candidate.filePath : ''
const lineNumber = typeof candidate.lineNumber === 'number' ? candidate.lineNumber : NaN
const body = typeof candidate.body === 'string' ? candidate.body.trim() : ''
const createdAt = typeof candidate.createdAt === 'number' ? candidate.createdAt : Date.now()
if (!id || !filePath || !Number.isFinite(lineNumber) || !body) return []
return [
{
id,
worktreeId: typeof candidate.worktreeId === 'string' ? candidate.worktreeId : worktreeId,
filePath,
source: candidate.source === 'markdown' ? 'markdown' : 'diff',
selectedText:
typeof candidate.selectedText === 'string' ? candidate.selectedText : undefined,
startLine: typeof candidate.startLine === 'number' ? candidate.startLine : undefined,
lineNumber,
body,
createdAt,
sentAt: typeof candidate.sentAt === 'number' ? candidate.sentAt : undefined,
side: 'modified'
}
]
})
}
export function createMobileDiffComment(input: CreateMobileDiffCommentInput): DiffComment | null {
const body = input.body.trim()
if (!body || !Number.isFinite(input.lineNumber) || input.lineNumber <= 0) {
return null
}
return {
id: input.id,
worktreeId: input.worktreeId,
filePath: input.filePath,
source: 'diff',
lineNumber: input.lineNumber,
body,
createdAt: input.createdAt,
side: 'modified'
}
}
export function addMobileDiffComment(
comments: readonly DiffComment[],
input: CreateMobileDiffCommentInput
): { comments: DiffComment[]; comment: DiffComment | null } {
const comment = createMobileDiffComment(input)
if (!comment) {
return { comments: [...comments], comment: null }
}
return { comments: [...comments, comment], comment }
}
export function removeMobileDiffComments(
comments: readonly DiffComment[],
ids: ReadonlySet<string>
): DiffComment[] {
if (ids.size === 0) return [...comments]
return comments.filter((comment) => !ids.has(comment.id))
}
function deliveredCommentMatches(comment: DiffComment, delivered: DiffComment): boolean {
return (
comment.id === delivered.id &&
comment.body === delivered.body &&
comment.filePath === delivered.filePath &&
comment.lineNumber === delivered.lineNumber &&
comment.selectedText === delivered.selectedText &&
comment.source === delivered.source &&
comment.startLine === delivered.startLine
)
}
export function removeDeliveredMobileDiffComments(
comments: readonly DiffComment[],
delivered: readonly DiffComment[]
): DiffComment[] {
if (delivered.length === 0) return [...comments]
const deliveredById = new Map(delivered.map((comment) => [comment.id, comment]))
return comments.filter((comment) => {
const snapshot = deliveredById.get(comment.id)
return !snapshot || !deliveredCommentMatches(comment, snapshot)
})
}
@@ -14,4 +14,39 @@ describe('buildMobileDiffLines', () => {
{ kind: 'add', text: 'four', newLineNumber: 4 }
])
})
it('caps add-only preview rows before returning the mobile truncation marker', () => {
const modified = Array.from({ length: 3_000 }, (_, index) => `line-${index + 1}`).join('\n')
const result = buildMobileDiffLines('', `${modified}\n`)
expect(result.truncated).toBe(true)
expect(result.lines).toHaveLength(2_501)
expect(result.lines[0]).toEqual({ kind: 'add', text: 'line-1', newLineNumber: 1 })
expect(result.lines[2_499]).toEqual({ kind: 'add', text: 'line-2500', newLineNumber: 2500 })
expect(result.lines[2_500]).toEqual({
kind: 'context',
text: '... diff truncated for mobile preview ...'
})
})
it('caps prefix-suffix fallback rows for very large changed files', () => {
const original = Array.from({ length: 3_000 }, (_, index) => `old-${index + 1}`).join('\n')
const modified = Array.from({ length: 3_000 }, (_, index) => `new-${index + 1}`).join('\n')
const result = buildMobileDiffLines(`${original}\n`, `${modified}\n`)
expect(result.truncated).toBe(true)
expect(result.lines).toHaveLength(2_501)
expect(result.lines[0]).toEqual({ kind: 'delete', text: 'old-1', oldLineNumber: 1 })
expect(result.lines[2_499]).toEqual({
kind: 'delete',
text: 'old-2500',
oldLineNumber: 2500
})
expect(result.lines[2_500]).toEqual({
kind: 'context',
text: '... diff truncated for mobile preview ...'
})
})
})
+103 -44
View File
@@ -9,6 +9,7 @@ export type MobileDiffLine = {
const MAX_DIFF_CELLS = 200_000
const MAX_MOBILE_DIFF_LINES = 2_500
const MAX_BUILT_DIFF_LINES = MAX_MOBILE_DIFF_LINES + 1
const TRUNCATED_LINE: MobileDiffLine = {
kind: 'context',
text: '... diff truncated for mobile preview ...'
@@ -27,10 +28,7 @@ export function buildMobileDiffLines(
? buildLcsDiffLines(originalLines, modifiedLines)
: buildPrefixSuffixDiffLines(originalLines, modifiedLines)
if (lines.length <= MAX_MOBILE_DIFF_LINES) {
return { lines, truncated: false }
}
return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true }
return finalizeMobileDiffLines(lines)
}
function splitContentLines(content: string): string[] {
@@ -61,48 +59,68 @@ function buildLcsDiffLines(originalLines: string[], modifiedLines: string[]): Mo
let modifiedIndex = 0
while (originalIndex < originalLines.length && modifiedIndex < modifiedLines.length) {
if (originalLines[originalIndex] === modifiedLines[modifiedIndex]) {
lines.push({
kind: 'context',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1,
newLineNumber: modifiedIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1,
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
originalIndex += 1
modifiedIndex += 1
} else if (
dp[(originalIndex + 1) * rowWidth + modifiedIndex] >=
dp[originalIndex * rowWidth + modifiedIndex + 1]
) {
lines.push({
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
) {
return lines
}
originalIndex += 1
} else {
lines.push({
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
modifiedIndex += 1
}
}
while (originalIndex < originalLines.length) {
lines.push({
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[originalIndex] ?? '',
oldLineNumber: originalIndex + 1
})
) {
return lines
}
originalIndex += 1
}
while (modifiedIndex < modifiedLines.length) {
lines.push({
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[modifiedIndex] ?? '',
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
modifiedIndex += 1
}
@@ -134,28 +152,69 @@ function buildPrefixSuffixDiffLines(
const lines: MobileDiffLine[] = []
for (let i = 0; i < prefixLength; i += 1) {
lines.push({
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: i + 1
})
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: i + 1
})
) {
return lines
}
}
for (let i = prefixLength; i < originalLines.length - suffixLength; i += 1) {
lines.push({ kind: 'delete', text: originalLines[i] ?? '', oldLineNumber: i + 1 })
if (
!appendDiffLine(lines, {
kind: 'delete',
text: originalLines[i] ?? '',
oldLineNumber: i + 1
})
) {
return lines
}
}
for (let i = prefixLength; i < modifiedLines.length - suffixLength; i += 1) {
lines.push({ kind: 'add', text: modifiedLines[i] ?? '', newLineNumber: i + 1 })
if (
!appendDiffLine(lines, {
kind: 'add',
text: modifiedLines[i] ?? '',
newLineNumber: i + 1
})
) {
return lines
}
}
for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
const modifiedIndex =
modifiedLines.length - suffixLength + (i - (originalLines.length - suffixLength))
lines.push({
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: modifiedIndex + 1
})
if (
!appendDiffLine(lines, {
kind: 'context',
text: originalLines[i] ?? '',
oldLineNumber: i + 1,
newLineNumber: modifiedIndex + 1
})
) {
return lines
}
}
return lines
}
function appendDiffLine(lines: MobileDiffLine[], line: MobileDiffLine): boolean {
// Why: the mobile preview only renders the first capped rows; keep one extra
// row solely to preserve the existing "truncated" marker decision.
lines.push(line)
return lines.length < MAX_BUILT_DIFF_LINES
}
function finalizeMobileDiffLines(lines: MobileDiffLine[]): {
lines: MobileDiffLine[]
truncated: boolean
} {
if (lines.length <= MAX_MOBILE_DIFF_LINES) {
return { lines, truncated: false }
}
return { lines: [...lines.slice(0, MAX_MOBILE_DIFF_LINES), TRUNCATED_LINE], truncated: true }
}
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import {
createMobileSessionCreateWarningState,
dismissMobileSessionCreateWarningState,
reconcileMobileSessionCreateWarningState
} from './mobile-session-create-warning-state'
describe('mobile session create warning state', () => {
it('preserves a dismissed warning while the route warning is unchanged', () => {
const dismissed = dismissMobileSessionCreateWarningState(
createMobileSessionCreateWarningState('Created, but setup failed')
)
expect(reconcileMobileSessionCreateWarningState(dismissed, 'Created, but setup failed')).toBe(
dismissed
)
})
it('shows a new warning when the route warning changes', () => {
const dismissed = dismissMobileSessionCreateWarningState(
createMobileSessionCreateWarningState('Old warning')
)
expect(reconcileMobileSessionCreateWarningState(dismissed, 'New warning')).toEqual({
source: 'New warning',
visible: 'New warning'
})
})
})
@@ -0,0 +1,29 @@
export type MobileSessionCreateWarningState = {
source: string
visible: string
}
export function createMobileSessionCreateWarningState(
source: string
): MobileSessionCreateWarningState {
return { source, visible: source }
}
export function reconcileMobileSessionCreateWarningState(
current: MobileSessionCreateWarningState,
source: string
): MobileSessionCreateWarningState {
if (current.source === source) {
return current
}
return createMobileSessionCreateWarningState(source)
}
export function dismissMobileSessionCreateWarningState(
current: MobileSessionCreateWarningState
): MobileSessionCreateWarningState {
if (!current.visible) {
return current
}
return { ...current, visible: '' }
}

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