Merge remote-tracking branch 'origin/main' into nwparker/terminal-rc-verified

# Conflicts:
#	src/main/daemon/daemon-pty-adapter.ts
#	src/main/ipc/pty.ts
#	src/main/runtime/rpc/methods/terminal.ts
#	src/renderer/src/App.tsx
#	src/renderer/src/components/Terminal.tsx
#	src/renderer/src/components/terminal-pane/pty-connection-types.ts
#	src/renderer/src/components/terminal-pane/pty-connection.test.ts
#	src/renderer/src/components/terminal-pane/pty-connection.ts
#	src/renderer/src/components/terminal-pane/pty-dispatcher.ts
#	src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
#	src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts
#	src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts
#	src/renderer/src/lib/launch-agent-background-session.ts
#	src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts
#	src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts
#	src/shared/agent-detection.ts
#	tests/e2e/terminal-long-table-scroll-restore.spec.ts
This commit is contained in:
Neil
2026-06-26 21:10:10 -07:00
1119 changed files with 97459 additions and 14380 deletions
+23 -12
View File
@@ -81,16 +81,26 @@ jobs:
fail-fast: false
matrix:
include:
- shard: '1/5'
shard_name: 1-of-5
- shard: '2/5'
shard_name: 2-of-5
- shard: '3/5'
shard_name: 3-of-5
- shard: '4/5'
shard_name: 4-of-5
- shard: '5/5'
shard_name: 5-of-5
- shard: '1/10'
shard_name: 1-of-10
- shard: '2/10'
shard_name: 2-of-10
- shard: '3/10'
shard_name: 3-of-10
- shard: '4/10'
shard_name: 4-of-10
- shard: '5/10'
shard_name: 5-of-10
- shard: '6/10'
shard_name: 6-of-10
- shard: '7/10'
shard_name: 7-of-10
- shard: '8/10'
shard_name: 8-of-10
- shard: '9/10'
shard_name: 9-of-10
- shard: '10/10'
shard_name: 10-of-10
steps:
- name: Checkout
@@ -142,8 +152,9 @@ jobs:
path: out/
# Why: the Electron suite is wall-clock constrained on OSS runners, but
# raising workers on one runner OOMs. Sharding keeps each VM at 4 workers
# while splitting the 91-test headless suite across separate runners.
# multiple Electron apps on one Xvfb VM contend on git/Chromium resources.
# Sharding keeps each VM at one Playwright worker while splitting the
# headless suite across separate runners.
# SKIP_BUILD makes Playwright globalSetup reuse the single build job's
# artifact instead of starting five concurrent electron-vite builds.
# ORCA_E2E_FORWARD_APP_LOGS keeps startup failures visible when Electron
+66 -9
View File
@@ -8,6 +8,30 @@ on:
tags:
- 'mobile-android-v*'
workflow_dispatch:
inputs:
bump_patch_version:
description: 'Bump the mobile marketing version patch number before release'
required: false
default: false
type: boolean
release_version:
description: 'Optional exact mobile marketing version override, e.g. 0.0.15'
required: false
type: string
bump_android_version_code:
description: 'Increment the Android versionCode before release'
required: false
default: false
type: boolean
android_version_code:
description: 'Optional exact Android versionCode override'
required: false
type: string
publish_github_release:
description: 'Create or update the mobile-android-v<version> GitHub Release'
required: false
default: true
type: boolean
jobs:
android-build:
@@ -41,6 +65,16 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Resolve release version and version code
id: release
env:
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }}
MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }}
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
run: node scripts/prepare-android-release.mjs
- name: Setup JDK 17
uses: actions/setup-java@v5
with:
@@ -59,16 +93,39 @@ jobs:
name: orca-mobile-apk
path: mobile/android/app/build/outputs/apk/release/*.apk
- name: Ensure GitHub release tag
if: steps.release.outputs.publish_release == 'true' && !startsWith(github.ref, 'refs/tags/mobile-android-v')
run: |
set -euo pipefail
tag="${{ steps.release.outputs.tag }}"
if git ls-remote --exit-code --tags origin "refs/tags/$tag" >/dev/null 2>&1; then
echo "Tag $tag already exists"
exit 0
fi
git tag "$tag" "$GITHUB_SHA"
git push origin "refs/tags/$tag"
- name: Create GitHub Release
if: startsWith(github.ref, 'refs/tags/mobile-android-v')
if: steps.release.outputs.publish_release == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
tag="${GITHUB_REF#refs/tags/}"
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
--generate-notes \
android/app/build/outputs/apk/release/*.apk
set -euo pipefail
tag="${{ steps.release.outputs.tag }}"
if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
gh release upload "$tag" \
--repo "$GITHUB_REPOSITORY" \
--clobber \
android/app/build/outputs/apk/release/*.apk
else
gh release create "$tag" \
--repo "$GITHUB_REPOSITORY" \
--title "Orca Mobile Android $tag" \
--prerelease \
--latest=false \
--generate-notes \
android/app/build/outputs/apk/release/*.apk
fi
+59 -10
View File
@@ -716,8 +716,14 @@ jobs:
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
- os: ubuntu-latest
platform: linux
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux --publish always
platform: linux-x64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
- os: ubuntu-24.04-arm
platform: linux-arm64
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
eb_cache_path: |
~/.cache/electron
~/.cache/electron-builder
@@ -888,7 +894,7 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux'
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
with:
timeout_minutes: 30
@@ -911,6 +917,56 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
$trimChars = [char[]]@([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$documentsRoot = [System.IO.Path]::GetFullPath([Environment]::GetFolderPath('MyDocuments')).TrimEnd($trimChars)
$currentUserModuleRoot = $env:PSModulePath -split [System.IO.Path]::PathSeparator |
Where-Object {
if ([string]::IsNullOrWhiteSpace($_)) {
$false
} else {
$candidate = [System.IO.Path]::GetFullPath($_).TrimEnd($trimChars)
$candidate.StartsWith($documentsRoot, [System.StringComparison]::OrdinalIgnoreCase)
}
} |
Select-Object -First 1
if ([string]::IsNullOrWhiteSpace($currentUserModuleRoot)) {
throw 'Unable to resolve the current-user PowerShell module root from PSModulePath.'
}
$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'
for ($attempt = 1; $attempt -le 3; $attempt++) {
if ($attempt -eq 2) {
Start-Sleep -Seconds 15
} elseif ($attempt -eq 3) {
Start-Sleep -Seconds 30
}
try {
Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop
Import-Module SignPath -ErrorAction Stop
Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop
break
} catch {
if ($attempt -eq 3) {
throw
}
Write-Warning "SignPath PowerShell module preflight attempt $attempt failed: $_"
if (Test-Path -LiteralPath $signPathModulePath) {
Write-Warning "Removing current-user SignPath module directory before retry: $signPathModulePath"
Remove-Item -LiteralPath $signPathModulePath -Recurse -Force
}
}
}
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -971,13 +1027,6 @@ jobs:
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
- name: Install SignPath PowerShell module
if: matrix.platform == 'win'
shell: pwsh
run: |
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name SignPath -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force
- name: Download signed Windows installer from SignPath
if: matrix.platform == 'win'
shell: pwsh
+9 -3
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="docs/assets/readme-downloads.svg" alt="Total downloads across all releases" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="License" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Join the Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Follow Orca on X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Supported platforms: macOS, Windows, and Linux" />
</p>
@@ -35,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -178,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
<a href="https://antigravity.google/docs/cli-overview"><kbd><img src="https://www.google.com/s2/favicons?domain=antigravity.google&sz=64" alt="Antigravity logo" width="16" valign="middle" /> Antigravity</kbd></a> &nbsp;
@@ -226,8 +228,8 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download the APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.16](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
@@ -235,6 +237,10 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
+3 -2
View File
@@ -9,6 +9,7 @@ const {
} = require('./packaged-runtime-node-modules.cjs')
const isMacRelease = process.env.ORCA_MAC_RELEASE === '1'
const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1'
const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
@@ -273,12 +274,12 @@ module.exports = {
},
featureWallResources
],
target: ['AppImage', 'deb', 'rpm'],
target: ['AppImage', 'deb'],
maintainer: 'stablyai',
category: 'Utility'
},
appImage: {
artifactName: 'orca-linux.${ext}'
artifactName: isLinuxArm64Release ? 'orca-linux-arm64.${ext}' : 'orca-linux.${ext}'
},
deb: {
packageName: 'orca-ide',
@@ -0,0 +1,440 @@
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
compareBenchmarkArtifacts,
formatBenchmarkComparisonMarkdown,
parseBenchmarkComparisonArgs
} from './compare-benchmark-artifacts.mjs'
const scriptPath = 'config/scripts/compare-benchmark-artifacts.mjs'
const tempDirs = []
function makeTempDir() {
const dir = mkdtempSync(join(tmpdir(), 'orca-benchmark-comparison-'))
tempDirs.push(dir)
return dir
}
function writeArtifact(dir, name, artifact) {
const artifactPath = join(dir, name)
writeFileSync(artifactPath, JSON.stringify(artifact))
return artifactPath
}
function comparePaths(baselinePath, candidatePath, extra = {}) {
return compareBenchmarkArtifacts({
baselinePath,
candidatePath,
now: () => new Date('2026-06-21T12:00:00.000Z'),
title: 'Test Compare',
...extra
})
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('benchmark artifact comparison', () => {
it('parses required CLI flags and rejects missing paths', () => {
expect(() => parseBenchmarkComparisonArgs(['--baseline'])).toThrow('--baseline requires a path')
expect(() => parseBenchmarkComparisonArgs(['--candidate'])).toThrow(
'--candidate requires a path'
)
expect(() => parseBenchmarkComparisonArgs(['--candidate', 'candidate.json'])).toThrow(
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
)
})
it('compares startup-style summary median metrics and skips null candidates', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', {
label: 'baseline',
summaryMedianMs: {
missingLater: 5,
spawnToAppReady: 25,
totalToDidFinishLoad: 100
}
})
const candidatePath = writeArtifact(dir, 'candidate.json', {
label: 'candidate',
summaryMedianMs: {
missingLater: null,
spawnToAppReady: 30,
totalToDidFinishLoad: 40
}
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.schemaVersion).toBe(1)
expect(comparison.createdAt).toBe('2026-06-21T12:00:00.000Z')
expect(comparison.baseline.label).toBe('baseline')
expect(comparison.candidate.label).toBe('candidate')
expect(
comparison.metrics.find((metric) => metric.key === 'totalToDidFinishLoad')
).toMatchObject({
absoluteDelta: -60,
baseline: 100,
candidate: 40,
percentDelta: -60,
status: 'improved',
unit: 'ms'
})
expect(comparison.metrics.find((metric) => metric.key === 'spawnToAppReady')).toMatchObject({
status: 'regressed'
})
expect(comparison.skippedMetrics).toContainEqual({
key: 'missingLater',
reason: 'missing candidate metric'
})
})
it('compares numeric Playwright annotation metrics and omits metadata fields', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright.json', {
suites: [
{
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-50',
description:
'panes=50 frames=60 median=80.0ms worst=120.0ms rendererQueuedChars=1000 samples=1,2'
}
]
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale-same-workspace-50',
description:
'panes=50 frames=60 median=60.0ms worst=100.0ms rendererQueuedChars=800 samples=1,2'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
const metricKeys = comparison.metrics.map((metric) => metric.key)
expect(metricKeys).toContain('opencode-scale-same-workspace-50.median')
expect(metricKeys).toContain('opencode-scale-same-workspace-50.rendererQueuedChars')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.panes')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.frames')
expect(metricKeys).not.toContain('opencode-scale-same-workspace-50.samples')
expect(
comparison.metrics.find((metric) => metric.key === 'opencode-scale-same-workspace-50.median')
).toMatchObject({ status: 'improved', unit: 'ms' })
})
it('aggregates duplicate Playwright scenario metrics before comparison', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright-duplicates.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-duplicate',
description: 'median=80.0ms rendererQueuedChars=1000'
},
{
type: 'opencode-duplicate',
description: 'median=100.0ms rendererQueuedChars=1400'
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright-duplicates.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-duplicate',
description: 'median=60.0ms rendererQueuedChars=800'
},
{
type: 'opencode-duplicate',
description: 'median=70.0ms rendererQueuedChars=1000'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
const duplicateMedianMetrics = comparison.metrics.filter(
(metric) => metric.key === 'opencode-duplicate.median'
)
expect(duplicateMedianMetrics).toHaveLength(1)
expect(duplicateMedianMetrics[0]).toMatchObject({
absoluteDelta: -25,
baseline: 90,
candidate: 65,
percentDelta: -27.8,
status: 'improved',
unit: 'ms'
})
expect(
comparison.metrics.filter((metric) => metric.key === 'opencode-duplicate.rendererQueuedChars')
).toHaveLength(1)
})
it('skips unit mismatches instead of comparing incompatible metrics', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline-playwright-units.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-units',
description: 'median=80.0ms rendererQueuedChars=1000'
}
]
}
]
}
]
}
]
})
const candidatePath = writeArtifact(dir, 'candidate-playwright-units.json', {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-units',
description: 'median=60 rendererQueuedChars=800'
}
]
}
]
}
]
}
]
})
const comparison = comparePaths(baselinePath, candidatePath)
expect(comparison.metrics.map((metric) => metric.key)).toEqual([
'opencode-units.rendererQueuedChars'
])
expect(comparison.skippedMetrics).toContainEqual({
key: 'opencode-units.median',
reason: 'unit mismatch (ms vs count)'
})
})
it('supports higher-is-better metrics for generic summary artifacts', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'generic-baseline.json', {
summary: {
totalBytes: 2048,
totalCpuPercent: { mean: 80 },
throughput: 10
}
})
const candidatePath = writeArtifact(dir, 'generic-candidate.json', {
summary: {
totalBytes: 1024,
totalCpuPercent: { mean: 70 },
throughput: 12
}
})
const comparison = comparePaths(baselinePath, candidatePath, {
higherIsBetter: new Set(['summary.throughput'])
})
expect(comparison.metrics.find((metric) => metric.key === 'summary.throughput')).toMatchObject({
direction: 'higher-is-better',
status: 'improved'
})
expect(comparison.metrics.find((metric) => metric.key === 'summary.totalBytes')).toMatchObject({
unit: 'bytes'
})
expect(
comparison.metrics.find((metric) => metric.key === 'summary.totalCpuPercent.mean')
).toMatchObject({
unit: '%'
})
})
it('writes Markdown and JSON reports from the CLI while printing Markdown', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', {
label: 'baseline',
summaryMedianMs: { totalToDidFinishLoad: 100 }
})
const candidatePath = writeArtifact(dir, 'candidate.json', {
label: 'candidate',
summaryMedianMs: { totalToDidFinishLoad: 40 }
})
const markdownPath = join(dir, 'nested', 'comparison.md')
const jsonPath = join(dir, 'nested', 'comparison.json')
const result = spawnSync(
process.execPath,
[
scriptPath,
'--baseline',
baselinePath,
'--candidate',
candidatePath,
'--title',
'Test Compare',
'--output',
markdownPath,
'--json-output',
jsonPath
],
{ cwd: process.cwd(), encoding: 'utf8' }
)
expect(result.status).toBe(0)
expect(result.stdout).toContain('| Metric | Baseline | Candidate | Delta | Delta % | Result |')
expect(result.stdout).toContain(
'| totalToDidFinishLoad | 100.0ms | 40.0ms | -60.0ms | -60.0% | improved |'
)
expect(existsSync(markdownPath)).toBe(true)
expect(existsSync(jsonPath)).toBe(true)
expect(JSON.parse(readFileSync(jsonPath, 'utf8')).schemaVersion).toBe(1)
})
it('redacts absolute input paths from generated reports', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'absolute-baseline.json', {
label: 'baseline',
summaryMedianMs: { totalToDidFinishLoad: 100 }
})
const candidatePath = writeArtifact(dir, 'absolute-candidate.json', {
label: 'candidate',
summaryMedianMs: { totalToDidFinishLoad: 40 }
})
const markdownPath = join(dir, 'comparison.md')
const jsonPath = join(dir, 'comparison.json')
const result = spawnSync(
process.execPath,
[
scriptPath,
'--baseline',
baselinePath,
'--candidate',
candidatePath,
'--output',
markdownPath,
'--json-output',
jsonPath
],
{ cwd: process.cwd(), encoding: 'utf8' }
)
const json = JSON.parse(readFileSync(jsonPath, 'utf8'))
const markdown = readFileSync(markdownPath, 'utf8')
expect(result.status).toBe(0)
expect(json.baseline.path).toBe('absolute-baseline.json')
expect(json.candidate.path).toBe('absolute-candidate.json')
expect(markdown).not.toContain(dir)
expect(result.stdout).not.toContain(dir)
})
it('escapes artifact-controlled Markdown fields in reports', () => {
const markdown = formatBenchmarkComparisonMarkdown({
title: 'Compare\n[Injected](https://example.test)',
createdAt: '2026-06-21T12:00:00.000Z',
baseline: { label: 'base\n<label>', path: 'base|path', kind: 'summary' },
candidate: { label: 'candidate', path: 'candidate.md', kind: 'summary' },
metrics: [
{
key: 'summary.value|with-pipe',
unit: '',
baseline: 1,
candidate: 2,
absoluteDelta: 1,
percentDelta: 100,
status: 'regressed'
}
],
skippedMetrics: [{ key: 'missing\nmetric', reason: 'missing | candidate' }]
})
expect(markdown).toContain('# Compare \\[Injected\\]\\(https://example\\.test\\)')
expect(markdown).toContain('Baseline: base \\<label\\> (base|path)')
expect(markdown).toContain(
'| summary\\.value\\|with\\-pipe | 1.0 | 2.0 | 1.0 | 100.0% | regressed |'
)
expect(markdown).toContain('- missing metric: missing | candidate')
})
it('fails unsupported artifacts in the CLI', () => {
const dir = makeTempDir()
const baselinePath = writeArtifact(dir, 'baseline.json', { hello: 'world' })
const candidatePath = writeArtifact(dir, 'candidate.json', { hello: 'world' })
const result = spawnSync(
process.execPath,
[scriptPath, '--baseline', baselinePath, '--candidate', candidatePath],
{ cwd: process.cwd(), encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('unsupported benchmark artifact')
})
})
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
const reportPaths = process.argv.slice(2)
if (reportPaths[0] === '--') {
@@ -27,55 +27,6 @@ const BUDGETS = {
maxRendererDroppedBacklogs: 0
}
function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function parseMs(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
@@ -0,0 +1,394 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, dirname, isAbsolute, relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import { collectTerminalPerfRows } from './terminal-perf-report-annotations.mjs'
const USAGE =
'Usage: node config/scripts/compare-benchmark-artifacts.mjs --baseline <path> --candidate <path> [--title <title>] [--output <path>] [--json-output <path>] [--higher-is-better <metric-key> ...]'
const PLAYWRIGHT_METADATA_FIELDS = new Set(['source', 'scenario', 'panes', 'frames', 'samples'])
export function parseBenchmarkComparisonArgs(argv = process.argv.slice(2)) {
const args = argv[0] === '--' ? argv.slice(1) : [...argv]
const parsed = {
higherIsBetter: new Set(),
title: 'Benchmark comparison'
}
for (let index = 0; index < args.length; index += 1) {
const flag = args[index]
if (flag === '--baseline') {
parsed.baselinePath = readRequiredValue(args, ++index, '--baseline requires a path')
continue
}
if (flag === '--candidate') {
parsed.candidatePath = readRequiredValue(args, ++index, '--candidate requires a path')
continue
}
if (flag === '--title') {
parsed.title = readRequiredValue(args, ++index, '--title requires a value')
continue
}
if (flag === '--output') {
parsed.outputPath = readRequiredValue(args, ++index, '--output requires a path')
continue
}
if (flag === '--json-output') {
parsed.jsonOutputPath = readRequiredValue(args, ++index, '--json-output requires a path')
continue
}
if (flag === '--higher-is-better') {
let consumed = 0
while (args[index + 1] != null && !args[index + 1].startsWith('--')) {
parsed.higherIsBetter.add(args[index + 1])
index += 1
consumed += 1
}
if (consumed === 0) {
throw new Error('--higher-is-better requires a metric key')
}
continue
}
throw new Error(USAGE)
}
if (!parsed.baselinePath) {
throw new Error(USAGE)
}
if (!parsed.candidatePath) {
throw new Error(USAGE)
}
return parsed
}
function readRequiredValue(args, index, message) {
const value = args[index]
if (value == null || value.startsWith('--')) {
throw new Error(message)
}
return value
}
export function readBenchmarkArtifact(path) {
return JSON.parse(readFileSync(path, 'utf8'))
}
export function normalizeBenchmarkArtifact(path, artifact = readBenchmarkArtifact(path)) {
if (artifact?.summaryMedianMs != null) {
return normalizeNumericObject(path, artifact, 'startup', artifact.summaryMedianMs, () => 'ms')
}
if (artifact?.summaryMedian != null) {
return normalizeNumericObject(path, artifact, 'daemon', artifact.summaryMedian, (key) =>
key.endsWith('Count') || key.endsWith('After') ? 'count' : 'ms'
)
}
if (artifact?.suites != null) {
return normalizePlaywrightArtifact(path, artifact)
}
if (artifact?.summary != null) {
return normalizeSummaryArtifact(path, artifact)
}
throw new Error(
`${path}: unsupported benchmark artifact; expected summaryMedianMs, summaryMedian, Playwright suites, or top-level summary`
)
}
function artifactLabel(path, artifact) {
return typeof artifact?.label === 'string' && artifact.label.length > 0
? artifact.label
: basename(path)
}
function normalizeNumericObject(path, artifact, kind, values, unitForKey) {
return {
kind,
label: artifactLabel(path, artifact),
metrics: Object.entries(values ?? {})
.filter(([, value]) => Number.isFinite(value))
.map(([key, value]) => ({
direction: 'lower-is-better',
key,
unit: unitForKey(key),
value
}))
}
}
function normalizePlaywrightArtifact(path, artifact) {
const rows = collectTerminalPerfRows(artifact, basename(path), { typePrefix: 'opencode-' })
const groupedMetrics = new Map()
for (const row of rows) {
for (const [field, rawValue] of Object.entries(row)) {
if (PLAYWRIGHT_METADATA_FIELDS.has(field)) {
continue
}
const parsed = parseMetricValue(rawValue)
if (parsed == null) {
continue
}
const key = `${row.scenario}.${field}`
const metricGroup = groupedMetrics.get(key) ?? {
unit: parsed.unit,
values: []
}
metricGroup.values.push(parsed.value)
groupedMetrics.set(key, metricGroup)
}
}
const metrics = [...groupedMetrics.entries()].map(([key, metricGroup]) => ({
direction: 'lower-is-better',
key,
unit: metricGroup.unit,
value: mean(metricGroup.values)
}))
return {
kind: 'playwright',
label: artifactLabel(path, artifact),
metrics
}
}
function mean(values) {
return values.reduce((sum, value) => sum + value, 0) / values.length
}
function parseMetricValue(rawValue) {
if (typeof rawValue === 'string') {
const msMatch = rawValue.match(/^(-?\d+(?:\.\d+)?)ms$/)
if (msMatch) {
return { unit: 'ms', value: Number(msMatch[1]) }
}
}
const numericValue = Number(rawValue)
if (!Number.isFinite(numericValue)) {
return null
}
return { unit: 'count', value: numericValue }
}
function normalizeSummaryArtifact(path, artifact) {
const metrics = []
flattenSummary(metrics, ['summary'], artifact.summary)
return {
kind: 'summary',
label: artifactLabel(path, artifact),
metrics
}
}
function flattenSummary(metrics, pathParts, value) {
if (Number.isFinite(value)) {
const key = pathParts.join('.')
metrics.push({
direction: 'lower-is-better',
key,
unit: unitForSummaryKey(pathParts),
value
})
return
}
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return
}
for (const [childKey, childValue] of Object.entries(value)) {
flattenSummary(metrics, [...pathParts, childKey], childValue)
}
}
function unitForSummaryKey(pathParts) {
if (pathParts.some((part) => part.endsWith('CpuPercent'))) {
return '%'
}
if (pathParts.some((part) => part.endsWith('Bytes'))) {
return 'bytes'
}
return ''
}
export function compareBenchmarkArtifacts({
baselinePath,
candidatePath,
title = 'Benchmark comparison',
higherIsBetter = new Set(),
now = () => new Date()
}) {
const baseline = normalizeBenchmarkArtifact(baselinePath)
const candidate = normalizeBenchmarkArtifact(candidatePath)
const candidateMetrics = new Map(candidate.metrics.map((metric) => [metric.key, metric]))
const baselineMetrics = new Map(baseline.metrics.map((metric) => [metric.key, metric]))
const metrics = []
const skippedMetrics = []
for (const baselineMetric of baseline.metrics) {
const candidateMetric = candidateMetrics.get(baselineMetric.key)
if (!isComparableMetric(baselineMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing baseline metric' })
continue
}
if (!isComparableMetric(candidateMetric)) {
skippedMetrics.push({ key: baselineMetric.key, reason: 'missing candidate metric' })
continue
}
if (baselineMetric.unit !== candidateMetric.unit) {
skippedMetrics.push({
key: baselineMetric.key,
reason: `unit mismatch (${formatUnitLabel(baselineMetric.unit)} vs ${formatUnitLabel(candidateMetric.unit)})`
})
continue
}
const direction = higherIsBetter.has(baselineMetric.key)
? 'higher-is-better'
: baselineMetric.direction
metrics.push(compareMetric(baselineMetric, candidateMetric, direction))
}
for (const candidateMetric of candidate.metrics) {
if (!baselineMetrics.has(candidateMetric.key)) {
skippedMetrics.push({ key: candidateMetric.key, reason: 'missing baseline metric' })
}
}
if (metrics.length === 0) {
throw new Error('No comparable benchmark metrics found.')
}
return {
schemaVersion: 1,
createdAt: now().toISOString(),
title,
baseline: {
path: benchmarkDisplayPath(baselinePath),
label: baseline.label,
kind: baseline.kind
},
candidate: {
path: benchmarkDisplayPath(candidatePath),
label: candidate.label,
kind: candidate.kind
},
metrics,
skippedMetrics
}
}
function benchmarkDisplayPath(path, cwd = process.cwd()) {
if (!isAbsolute(path)) {
return path
}
const relativePath = relative(cwd, path)
if (relativePath && !relativePath.startsWith('..') && !isAbsolute(relativePath)) {
return relativePath
}
return basename(path)
}
function isComparableMetric(metric) {
return metric != null && Number.isFinite(metric.value)
}
function compareMetric(baselineMetric, candidateMetric, direction) {
const rawDelta = candidateMetric.value - baselineMetric.value
const absoluteDelta = roundOneDecimal(rawDelta)
const percentDelta =
baselineMetric.value === 0
? null
: roundOneDecimal((rawDelta / Math.abs(baselineMetric.value)) * 100)
return {
key: baselineMetric.key,
unit: baselineMetric.unit,
direction,
baseline: baselineMetric.value,
candidate: candidateMetric.value,
absoluteDelta,
percentDelta,
status: metricStatus(absoluteDelta, direction)
}
}
function formatUnitLabel(unit) {
return unit === '' ? 'none' : unit
}
function roundOneDecimal(value) {
return Math.round(value * 10) / 10
}
function metricStatus(absoluteDelta, direction) {
if (absoluteDelta === 0) {
return 'unchanged'
}
if (direction === 'higher-is-better') {
return absoluteDelta > 0 ? 'improved' : 'regressed'
}
return absoluteDelta < 0 ? 'improved' : 'regressed'
}
export function formatBenchmarkComparisonMarkdown(comparison) {
const lines = [
`# ${markdownText(comparison.title)}`,
'',
`Baseline: ${markdownText(comparison.baseline.label)} (${markdownText(comparison.baseline.path)})`,
`Candidate: ${markdownText(comparison.candidate.label)} (${markdownText(comparison.candidate.path)})`,
`Generated: ${comparison.createdAt}`,
'',
'| Metric | Baseline | Candidate | Delta | Delta % | Result |',
'|---|---:|---:|---:|---:|---|'
]
for (const metric of comparison.metrics) {
lines.push(
`| ${markdownTableCell(metric.key)} | ${formatMetricValue(metric.baseline, metric.unit)} | ${formatMetricValue(metric.candidate, metric.unit)} | ${formatMetricValue(metric.absoluteDelta, metric.unit)} | ${formatPercent(metric.percentDelta)} | ${metric.status} |`
)
}
if (comparison.skippedMetrics.length > 0) {
lines.push('', '## Skipped metrics')
for (const skippedMetric of comparison.skippedMetrics) {
lines.push(`- ${markdownText(skippedMetric.key)}: ${markdownText(skippedMetric.reason)}`)
}
}
return `${lines.join('\n')}\n`
}
function markdownText(value) {
return String(value ?? '')
.replace(/\s*\r?\n\s*/g, ' ')
.replace(/[\\`*_{}<>()#+.!-]|\[|\]/g, '\\$&')
}
function markdownTableCell(value) {
return markdownText(value).replaceAll('|', '\\|')
}
function formatMetricValue(value, unit) {
return `${Number(value).toFixed(1)}${unit}`
}
function formatPercent(value) {
return value == null ? '' : `${value.toFixed(1)}%`
}
export function runBenchmarkComparisonCli(args = {}) {
const argv = args.argv ?? process.argv.slice(2)
const parsed = parseBenchmarkComparisonArgs(argv)
const comparison = compareBenchmarkArtifacts(parsed)
const markdown = formatBenchmarkComparisonMarkdown(comparison)
if (parsed.outputPath) {
mkdirSync(dirname(parsed.outputPath), { recursive: true })
writeFileSync(parsed.outputPath, markdown)
}
if (parsed.jsonOutputPath) {
mkdirSync(dirname(parsed.jsonOutputPath), { recursive: true })
writeFileSync(parsed.jsonOutputPath, `${JSON.stringify(comparison, null, 2)}\n`)
}
const stdout = args.stdout ?? process.stdout
stdout.write(markdown)
return comparison
}
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
runBenchmarkComparisonCli()
} catch (error) {
console.error(error.message)
process.exit(1)
}
}
@@ -71,8 +71,8 @@ describe('electron-builder config', () => {
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
})
it('builds RPMs without changing existing Linux artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
it('uses AppImage and deb as local Linux targets without changing existing artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
expect(electronBuilderConfig.rpm).toMatchObject({
@@ -81,6 +81,26 @@ describe('electron-builder config', () => {
})
})
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
const configPath = require.resolve('../electron-builder.config.cjs')
const original = process.env.ORCA_LINUX_ARM64_RELEASE
try {
delete require.cache[configPath]
process.env.ORCA_LINUX_ARM64_RELEASE = '1'
expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe(
'orca-linux-arm64.${ext}'
)
} finally {
if (original === undefined) {
delete process.env.ORCA_LINUX_ARM64_RELEASE
} else {
process.env.ORCA_LINUX_ARM64_RELEASE = original
}
delete require.cache[configPath]
require('../electron-builder.config.cjs')
}
})
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
+3 -3
View File
@@ -341,7 +341,7 @@ const BASE_LOCALE_KEY_OVERRIDES = {
'auto.App.62ca9895a7': { ko: '스페이스', zh: '空间', ja: 'スペース' },
'settings.appearance.statusBar.kimiToggleDescription': {
ko: '활성 워크스페이스의 Kimi 구독 사용량을 표시합니다.',
zh: 'Kimi 订阅',
zh: '显示当前工作区的 Kimi 订阅使用情况。',
ja: 'Kimi サブスクリプション'
},
'auto.components.mobile.MobileHero.cd4e5e816f': {
@@ -541,12 +541,12 @@ const BASE_LOCALE_KEY_OVERRIDES = {
},
'auto.components.editor.EditorContent.e4b074749d': {
ko: '머리말',
zh: '前线事项',
zh: '前言',
ja: 'フロントマター'
},
'auto.components.editor.MarkdownPreview.2b2b31382c': {
ko: '머리말',
zh: '前线事项',
zh: '前言',
ja: 'フロントマター'
},
'auto.components.settings.IntegrationsPane.c0c8575e05': {
@@ -1007,9 +1007,6 @@
"auto.components.feature.wall.ConnectIntegrationsList.33b650af52": {
"ko": "팀이 작업을 추적하는 서비스를 연결하세요. Orca는 이슈 제목, 링크, 컨텍스트가 이미 첨부된 워크스페이스를 시작합니다."
},
"auto.components.feature.wall.ConnectIntegrationsList.3a1fcdddad": {
"ko": "두 단계만 거치면 됩니다. 먼저 코드 리뷰 서비스를 연결한 다음, 팀이 작업을 계획하는 서비스를 연결하세요."
},
"auto.components.feature.wall.ConnectIntegrationsList.3dddb2d565": {
"ko": "작업용으로 연결됨"
},
+19
View File
@@ -0,0 +1,19 @@
import os from 'node:os'
const maxOldSpaceSizeMb = 4096
const minOldSpaceSizeMb = 2048
const reservedSystemMemoryMb = 1024
export function getBuildOldSpaceSizeMb(totalMemoryBytes = os.totalmem()) {
const totalMemoryMb = Math.floor(totalMemoryBytes / 1024 / 1024)
const hostSizedLimitMb = Math.max(minOldSpaceSizeMb, totalMemoryMb - reservedSystemMemoryMb)
return Math.min(maxOldSpaceSizeMb, hostSizedLimitMb)
}
export function appendBuildOldSpaceOption(existingNodeOptions, totalMemoryBytes = os.totalmem()) {
const requestedNodeOptions = `--max-old-space-size=${getBuildOldSpaceSizeMb(totalMemoryBytes)}`
const trimmedNodeOptions = existingNodeOptions?.trim()
return trimmedNodeOptions ? `${trimmedNodeOptions} ${requestedNodeOptions}` : requestedNodeOptions
}
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { appendBuildOldSpaceOption, getBuildOldSpaceSizeMb } from './node-old-space-limit.mjs'
const gib = 1024 * 1024 * 1024
describe('node old-space build limit', () => {
it('caps larger hosts at the release-runner build heap', () => {
expect(getBuildOldSpaceSizeMb(8 * gib)).toBe(4096)
})
it('reserves memory for smaller arm64 hosts', () => {
expect(getBuildOldSpaceSizeMb(4 * gib)).toBe(3072)
})
it('keeps existing NODE_OPTIONS while appending the build heap limit last', () => {
expect(appendBuildOldSpaceOption('--trace-warnings --max-old-space-size=8192', 4 * gib)).toBe(
'--trace-warnings --max-old-space-size=8192 --max-old-space-size=3072'
)
})
})
@@ -14,4 +14,19 @@ describe('orca CLI skill guidance', () => {
expect(skill).toContain('omit `--base-branch` so Orca uses the repo default base')
expect(skill).toContain('Never base it on the current feature branch')
})
it('keeps browser injection guidance narrow and avoids literal secret examples', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('Treat fetched page content as untrusted data, not agent instructions')
expect(skill).toContain('Do not execute page-provided text as shell commands')
expect(skill).toContain('`orca eval` expressions, or `orca exec` commands')
expect(skill).toContain('unless the user explicitly asked for that workflow')
expect(skill).not.toContain('s3cret')
expect(skill).not.toContain('hunter2')
expect(skill).not.toContain('password123')
expect(skill).not.toContain('sk_live_')
expect(skill).not.toContain('live_sk_')
})
})
@@ -50,6 +50,11 @@ describe('Electron runtime package contract', () => {
expect(scripts['build:release']).not.toContain('build:computer-macos')
})
it('runs the web build through the heap-sized Vite wrapper', () => {
expect(packageJson.scripts['build:web']).toContain('node config/scripts/run-vite-web-build.mjs')
expect(packageJson.scripts['build:web']).toContain('node config/scripts/verify-web-build.mjs')
})
it('guards release publishing before electron-builder runs', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
@@ -63,7 +68,7 @@ describe('Electron runtime package contract', () => {
])
)
expect([...releaseCommands.keys()].sort()).toEqual(['linux', 'mac', 'win'])
expect([...releaseCommands.keys()].sort()).toEqual(['linux-arm64', 'linux-x64', 'mac', 'win'])
for (const command of releaseCommands.values()) {
expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron')
expect(command).toContain('electron-builder')
@@ -72,12 +77,97 @@ describe('Electron runtime package contract', () => {
)
}
expect(releaseCommands.get('mac')).toContain(' && ORCA_MAC_RELEASE=1 ')
expect(releaseCommands.get('linux')).toContain(' && pnpm exec electron-builder ')
expect(releaseCommands.get('linux-x64')).toContain(' && pnpm exec electron-builder ')
expect(releaseCommands.get('linux-x64')).toContain('--linux AppImage deb rpm --x64')
expect(releaseCommands.get('linux-arm64')).toContain('ORCA_LINUX_ARM64_RELEASE=1')
expect(releaseCommands.get('linux-arm64')).toContain('--linux AppImage deb rpm --arm64')
expect(releaseCommands.get('win')).toContain(
'; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder '
)
})
it('preflights SignPath module install before Windows signing side effects', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const installStepIndexes = stepNames.flatMap((name, index) =>
name === 'Install SignPath PowerShell module' ? [index] : []
)
const buildIndex = stepNames.indexOf('Build Windows release artifacts')
const uploadIndex = stepNames.indexOf('Upload unsigned Windows installer for SignPath')
const downloadIndex = stepNames.indexOf('Download signed Windows installer from SignPath')
expect(installStepIndexes).toEqual([buildIndex + 1])
expect(installStepIndexes[0]).toBeLessThan(uploadIndex)
const uploadThroughDownloadScript = steps
.slice(uploadIndex, downloadIndex + 1)
.map((step) => step.run ?? '')
.join('\n')
expect(uploadThroughDownloadScript).not.toContain('Install-Module -Name SignPath')
const installStep = steps[installStepIndexes[0]]
const installRun = installStep.run
const sleepSeconds = [...installRun.matchAll(/Start-Sleep -Seconds (\d+)/g)].map(
([, seconds]) => seconds
)
expect(installStep.if).toBe("matrix.platform == 'win'")
expect(installStep.shell).toBe('pwsh')
expect(installRun).toContain('Set-PSRepository -Name PSGallery -InstallationPolicy Trusted')
expect(installRun).toMatch(/\$env:PSModulePath -split \[System\.IO\.Path\]::PathSeparator/)
expect(installRun).toContain(
"$signPathModulePath = Join-Path -Path $currentUserModuleRoot -ChildPath 'SignPath'"
)
expect(installRun).toMatch(/for \(\$attempt = 1; \$attempt -le 3; \$attempt\+\+\)/)
expect(sleepSeconds).toEqual(['15', '30'])
expect(installRun).toContain(
'Install-Module -Name SignPath -Repository PSGallery -MinimumVersion 4.0.0 -MaximumVersion 4.999.999 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop'
)
expect(installRun).toContain('Import-Module SignPath')
expect(installRun).toContain(
'Get-Command -Name Get-SignedArtifact -Module SignPath -ErrorAction Stop'
)
expect(installRun).toContain('Remove-Item -LiteralPath $signPathModulePath -Recurse -Force')
expect(installRun).not.toContain('SignPath*')
expect(installRun.indexOf('if ($attempt -eq 3)')).toBeLessThan(
installRun.indexOf('Remove-Item -LiteralPath $signPathModulePath')
)
expect(installRun).toMatch(/if \(\$attempt -eq 3\) {\s+throw\s+}/)
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('publishes both Linux release matrix entries', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
)
const parsedWorkflow = parse(releaseWorkflow)
const publishLinuxStep = parsedWorkflow.jobs.build.steps.find(
(step) => step.name === 'Publish release artifacts (Linux)'
)
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-x64'")
expect(publishLinuxStep.if).toContain("matrix.platform == 'linux-arm64'")
expect(publishLinuxStep.with.command).toBe('${{ matrix.release_command }}')
})
it('keeps Linux postinstall repairing Chromium sandbox permissions', () => {
const afterInstallScript = readFileSync(
join(projectDir, 'resources/linux/packaging/after-install.sh'),
'utf8'
)
expect(afterInstallScript).toContain('chrome-sandbox')
expect(afterInstallScript).toContain('chmod 4755 "$sandbox"')
expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"')
})
it('lets release-cut tag a version that is already present on main', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
+3 -7
View File
@@ -1,19 +1,15 @@
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import path from 'node:path'
import { appendBuildOldSpaceOption } from './node-old-space-limit.mjs'
const require = createRequire(import.meta.url)
const electronVitePackageJson = require.resolve('electron-vite/package.json')
const electronViteCli = path.join(path.dirname(electronVitePackageJson), 'bin', 'electron-vite.js')
const requestedNodeOptions = '--max-old-space-size=4096'
const existingNodeOptions = process.env.NODE_OPTIONS?.trim()
// Release builds have started OOMing on GitHub's macOS runners during the
// renderer bundle, so we force a larger heap here in one shared entrypoint
// instead of relying on shell-specific env syntax in individual scripts.
const nodeOptions = existingNodeOptions
? `${existingNodeOptions} ${requestedNodeOptions}`
: requestedNodeOptions
// renderer bundle. Reserve memory on smaller hosts so the OS does not kill Vite.
const nodeOptions = appendBuildOldSpaceOption(process.env.NODE_OPTIONS)
const child = spawn(process.execPath, [electronViteCli, 'build', ...process.argv.slice(2)], {
stdio: 'inherit',
@@ -0,0 +1,45 @@
import { spawnSync } from 'node:child_process'
const extraArgs = process.argv.slice(2)
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], {
stdio: 'inherit',
env
})
if (runtime.error) {
throw runtime.error
}
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
{
stdio: 'inherit',
env
}
)
if (result.error) {
throw result.error
}
process.exit(result.status ?? 1)
+33
View File
@@ -0,0 +1,33 @@
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import path from 'node:path'
import { appendBuildOldSpaceOption } from './node-old-space-limit.mjs'
const require = createRequire(import.meta.url)
const vitePackageJson = require.resolve('vite/package.json')
const viteCli = path.join(path.dirname(vitePackageJson), 'bin', 'vite.js')
// Why: Raspberry Pi and release runners can hit Node's default old-space
// ceiling, but smaller hosts still need memory left for the OS and bundler.
const nodeOptions = appendBuildOldSpaceOption(process.env.NODE_OPTIONS)
const child = spawn(
process.execPath,
[viteCli, 'build', '--config', 'vite.web.config.ts', ...process.argv.slice(2)],
{
stdio: 'inherit',
env: {
...process.env,
NODE_OPTIONS: nodeOptions
}
}
)
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { basename } from 'node:path'
import { collectTerminalPerfRows, readJsonReport } from './terminal-perf-report-annotations.mjs'
const reportPaths = process.argv.slice(2)
if (reportPaths[0] === '--') {
@@ -13,55 +13,6 @@ if (reportPaths.length === 0) {
process.exit(1)
}
function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
function collectTerminalPerfRows(report, source) {
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith('opencode-')) {
continue
}
rows.push({
source,
scenario: annotation.type,
...parseAnnotationDescription(annotation.description ?? '')
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
function markdownCell(value) {
return String(value ?? '').replaceAll('|', '\\|')
}
@@ -0,0 +1,63 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
const scriptPath = 'config/scripts/summarize-terminal-perf-report.mjs'
const tempDirs = []
function writeReport() {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-summary-'))
tempDirs.push(dir)
const reportPath = join(dir, 'report.json')
writeFileSync(
reportPath,
JSON.stringify({
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'panes=50 frames=60 median=12.3ms rendererQueuedChars=1000'
},
{
type: 'browser-unrelated',
description: 'panes=1 median=999.0ms'
}
]
}
]
}
]
}
]
})
)
return reportPath
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('summarize-terminal-perf-report', () => {
it('prints only OpenCode terminal perf annotation rows', () => {
const output = execFileSync(process.execPath, [scriptPath, writeReport()], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(output).toContain('| Source | Scenario | Panes | Frames | Median |')
expect(output).toContain('| report.json | opencode-scale | 50 | 60 | 12.3ms |')
expect(output).toContain('1000')
expect(output).not.toContain('browser-unrelated')
expect(output).not.toContain('999.0ms')
})
})
@@ -0,0 +1,53 @@
import { readFileSync } from 'node:fs'
export function readJsonReport(path) {
const raw = readFileSync(path, 'utf8')
const start = raw.indexOf('{')
const end = raw.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error(`${path}: no JSON object found`)
}
return JSON.parse(raw.slice(start, end + 1))
}
export function parseAnnotationDescription(description) {
const values = {}
for (const part of description.split(/\s+/)) {
const index = part.indexOf('=')
if (index === -1) {
continue
}
values[part.slice(0, index)] = part.slice(index + 1)
}
return values
}
export function collectTerminalPerfRows(report, source, options = {}) {
const { typePrefix = 'opencode-' } = options
const rows = []
const visitSuite = (suite) => {
for (const spec of suite.specs ?? []) {
for (const test of spec.tests ?? []) {
for (const annotation of test.annotations ?? []) {
if (!annotation.type.startsWith(typePrefix)) {
continue
}
rows.push({
...parseAnnotationDescription(annotation.description ?? ''),
// Why: annotation descriptions are artifact-controlled; keep the
// trusted report source and annotation type from being relabeled.
source,
scenario: annotation.type
})
}
}
}
for (const child of suite.suites ?? []) {
visitSuite(child)
}
}
for (const suite of report.suites ?? []) {
visitSuite(suite)
}
return rows
}
@@ -0,0 +1,144 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
collectTerminalPerfRows,
parseAnnotationDescription,
readJsonReport
} from './terminal-perf-report-annotations.mjs'
const tempDirs = []
function makeReportPath(content) {
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-annotations-'))
tempDirs.push(dir)
const reportPath = join(dir, 'report.json')
writeFileSync(reportPath, content)
return reportPath
}
function makeNestedReport() {
return {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'browser-unrelated',
description: 'median=999.0ms'
}
]
}
]
}
],
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'panes=50 median=12.3ms ignored-token rendererQueuedChars=1000'
},
{
type: 'terminal-scale',
description: 'panes=25 median=7.0ms'
}
]
}
]
}
]
}
]
}
]
}
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { force: true, recursive: true })
}
})
describe('terminal perf report annotations', () => {
it('parses key-value annotation description segments only', () => {
expect(parseAnnotationDescription('panes=50 no-equals median=12.3ms name=a=b')).toEqual({
median: '12.3ms',
name: 'a=b',
panes: '50'
})
})
it('reads JSON reports surrounded by noisy process output', () => {
const reportPath = makeReportPath(
`noise before\n${JSON.stringify({ suites: [] })}\nnoise after`
)
expect(readJsonReport(reportPath)).toEqual({ suites: [] })
})
it('collects nested OpenCode annotations by default', () => {
expect(collectTerminalPerfRows(makeNestedReport(), 'report.json')).toEqual([
{
median: '12.3ms',
panes: '50',
rendererQueuedChars: '1000',
scenario: 'opencode-scale',
source: 'report.json'
}
])
})
it('keeps trusted source and scenario fields when descriptions contain matching keys', () => {
const report = {
suites: [
{
specs: [
{
tests: [
{
annotations: [
{
type: 'opencode-scale',
description: 'source=spoofed.json scenario=spoofed median=12.3ms'
}
]
}
]
}
]
}
]
}
expect(collectTerminalPerfRows(report, 'report.json')).toEqual([
{
median: '12.3ms',
scenario: 'opencode-scale',
source: 'report.json'
}
])
})
it('supports a custom annotation type prefix', () => {
expect(
collectTerminalPerfRows(makeNestedReport(), 'report.json', { typePrefix: 'terminal-' })
).toEqual([
{
median: '7.0ms',
panes: '25',
scenario: 'terminal-scale',
source: 'report.json'
}
])
})
})
@@ -8,11 +8,15 @@ export function getRequiredReleaseAssetNames(tag) {
const version = tag.replace(/^v/i, '')
return [
'latest-linux.yml',
'latest-linux-arm64.yml',
'latest-mac.yml',
'latest.yml',
'orca-linux.AppImage',
'orca-linux-arm64.AppImage',
`orca-ide_${version}_amd64.deb`,
`orca-ide_${version}_arm64.deb`,
`orca-ide-${version}.x86_64.rpm`,
`orca-ide-${version}.aarch64.rpm`,
'orca-windows-setup.exe',
'orca-windows-setup.exe.blockmap',
`Orca-${version}-mac.zip`,
@@ -86,7 +90,12 @@ export async function verifyRequiredReleaseAssets({ repo, tag, token }) {
const assetsByName = new Map(release.assets.map((asset) => [asset.name, asset]))
const requiredNames = new Set(getRequiredReleaseAssetNames(tag))
const manifestNames = ['latest-linux.yml', 'latest-mac.yml', 'latest.yml']
const manifestNames = [
'latest-linux.yml',
'latest-linux-arm64.yml',
'latest-mac.yml',
'latest.yml'
]
for (const manifestName of manifestNames) {
const manifestAsset = assetsByName.get(manifestName)
@@ -45,12 +45,16 @@ describe('getRequiredReleaseAssetNames', () => {
)
})
it('includes the Linux RPM alongside the existing AppImage and deb names', () => {
it('includes x64 and arm64 Linux assets', () => {
expect(getRequiredReleaseAssetNames('v1.4.27')).toEqual(
expect.arrayContaining([
'latest-linux-arm64.yml',
'orca-linux.AppImage',
'orca-linux-arm64.AppImage',
'orca-ide_1.4.27_amd64.deb',
'orca-ide-1.4.27.x86_64.rpm'
'orca-ide_1.4.27_arm64.deb',
'orca-ide-1.4.27.x86_64.rpm',
'orca-ide-1.4.27.aarch64.rpm'
])
)
})
@@ -100,4 +104,32 @@ describe('verifyRequiredReleaseAssets', () => {
).rejects.toThrow('Missing: Orca-1.4.27-arm64-mac.zip')
expect(latestMacAsset).toBeTruthy()
})
it('checks assets referenced by the Linux arm64 updater manifest', async () => {
const tag = 'v1.4.27'
const required = getRequiredReleaseAssetNames(tag)
const release = releaseWithAssets(tag, required)
const arm64Manifest = release.assets.find((asset) => asset.name === 'latest-linux-arm64.yml')
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse([release]))
.mockResolvedValueOnce(jsonResponse('version: 1.4.27\n'))
.mockResolvedValueOnce(
jsonResponse(
[
'version: 1.4.27',
'files:',
' - url: orca-linux-arm64.AppImage.blockmap',
'path: orca-linux-arm64.AppImage'
].join('\n')
)
)
.mockResolvedValue(jsonResponse('version: 1.4.27\n'))
vi.stubGlobal('fetch', fetchMock)
await expect(
verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag, token: 'token' })
).rejects.toThrow('Missing: orca-linux-arm64.AppImage.blockmap')
expect(arm64Manifest).toBeTruthy()
})
})
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 1.3m">
<title>downloads: 1.3m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 2.2m">
<title>downloads: 2.2m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">1.3m</text>
<text x="93.5" y="14">1.3m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">2.2m</text>
<text x="93.5" y="14">2.2m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

+137
View File
@@ -0,0 +1,137 @@
# GitHub Tasks Close Reason Parity
## Problem
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1025) renders `GHStatusCell` with only `Open` and `Closed`.
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1064) sends `{ state: 'closed' }`, so the list path cannot choose GitHub close reasons.
- [TaskPage.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/TaskPage.tsx:1137) styles closed issues with rose/destructive color, while GitHub treats closing as a neutral/purple completion action rather than a delete/error action.
- [GitHubItemDialog.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/GitHubItemDialog.tsx:4960) has a separate issue-detail status popover; it must expose the same close reasons so opening an issue from Tasks does not fall back to the old Open/Closed-only UI.
- [GitHubIssueCommentComposer.tsx](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/renderer/src/components/github/GitHubIssueCommentComposer.tsx:188) already supports close reasons in the detail composer, but the Tasks list does not.
## Goal
Bring the GitHub Tasks list status menu closer to GitHub:
1. Closed issue UI must stop using destructive red styling.
2. Open issues must expose close-as-completed, close-as-not-planned, and close-as-duplicate from the status menu.
3. Duplicate closes must require a target issue number and pass it as `duplicateOf`.
4. Reopening still works from the same status menu.
5. GitHub issue detail metadata must use the same close-reason and duplicate-picker behavior as the Tasks row.
## Non-goals
- No provider-generic changes for GitLab, Linear, or Jira.
- No network-backed GitHub issue search picker in this pass; the duplicate picker uses the loaded Tasks cache plus an exact issue-number fallback.
- No schema changes or cache migration.
- No change to PR status behavior.
- No automatic cross-window broadcast beyond the existing cache/list refresh mechanics; this pass updates the current renderer optimistically and relies on normal refetch in other windows.
## Design
1. Add a small TaskPage GitHub status menu model in a named module, including option metadata, duplicate-target validation, and payload construction for close reasons.
2. Update `GHStatusCell` so `handleStateChange` accepts close options, optimistically patches only the issue `state`, and sends `stateReason` plus optional `duplicateOf`.
3. Route issue-state mutations by issue identity:
- If the row URL parses to `owner/repo`, use `github.project.updateIssueBySlug` / runtime `github.project.updateIssueBySlug`. This avoids closing the wrong repository for GitHub project/custom-source rows whose issue repo can differ from the caller repo.
- Otherwise keep the existing `github.updateIssue` path with `repoPath`/`repoId`/`sourceContext`.
4. Render the status popover with `Open`, `Close as completed`, `Close as not planned`, and `Close as duplicate` in both the Tasks row and issue-detail metadata sidebar. The duplicate row advances to a second-step picker with a back button, search field, loaded issue candidates, and an exact-number fallback.
5. Replace rose closed-state classes with token-based primary/ring color-mix classes so the closed pill is completion-like, not destructive. Keep the open-state green treatment for parity with the existing Tasks page unless a broader status-color pass changes it.
6. Keep the payload serializable for IPC/runtime/SSH: `{ state, stateReason, duplicateOf }` only contains strings and numbers.
## API Notes
- The local path in [issues.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/issues.ts:231) already closes issues with `gh issue close`. Preserve that model and pass:
- `--reason completed` for `stateReason: 'completed'`
- `--reason "not planned"` for `stateReason: 'not_planned'`
- `--duplicate-of <number>` for `stateReason: 'duplicate'` with `duplicateOf`
- The slug path in [mutations.ts](/Users/jinwoohong/orca/workspaces/orca/tasks-page-parity/src/main/github/project-view/mutations.ts:160) must not implement duplicates by PATCHing `duplicate_of` on `repos/{owner}/{repo}/issues/{n}`. `gh issue close --duplicate-of` is the supported CLI surface already used by the path-based mutation, so slug-addressed state changes should use `gh issue close/reopen --repo owner/repo` for state and reserve REST PATCH for title/body.
- When `stateReason: 'duplicate'` is sent without a valid `duplicateOf`, reject it before dispatch. Do not silently downgrade to plain closed or `--reason duplicate`, because GitHub's duplicate UX expects the target issue to be recorded.
- Keep `state_reason` REST PATCH only for non-duplicate close reasons if the implementation deliberately stays on REST for completed/not-planned. The simpler consistency target is to route all slug-addressed state changes through `gh issue close/reopen`.
## Data Flow
- User opens a GitHub issue status pill.
- Completed/not-planned click -> `handleStateChange('closed', { stateReason })`.
- Duplicate click -> second-step picker -> select a loaded issue or exact target number -> `handleStateChange('closed', { stateReason: 'duplicate', duplicateOf })`.
- Cell optimistically patches local state and store row state.
- Mutation goes through local IPC or runtime RPC with the same repo/source context.
- Failure reverts the local/store state and shows the existing error toast.
- Success records the existing `github-tasks` feature interaction and should invalidate or refresh the relevant work-items cache entry if the current filter can hide the row after the state changes (for example, an `is:open` filter). Without that, a closed row can remain visible until a later refetch.
## Edge Cases
- Duplicate target is blank, zero, negative, decimal, or the same issue number: keep the picker open and show inline validation after Enter.
- Duplicate target can be a different repository's issue only when the implementation accepts a URL; this pass takes a loaded same-repository issue or number, so validation and copy should describe it as an issue in the same repository.
- The row refreshes while the menu is open: reconcile the optimistic draft before paint as the current status cell already does.
- Rapid repeated choices: retain the existing request id guard so stale responses cannot revert a newer choice.
- SSH/runtime target: payload must remain serializable and use the existing runtime method.
- Multi-window consistency: do not assume another window's in-memory `workItemsCache` observes the optimistic patch. Ensure the mutation path updates GitHub authoritatively and that normal cache invalidation/refetch behavior eventually corrects other windows.
- Project/custom-source rows: prefer the parsed issue URL slug over the selected local repo when available, because a GitHub Project can contain issues from repositories other than the current worktree.
- PR rows and rows without a repo continue to render a non-editable status pill.
- Rows without a parsed slug and without a local repo/source context should stay non-editable; do not attempt a mutation from only an issue number.
## Test Plan
- Unit: menu model builds `{ state: 'closed', stateReason: 'completed' }`, `{ state: 'closed', stateReason: 'not_planned' }`, and duplicate payloads only for valid duplicate targets.
- Unit: duplicate validation rejects missing/self/non-positive/non-integer targets.
- Existing unit: optimistic status draft reconciliation remains unchanged.
- Main unit: `updateIssue` emits `gh issue close --reason completed`, `gh issue close --reason "not planned"`, `gh issue close --duplicate-of <n>`, and `gh issue reopen`.
- Main unit: `updateIssueBySlug` uses `gh issue close/reopen --repo owner/repo` for state changes, rejects duplicate without `duplicateOf`, and does not send unsupported `duplicate_of` REST fields.
- Renderer unit: `GHStatusCell` uses slug-addressed mutation when `item.url` contains an owner/repo and falls back to `github.updateIssue` only when slug parsing is unavailable.
- Store/cache unit: successful close from an open-filtered Tasks list invalidates or refreshes the affected work-items cache so hidden-by-filter rows do not linger indefinitely.
- Electron validation: Tasks GitHub issue row status menu shows the new close options and closed issue pill is not red.
- Electron validation: duplicate close picker appears after the duplicate action and supports issue search/exact-number fallback. Do not submit a real close mutation against user data.
- Electron validation: GitHub issue detail status sidebar shows the same close options and duplicate picker as the list row.
- Electron/runtime smoke: same menu opens and validation works when the active runtime target is an SSH/environment source; avoid a real close mutation unless using disposable test data.
## UI Quality Bar
- Popover should feel like an action menu, not an error/destructive confirmation.
- Closed issue pill must be visually distinct from open but not red/destructive.
- Duplicate picker must fit in the popover at desktop and narrow widths without clipping.
- Hover, focus, disabled, and inline validation states use existing shadcn primitives/tokens.
## Review Screenshots
1. GitHub Tasks issue row with status popover open showing all close reasons.
2. Duplicate close second-step picker with search and issue candidates.
3. Closed issue row/pill showing non-red completion styling.
4. GitHub issue detail sidebar status popover showing all close reasons.
5. GitHub issue detail duplicate close second-step picker.
6. Adjacent smoke: GitHub Tasks header/search area still renders normally.
## Rollout
1. Add status menu model and tests.
2. Update `GHStatusCell` UI and mutation payload.
3. Run focused tests, typecheck, and lint.
4. Run UI quality review.
5. Validate in Electron and capture screenshots.
## Lightweight Eng Review
- Scope: kept to the GitHub Tasks status cell plus a small model module; no provider-wide or network-backed search-picker work.
- Architecture/data flow: preserves current renderer -> IPC/runtime mutation boundaries, but must route rows with parseable GitHub slugs through `updateIssueBySlug` so project/custom-source issues mutate the issue's own repository rather than the selected worktree repo.
- API feasibility: path-based mutations already use the supported `gh issue close --duplicate-of` CLI path; slug-addressed mutations need the same CLI close/reopen treatment instead of REST `duplicate_of`.
- Failure modes covered:
- Invalid duplicate targets stay local and do not mutate.
- Failed mutations revert optimistic state using the existing request id guard.
- Runtime/SSH remains on the existing serializable RPC method.
- Unsupported duplicate API shape is avoided by keeping duplicate close on `gh issue close --duplicate-of`.
- Rows filtered by open/closed status do not rely solely on optimistic patching; the relevant work-items cache must be invalidated/refetched after success.
- Test coverage required:
- Unit tests for close update payloads and duplicate validation.
- Main-process tests for both `updateIssue` and `updateIssueBySlug` close/reopen command generation.
- Renderer routing tests for slug-addressed versus repo-path-addressed issue rows.
- Cache invalidation/refetch coverage for status-filtered Tasks lists.
- Existing status draft tests for optimistic reconciliation.
- Electron screenshots for menu, duplicate validation, closed styling, and adjacent header smoke.
- Performance/blast radius: no material concern; one extra tiny model import and no new polling/IPC.
- UI quality bar: compact GitHub-like action menu, token-based non-destructive closed styling, no clipping or overlap.
- Required review screenshots:
1. Status popover with close reasons.
2. Duplicate second-step picker with search and issue candidates.
3. Non-red closed status pill.
4. Tasks header/search smoke.
- Residual risks:
- Duplicate target search is limited to loaded Tasks cache candidates, with exact-number fallback for unloaded same-repository issues.
- Other open Orca windows may show stale status until their normal GitHub Tasks data refresh runs.
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="Descargas totales en todas las versiones" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="Licencia" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Únete al Discord de Orca" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="Sigue a Orca en X" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="Plataformas compatibles: macOS, Windows y Linux" />
</p>
@@ -35,7 +36,7 @@
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="全リリースの合計ダウンロード数" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="ライセンス" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca の Discord に参加" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X で Orca をフォロー" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="対応プラットフォーム: macOS、Windows、Linux" />
</p>
@@ -35,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="전체 릴리스 누적 다운로드 수" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="라이선스" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="Orca Discord 참여" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="X에서 Orca 팔로우" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="지원 플랫폼: macOS, Windows, Linux" />
</p>
@@ -35,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
+3 -2
View File
@@ -7,6 +7,7 @@
<a href="https://github.com/stablyai/orca/releases"><img src="../assets/readme-downloads.svg" alt="所有版本的总下载量" /></a>
<img src="https://badgen.net/github/license/stablyai/orca" alt="许可证" />
<a href="https://discord.gg/fzjDKHxv8Q"><img src="https://img.shields.io/badge/Discord-5865F2?logo=discord&logoColor=white" alt="加入 Orca Discord" /></a>
<a href="https://x.com/orca_build"><img src="https://img.shields.io/badge/X-000000?logo=x&logoColor=white" alt="在 X 上关注 Orca" /></a>
<img src="https://img.shields.io/badge/macOS%20%7C%20Windows%20%7C%20Linux-4493F8?style=flat-square" alt="支持的平台:macOS、Windows 和 Linux" />
</p>
@@ -35,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -226,7 +227,7 @@ yay -S stably-orca-bin
与桌面应用配对,用手机监控并指挥你的智能体。
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.14/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.16/app-release.apk)
---
@@ -112,7 +112,7 @@ Emit once per setup step when that step first transitions from incomplete to com
Payload:
- `step_id`: one of `split-terminal`, `two-worktrees`, `notifications`, `default-agent`, `task-sources`, `setup-script`, `add-two-repos`, `agent-capabilities`
- `step_id`: one of `two-worktrees`, `browser`, `notifications`, `default-agent`, `task-sources`, `setup-script`, `add-two-repos`, `agent-capabilities`
- `section_id`: `parallel-work` or `setup`
- `completed_count`: integer `1..8`
- `total_steps`: integer `8`
+329
View File
@@ -0,0 +1,329 @@
# Terminal Scroll Intent Architecture
## Problem
Terminal panes can jump or jitter when a user switches workspaces while a TUI
pane is scrolled near, but not exactly at, the bottom. The most visible case is a
Codex-style alternate-screen TUI in a split pane:
1. The user scrolls slightly up from the bottom.
2. The user switches to another workspace.
3. The user switches back.
4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a
wrong position, or restores an older viewport.
The current code tries to repair the viewport after layout, fit, split
reparenting, hidden-output replay, and visibility resume. Those repairs compete
with each other because none of them owns the user's scroll intent. A delayed
restore can replay an old state after the user has already scrolled, while a
follow-output path can force the bottom even when the user is reading scrollback.
## Reference Investigation Summary
The useful reference pattern is not a timer-based restore loop. It is an
explicit pinned-to-bottom model owned by the terminal frontend:
- The frontend stores bottom-following state separately from xterm's transient
`viewportY`.
- `xterm.onScroll` is not used as user intent. In xterm, that event can be
content-driven and can briefly report bottom during fast output or layout work.
- User wheel and keyboard scroll commands update the intent.
- `scrollToBottom()` is an explicit command that sets the intent back to
follow-output.
- Every write snapshots the intent before writing. If the terminal was following
output, it scrolls to bottom after the write. If the user was pinned to a
viewport, it preserves the prior viewport line.
- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise
preserve the current viewport.
- Alternate-screen state is tracked from `buffer.active.type`, but it is not a
reason to infer user scroll intent from `onScroll`.
The reference implementation also patches private xterm internals to disable
xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll
only from explicit owner paths. That gives a clean ownership boundary, but it is
an escalation point for Orca because private internals increase xterm upgrade
risk.
## Current Orca Risk Points
The current Orca branch already has several independent scroll actors:
- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`,
`baseY`, bottom state, and sometimes a marker, then restores using immediate,
rAF, and timeout paths.
- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores
scroll around `safeFit()`.
- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split
reparent restores across rAF and timeout phases.
- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts`
listens to `terminal.onScroll` and stores snapshots while visible.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output
through foreground, background, hidden-output skip, snapshot replay, and
restore paths.
- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can
write immediately, enqueue foreground writes, coalesce synchronized output, or
drain hidden/background chunks later.
The biggest architectural mismatch is the visibility memory hook's use of
`terminal.onScroll` as a snapshot trigger. That event is not a reliable user
scroll signal. During workspace switching, output parsing, hidden replay, or fit
can make it persist transient positions that later appear as "older position"
restores.
## Design Goal
Make terminal viewport movement a result of explicit scroll intent, not a side
effect of visibility, output, or layout timing.
The terminal should have one active scroll intent per pane:
- `followOutput`: the terminal is logically pinned to the live output bottom.
New output, explicit focus-follow requests, and fits may keep it at bottom.
- `pinnedViewport`: the user is reading a specific viewport. New output,
workspace switches, hidden-output replay, and fits must not move the viewport
except when scrollback pruning or buffer replacement makes the exact line
impossible.
## State Model
Add a focused module, for example:
`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts`
Suggested state:
```ts
export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
export type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: 'normal' | 'alternate'
viewportY: number
baseY: number
capturedAt: number
}
```
The state should be keyed by the live `Terminal` or by pane leaf identity where
it needs to survive pane replacement. For normal workspace switches that keep
the same xterm instance, the live terminal-keyed state should be authoritative.
## Intent Transitions
Only these events should change scroll intent:
- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write
callbacks or rAF can pull the viewport back down.
- User wheel scrolls downward: after xterm applies the scroll, recompute whether
the viewport reached bottom. If yes, set `followOutput`; otherwise keep
`pinnedViewport`.
- User keyboard scroll commands: apply the same rules as wheel.
- Explicit `scrollToBottom()`, "follow output", or focus command with
follow-output semantics: set `followOutput`.
- Programmatic `scrollToTop()` or page/line scroll commands: set
`pinnedViewport`, unless the resulting viewport is bottom.
- Buffer change to alternate screen: record `bufferType`, but do not infer
follow-output from `onScroll`.
- Buffer return to normal screen: recompute from current viewport only if no
stronger user intent exists for the normal buffer.
- Scrollback prune: clamp a pinned viewport to the nearest valid line without
changing intent.
- Terminal remount/replay: restore from durable fallback only if the live xterm
instance was actually replaced.
Do not use xterm `onScroll` to set intent. It may still be useful as a passive
diagnostic signal, but it should not persist authoritative scroll state.
## Write Contract
All PTY output that reaches xterm should pass through one wrapper that enforces
intent around the actual `terminal.write` call.
Suggested owner:
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
Contract:
1. Capture intent and current `viewportY` before the write is scheduled.
2. Execute the write through the existing foreground/background/coalescing path.
3. After the write has parsed enough for xterm buffer state to be meaningful:
- If intent is `followOutput`, scroll to bottom.
- If intent is `pinnedViewport`, clamp and restore the saved viewport line.
4. Do not schedule multi-frame retries for normal output. A single post-write
enforcement point should be enough for standard writes.
For foreground synchronized-output holds, the enforcement point should run after
the held/coalesced frame is released, not for each partial cursor-hide chunk.
For background writes, preserve `pinnedViewport` if the pane is hidden or
inactive. Hidden output should not silently repin the terminal.
## Fit And Resize Contract
`safeFit()` should stop using generic delayed scroll restoration as its normal
behavior.
New rule:
1. If dimensions do not change, do nothing.
2. Before fit, capture current intent and viewport.
3. Fit.
4. If intent is `followOutput`, scroll to bottom.
5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the
new `baseY`.
Avoid fitting hidden or zero-geometry panes. Existing geometry guards should
remain because fitting to transient workspace-switch geometry can send bad sizes
to the PTY and trigger TUI redraw churn.
## Visibility And Workspace Switch Contract
Workspace switching should not be a scroll operation.
On hide:
- Capture the current intent once.
- Do not run scroll restore.
- Do not update intent from `onScroll`.
- Continue hidden-output throttling/snapshot behavior as today.
On show:
- Reattach or refresh renderer resources as needed.
- Flush only the bounded amount of hidden output required for the active pane.
- Apply intent once after the visible output catch-up:
- `followOutput` goes to bottom.
- `pinnedViewport` stays pinned.
- Do not run repeated rAF/timeout scroll restores.
If the underlying terminal instance was replaced, use durable fallback state.
If the instance stayed alive, its live scroll intent is authoritative.
## Hidden Output And Snapshot Replay
Hidden-output snapshot replay is a true fallback path because it clears and
reconstructs xterm content. It needs scroll handling, but it should still obey
intent:
- Before snapshot replay, capture intent and viewport.
- Replay serialized content.
- Fit only if needed and only with valid visible geometry.
- Reapply the captured intent once:
- `followOutput`: bottom.
- `pinnedViewport`: previous viewport line, clamped.
- Do not let replay set follow-output just because the replayed buffer ends at
bottom.
If hidden output arrives while the user is pinned, background catch-up should not
move the visible viewport on activation.
## Split Reparenting
DOM reparenting can reset browser scroll state and WebGL resources. This is a
legitimate place for a fallback restore, but it should be scoped:
- Capture intent before reparent.
- Reparent.
- Reattach renderer if required.
- Apply intent once after DOM settles.
- Avoid restoring alternate-screen scrollback, because alternate screen has no
normal scrollback and a TUI owns its cursor.
The current split-specific rAF/timer code should become a local fallback for DOM
reparenting only, not a general model copied into workspace switching.
## Performance Constraints
The design must preserve Orca's terminal performance priorities:
- Do not keep all hidden terminals hot-rendering.
- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering
work and throttle hidden output as today.
- Avoid per-output layout reads. Intent enforcement should read xterm buffer
fields, not DOM geometry.
- Avoid multi-frame restore loops. They cause visible jitter and keep the
renderer busy after activation.
- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes
can catch up over later frames.
- Do not send resize/SIGWINCH unless dimensions actually changed and the
renderer is the authoritative size owner.
The intended steady-state cost is one small buffer-state capture per xterm write
batch, not per byte and not per animation frame.
## Implementation Plan
1. Add `terminal-scroll-intent.ts`.
- Track `followOutput` vs `pinnedViewport`.
- Provide helpers for user scroll, explicit bottom, write capture, fit
capture, and intent enforcement.
2. Replace visibility-memory `onScroll` ownership.
- Remove authoritative snapshot updates from `terminal.onScroll`.
- Add capture-phase wheel listeners and keyboard scroll command hooks.
- Keep `onScroll` only for diagnostics if needed.
3. Route explicit scroll commands through intent helpers.
- `scrollToBottom()` sets `followOutput`.
- `scrollToTop()`, page up, and line up set `pinnedViewport`.
- Downward commands recompute after the command.
4. Wrap output writes.
- Add an intent-aware writer boundary in the output scheduler or in a narrow
wrapper used by the scheduler.
- Ensure foreground coalescing applies intent after the coalesced frame.
- Ensure background drains preserve pinned viewports.
5. Convert `safeFit()` to intent-aware fit.
- Capture intent before fit.
- Apply once after fit.
- Remove generic deferred scroll restore from normal fit.
6. Limit restore fallbacks.
- Keep fallback restore only for true remount/replay/reparent cases.
- Remove workspace-switch rAF/timeout restore loops once intent enforcement is
in place.
7. Add focused tests.
- User scroll up sets `pinnedViewport`.
- Output while pinned preserves viewport.
- Output while following scrolls to bottom.
- Fit while pinned preserves viewport.
- Fit while following stays at bottom.
- Hidden-output replay while pinned does not repin.
- Workspace hide/show does not change intent.
- `onScroll` does not mutate intent.
8. Add an E2E reproduction.
- Use the existing `scroll-primary` and `scroll-secondary` worktrees.
- Top pane in `scroll-primary` contains the TUI with enough scrollback.
- Scroll slightly above bottom, switch to `scroll-secondary`, then switch back.
- Assert the terminal remains within a small viewport tolerance and does not
visit top/bottom during the transition.
## Acceptance Criteria
- Switching away and back does not move a pinned TUI viewport.
- No visible flash to top or bottom during activation.
- Scrolling all the way to bottom re-enables follow-output and does not later
restore an older pinned position.
- Hidden output in inactive workspaces does not change visible scroll position
until the user explicitly follows output or reaches bottom.
- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts
their cursor or scroll region.
- The active pane remains responsive during heavy hidden-output catch-up.
## Open Decisions
- Whether Orca should patch private xterm bottom-scroll internals. The reference
pattern does this for strict ownership, but Orca should first try public API
enforcement at write/fit boundaries and only escalate if xterm continues to
auto-follow independently.
- Whether scroll intent should be stored only by live `Terminal` instance or also
mirrored by leaf ID for remount fallback. The initial implementation should
use live instance state plus a leaf-keyed fallback for true replacement.
- Whether alternate screen needs a separate intent record from normal screen.
The first version can use one record with `bufferType`; a separate normal vs
alternate record is justified only if testing shows mode switches overwrite
user intent.
+1 -1
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.15",
"version": "0.0.16",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -0,0 +1,14 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileFilePreviewScreen } from '../../../../../src/files/MobileFilePreviewScreen'
import { normalizeMobileFilePreviewRouteParams } from '../../../../../src/files/mobile-file-preview-route'
export default function MobileFilePreviewRoute() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
relativePath?: string | string[]
name?: string | string[]
worktreeName?: string | string[]
}>()
return <MobileFilePreviewScreen route={normalizeMobileFilePreviewRouteParams(params)} />
}
+43 -35
View File
@@ -83,6 +83,7 @@ import {
type Worktree
} from '../../../src/worktree/workspace-list-sections'
import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections'
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
import { repoColor } from '../../../src/worktree/repo-color'
import {
@@ -90,6 +91,8 @@ import {
WORKSPACE_SORT_OPTIONS as SORT_OPTIONS
} from '../../../src/worktree/workspace-list-picker-options'
import type { DesktopStatus, RepoSummary } from '../../../src/worktree/host-worktree-rpc-types'
import type { WorkspaceStatusDefinition } from '../../../../src/shared/types'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from '../../../src/worktree/mobile-workspace-statuses'
function isErrorVerdict(v: ConnectionVerdict): boolean {
return v.kind === 'warning' || v.kind === 'unreachable' || v.kind === 'auth-failed'
@@ -168,6 +171,9 @@ export function HostScreen({
hideDefaultBranch: false
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id, populated from repo.list. The filter model keys on
// repo ids (desktop's PersistedUIState), but the section headers/rows key on
// displayName, so we bridge the two here.
@@ -198,6 +204,7 @@ export function HostScreen({
hideDefaultBranch: false,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES,
workspaceHostScope: undefined,
visibleWorkspaceHostIds: undefined
})
@@ -210,10 +217,11 @@ export function HostScreen({
hideDefaultBranch: filters.hideDefaultBranch,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
}
}, [groupMode, sortMode, filters, collapsedGroups])
}, [groupMode, sortMode, filters, collapsedGroups, workspaceStatuses])
// Apply a MobileViewState (e.g. from a desktop ui.get) onto the individual
// states and the snapshot ref in one shot.
@@ -221,6 +229,7 @@ export function HostScreen({
viewStateRef.current = next
setGroupMode(next.groupMode)
setSortMode(next.sortMode)
setWorkspaceStatuses(next.workspaceStatuses)
setCollapsedGroups(new Set(next.collapsedGroups))
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
@@ -701,9 +710,12 @@ export function HostScreen({
// Highlight the row immediately; the next worktree.ps poll confirms it.
setOptimisticActiveWorktreeId(item.worktreeId)
if (client && connState === 'connected') {
// Why: opening a mobile session should hydrate host-owned tabs without
// pulling other paired clients, especially desktop, into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${item.worktreeId}`
worktree: `id:${item.worktreeId}`,
notifyClients: false
})
.catch(() => null)
}
@@ -756,6 +768,8 @@ export function HostScreen({
count += filters.filterRepoIds.size
return count
}, [filters])
const selectedSortLabel =
SORT_OPTIONS.find((option) => option.value === sortMode)?.label ?? 'Recent'
const handleGroupChange = useCallback(
(value: MobileGroupMode) => {
@@ -787,12 +801,12 @@ export function HostScreen({
}, [connState, worktrees, lastKnownWorktrees, sleptIds, optimisticActiveWorktreeId])
const toggleCollapsed = useCallback(
(title: string) => {
(key: string) => {
const next = new Set(viewStateRef.current.collapsedGroups)
if (next.has(title)) {
next.delete(title)
if (next.has(key)) {
next.delete(key)
} else {
next.add(title)
next.add(key)
}
persistViewSettings({ collapsedGroups: [...next] })
},
@@ -810,7 +824,8 @@ export function HostScreen({
repoColorsByName,
collapsedGroups,
workspaceHostScope: viewStateRef.current.workspaceHostScope,
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds
visibleWorkspaceHostIds: viewStateRef.current.visibleWorkspaceHostIds,
workspaceStatuses
})
const existingWorktreePaths = useMemo(() => worktrees.map((w) => w.path), [worktrees])
@@ -927,19 +942,19 @@ export function HostScreen({
</Pressable>
<Pressable
style={[styles.sortButton, styles.embeddedModeButton]}
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => setShowSortPicker(true)}
accessibilityRole="button"
accessibilityLabel={`Sort by ${SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? 'Recent'}`}
accessibilityLabel={`Sort by ${selectedSortLabel}`}
>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel} numberOfLines={1}>
{SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? 'Recent'}
{selectedSortLabel}
</Text>
</Pressable>
<Pressable
style={[styles.groupButton, styles.embeddedModeButton]}
style={[styles.modeButton, styles.embeddedModeButton]}
onPress={() => setShowGroupPicker(true)}
accessibilityRole="button"
accessibilityLabel="Group workspaces"
@@ -1040,16 +1055,16 @@ export function HostScreen({
</Text>
</Pressable>
<Pressable style={styles.sortButton} onPress={() => setShowSortPicker(true)}>
<Pressable style={styles.modeButton} onPress={() => setShowSortPicker(true)}>
<SlidersHorizontal size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel}>
{SORT_OPTIONS.find((o) => o.value === sortMode)?.label ?? 'Recent'}
<Text style={styles.sortLabel} numberOfLines={1}>
{selectedSortLabel}
</Text>
</Pressable>
<Pressable style={styles.groupButton} onPress={() => setShowGroupPicker(true)}>
<Pressable style={styles.modeButton} onPress={() => setShowGroupPicker(true)}>
<Layers size={14} color={colors.textSecondary} />
<Text style={styles.sortLabel}>
<Text style={styles.sortLabel} numberOfLines={1}>
{groupMode === 'none'
? 'Group'
: groupMode === 'workspaceStatus'
@@ -1160,12 +1175,11 @@ export function HostScreen({
</View>
)}
{/* Worktree list */}
{sections.length > 0 && (
<SectionList
ref={sectionListRef}
sections={sections}
keyExtractor={(w) => w.worktreeId}
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
stickySectionHeadersEnabled={false}
onScrollToIndexFailed={onScrollToIndexFailed}
// Why: edge-to-edge — the list scrolls under the system nav bar
@@ -1181,17 +1195,14 @@ export function HostScreen({
if (!section.title) {
return null
}
const isCollapsed = collapsedGroups.has(section.title)
const rawSection = rawSections.find((s) => s.title === section.title)
const isCollapsed = collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon = groupMode === 'repo' ? repoIconsByName.get(section.title) : null
return (
<Pressable
style={styles.sectionHeader}
onPress={() => toggleCollapsed(section.title)}
>
<Pressable style={styles.sectionHeader} onPress={() => toggleCollapsed(section.key)}>
{isCollapsed ? (
<ChevronRight size={12} color={colors.textMuted} style={styles.sectionIcon} />
) : (
@@ -1226,12 +1237,14 @@ export function HostScreen({
hideRepo={groupMode === 'repo'}
onPress={openWorktreeSession}
onLongPress={item.workspaceKind === 'folder-workspace' ? undefined : setActionTarget}
onToggleLineage={(row) =>
toggleCollapsed(getMobileWorkspaceLineageGroupKey(row.worktreeId))
}
/>
)}
/>
)}
{/* Sort picker modal */}
<PickerModal
visible={showSortPicker}
title="Sort By"
@@ -1241,7 +1254,6 @@ export function HostScreen({
onClose={() => setShowSortPicker(false)}
/>
{/* Group picker modal */}
<PickerModal
visible={showGroupPicker}
title="Group By"
@@ -1251,7 +1263,6 @@ export function HostScreen({
onClose={() => setShowGroupPicker(false)}
/>
{/* Filter modal — matches desktop's Status + Repositories dropdown */}
<BottomDrawer visible={showFilterModal} onClose={() => setShowFilterModal(false)}>
<View style={styles.filterModalHeader}>
<Text style={styles.filterModalTitle}>Filter</Text>
@@ -1564,21 +1575,18 @@ const styles = StyleSheet.create({
filterChipTextActive: {
color: colors.textPrimary
},
sortButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
groupButton: {
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
+71 -15
View File
@@ -163,6 +163,11 @@ import {
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
@@ -908,6 +913,16 @@ export default function SessionScreen() {
const terminalsRef = useRef<Terminal[]>([])
const [sessionTabs, setSessionTabs] = useState<MobileSessionTab[]>([])
const sessionTabsRef = useRef<MobileSessionTab[]>([])
// Why: subscription, 2s polling, and post-mutation refetch race to apply tab
// snapshots. Track the last applied (publicationEpoch, snapshotVersion) so a
// late-arriving older snapshot from the same publisher can't overwrite (and
// resurrect closed tabs in) a newer one. See session-tab-snapshot-gate.
const appliedSnapshotMarkerRef = useRef<AppliedSnapshotMarker>({ epoch: null, version: -1 })
// Why: after an optimistic local close, suppress the tab until the publisher
// confirms its absence, so an in-flight snapshot generated before the close
// propagated (and thus newer by version) can't flash the tab back. Maps tab id
// to an expiry timestamp so a failed host-side close can't hide a tab forever.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [input, setInput] = useState('')
// Why: baseline terminal zoom, reloaded on focus so a Settings → Terminal change
@@ -941,6 +956,10 @@ export default function SessionScreen() {
const [pendingDiffNotesDelivery, setPendingDiffNotesDelivery] =
useState<DiffNotesDelivery | null>(null)
const [creating, setCreating] = useState(false)
// Why: React state isn't a synchronous lock — a fast double-tap can fire two
// creates before `creating` re-renders. This ref blocks the second one in the
// same tick (server idempotency only dedupes identical clientMutationIds).
const creatingTerminalRef = useRef(false)
const [creatingBrowser, setCreatingBrowser] = useState(false)
const [creatingMarkdown, setCreatingMarkdown] = useState(false)
const [createError, setCreateError] = useState('')
@@ -1648,7 +1667,16 @@ export default function SessionScreen() {
const applySessionTabs = useCallback(
(result: SessionTabsResult) => {
let nextTabs = result.tabs
// Reject out-of-order snapshots, then suppress just-closed tabs until the
// publisher confirms their absence. See session-tab-snapshot-gate.
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
return
}
let nextTabs = applyClosedTabTombstones(
result.tabs,
closedTabTombstonesRef.current,
Date.now()
)
const presentTabIds = new Set(nextTabs.map((tab) => tab.id))
const orphanedDraftTabs: MobileSessionTab[] = []
const currentMarkdownDocs = markdownDocsRef.current
@@ -2541,6 +2569,12 @@ export default function SessionScreen() {
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
// Why: snapshot version floor and close tombstones are per-worktree. This
// screen can be reused across worktrees, so a prior worktree's high version
// would reject the next one's first snapshot (same renderer epoch) and stale
// tombstones could suppress same-id tabs.
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2590,11 +2624,12 @@ export default function SessionScreen() {
}
void (async () => {
if (client && created !== '1') {
// Why: desktop reveal can be slow on cold/busy hosts, but mobile
// session tabs are addressed by worktree id and can load immediately.
// Why: mobile needs host-owned tabs hydrated for this route, but should
// not pull other paired clients, especially desktop, into this worktree.
void client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
}
@@ -2619,7 +2654,8 @@ export default function SessionScreen() {
void (async () => {
await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
if (disposed) {
@@ -2768,7 +2804,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: matchingTab.id
tabId: matchingTab.id,
notifyClients: false
})
.catch(() => {})
}
@@ -2800,7 +2837,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
}
@@ -2823,7 +2861,8 @@ export default function SessionScreen() {
void client
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: tab.id
tabId: tab.id,
notifyClients: false
})
.catch(() => {})
}
@@ -3730,17 +3769,27 @@ export default function SessionScreen() {
agent?: MobileNewTabAgentOption['agent'],
options?: { initialPrompt?: string; onPromptSent?: () => void }
) {
if (!client || creating) {
if (!client || creatingTerminalRef.current) {
return
}
creatingTerminalRef.current = true
setCreating(true)
setCreateError('')
// Why: idempotency key so a transport-level retry (reconnect replay) of this
// create resolves to the same terminal instead of spawning a duplicate. Kept
// compact (no worktree id) to stay under the schema's length cap; the ref
// guard above blocks concurrent taps synchronously.
const clientMutationId = `mobile-create:${Date.now().toString(36)}-${Math.random()
.toString(36)
.slice(2, 10)}`
try {
const response = await client.sendRequest('session.tabs.createTerminal', {
worktree: `id:${worktreeId}`,
afterTabId: activeSessionTabId ?? undefined,
clientMutationId,
...(agent ? { agent } : {})
})
if (response.ok) {
@@ -3829,6 +3878,7 @@ export default function SessionScreen() {
} catch {
setCreateError('Failed to create terminal')
} finally {
creatingTerminalRef.current = false
setCreating(false)
}
}
@@ -4019,7 +4069,6 @@ export default function SessionScreen() {
subscribeToTerminal(replacement.handle)
}
}
scheduleDelayedAction(() => void fetchTerminals(), 300)
}
} catch {
// Close failed — keep the local tab list unchanged.
@@ -4042,13 +4091,16 @@ export default function SessionScreen() {
initializedHandlesRef.current.delete(tab.terminal)
}
setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
// Why: tombstone the closed tab and rely on the subscription/poll
// snapshot (gated by snapshotVersion) instead of a blind 300ms refetch
// that re-applied whatever the host had — often the not-yet-closed list.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
activeSessionTabTypeRef.current = null
setActiveSessionTabId(null)
activeHandleRef.current = null
setActiveHandle(null)
}
scheduleDelayedAction(() => void fetchSessionTabs(), 300)
}
} catch {
// Close failed — keep the authoritative session snapshot visible.
@@ -4090,7 +4142,8 @@ export default function SessionScreen() {
.sendRequest('session.tabs.activate', {
worktree: `id:${worktreeId}`,
tabId: activePendingTerminalTab.id,
leafId: activePendingTerminalTab.leafId
leafId: activePendingTerminalTab.leafId,
notifyClients: false
})
.then((response) => {
if (!response.ok) {
@@ -4868,12 +4921,14 @@ export default function SessionScreen() {
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType wins over autoComplete and can
// narrow the keyboard surface; keep IME switching available.
autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(Platform.OS)}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
textContentType="none"
/>
</Pressable>
) : (
@@ -4899,8 +4954,9 @@ export default function SessionScreen() {
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: Android's default keyboard is required for CJK IME
// composition; iOS can still use ASCII when autocomplete is off.
// Why: terminal commands are not autofill content, but the
// keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(
Platform.OS,
autocompleteEnabled
+3 -5
View File
@@ -47,6 +47,7 @@ import { BottomDrawer } from '../../../src/components/BottomDrawer'
import { ConfirmModal } from '../../../src/components/ConfirmModal'
import { MobileMarkdown } from '../../../src/components/MobileMarkdown'
import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput'
import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments'
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo'
@@ -4870,7 +4871,6 @@ export default function MobileTasksScreen() {
}
]
}, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds])
const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => {
const suggestedName = taskWorkspaceSuggestedName(item)
setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) })
@@ -10977,14 +10977,12 @@ export default function MobileTasksScreen() {
<Text style={styles.workspaceCreateLabel}>
Workspace Name <Text style={styles.workspaceCreateLabelHint}>[Optional]</Text>
</Text>
<TextInput
<MobileWorkspaceNameInput
style={styles.input}
value={workspaceNameDraft}
onChangeText={handleWorkspaceNameDraftChange}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
shouldAutoFocus={taskUiReady && workspaceCreateDraft !== null}
/>
</View>
+2
View File
@@ -68,6 +68,8 @@ export function createMockWorktrees(
parentWorktreeId: null,
childWorktreeIds: [],
displayName: name,
workspaceStatus: index % 9 === 0 ? 'in-review' : 'in-progress',
sortOrder: now - index * 1000,
linkedIssue: index % 7 === 0 ? 200 + index : null,
linkedPR,
linkedLinearIssue: index % 13 === 0 ? `ORC-${index + 1}` : null,
@@ -0,0 +1,82 @@
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
type Respond = (response: RpcResponse) => void
type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse
type ErrorResponse = (id: string, code: string, message: string) => RpcResponse
const MOCK_IMAGE_PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l8sm7wAAAABJRU5ErkJggg=='
const MOCK_FILE_CONTENT: Record<string, string> = {
'README.md': '# Mobile preview\n\nThis markdown file is served by the mock desktop.',
'src/app.ts': "export const mobilePreview = 'ready'\n",
'public/index.html': '<main><h1>Mobile preview</h1><p>HTML preview is ready.</p></main>'
}
const MOCK_FILE_LIST = [
{ relativePath: 'README.md', basename: 'README.md', kind: 'text' },
{ relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
{ relativePath: 'public/index.html', basename: 'index.html', kind: 'text' },
{ relativePath: 'assets/logo.png', basename: 'logo.png', kind: 'binary' },
{ relativePath: 'dist/archive.zip', basename: 'archive.zip', kind: 'binary' }
]
export function handleMockFilePreviewRequest(
request: RpcRequest,
respond: Respond,
success: Success,
error: ErrorResponse
): boolean {
switch (request.method) {
case 'files.list':
respond(
success(request.id, {
worktree: request.params?.worktree ?? 'id:mock',
rootPath: '/tmp/orca-mobile-repro/orca',
files: MOCK_FILE_LIST,
totalCount: MOCK_FILE_LIST.length,
truncated: false
})
)
return true
case 'files.read': {
const relativePath = String(request.params?.relativePath ?? '')
const content = MOCK_FILE_CONTENT[relativePath]
if (content == null) {
respond(error(request.id, 'not_found', 'File not found'))
return true
}
respond(
success(request.id, {
worktree: request.params?.worktree ?? 'id:mock',
relativePath,
content,
truncated: false,
byteLength: Buffer.byteLength(content, 'utf8')
})
)
return true
}
case 'files.readPreview': {
const relativePath = String(request.params?.relativePath ?? '')
if (relativePath !== 'assets/logo.png') {
respond(error(request.id, 'binary_file', 'binary_file'))
return true
}
respond(
success(request.id, {
content: MOCK_IMAGE_PNG_BASE64,
isBinary: true,
isImage: true,
mimeType: 'image/png'
})
)
return true
}
default:
return false
}
}
+5 -22
View File
@@ -3,7 +3,9 @@ import {
DESKTOP_PROTOCOL_VERSION,
MIN_COMPATIBLE_MOBILE_VERSION
} from '../../src/shared/protocol-version'
import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { FAKE_SCROLLBACK, STREAMING_CHUNKS } from './mock-server-terminal-fixtures'
import { createMockRepos, createMockWorktrees, readScenarioNumber } from './mobile-lag-scenario'
const MOCK_REPO_COUNT = readScenarioNumber('MOCK_REPO_COUNT', 2)
@@ -30,28 +32,6 @@ const FAKE_TERMINALS = [
}
]
const FAKE_SCROLLBACK = [
'$ claude "refactor the auth module to use JWT tokens"',
'',
'⏳ Working on it...',
'',
"I'll refactor the auth module. Here's my plan:",
'1. Replace session-based auth with JWT',
'2. Add token refresh endpoint',
'3. Update middleware',
'',
'Let me start by reading the current auth module...',
''
].join('\n')
const STREAMING_CHUNKS = [
'Reading src/auth/middleware.ts...\n',
'Reading src/auth/session.ts...\n',
'\nI see the current implementation uses express-session.\n',
"I'll replace it with jsonwebtoken.\n",
'\nUpdating src/auth/middleware.ts...\n'
]
export type RpcRequest = {
id: string
method: string
@@ -120,6 +100,9 @@ export function handleRequest(
if (handleMockGitRequest(request, respond, success)) {
return
}
if (handleMockFilePreviewRequest(request, respond, success, error)) {
return
}
switch (request.method) {
case 'status.get':
@@ -0,0 +1,21 @@
export const FAKE_SCROLLBACK = [
'$ claude "refactor the auth module to use JWT tokens"',
'',
'⏳ Working on it...',
'',
"I'll refactor the auth module. Here's my plan:",
'1. Replace session-based auth with JWT',
'2. Add token refresh endpoint',
'3. Update middleware',
'',
'Let me start by reading the current auth module...',
''
].join('\n')
export const STREAMING_CHUNKS = [
'Reading src/auth/middleware.ts...\n',
'Reading src/auth/session.ts...\n',
'\nI see the current implementation uses express-session.\n',
"I'll replace it with jsonwebtoken.\n",
'\nUpdating src/auth/middleware.ts...\n'
]
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
import fs from 'node:fs'
import path from 'node:path'
import process from 'node:process'
const mobileRoot = path.resolve(import.meta.dirname, '..')
const appConfigPath = process.env.MOBILE_APP_CONFIG_PATH || path.join(mobileRoot, 'app.json')
const androidTagRefPrefix = 'refs/tags/mobile-android-v'
const semverPattern = /^\d+\.\d+\.\d+$/
function input(name) {
return (process.env[name] || '').trim()
}
function truthy(value) {
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase())
}
function fail(message) {
console.error(message)
process.exit(1)
}
function parsePositiveInteger(value, name) {
if (!/^\d+$/.test(value)) {
fail(`${name} must be a positive integer`)
}
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
fail(`${name} must be a positive integer`)
}
return parsed
}
function validateSemver(version, name) {
if (!semverPattern.test(version)) {
fail(`${name} must use x.y.z format`)
}
}
function bumpPatchVersion(version) {
validateSemver(version, 'Current mobile version')
const [major, minor, patch] = version.split('.')
return `${major}.${minor}.${Number(patch) + 1}`
}
function writeOutput(name, value) {
const outputPath = process.env.GITHUB_OUTPUT
if (!outputPath) {
return
}
fs.appendFileSync(outputPath, `${name}=${value}\n`)
}
const config = JSON.parse(fs.readFileSync(appConfigPath, 'utf8'))
const expo = config.expo || fail('app.json is missing expo config')
const android = expo.android || fail('app.json is missing expo.android config')
const currentVersion = String(expo.version || '').trim()
validateSemver(currentVersion, 'Current mobile version')
const currentVersionCode = Number(android.versionCode)
if (!Number.isSafeInteger(currentVersionCode) || currentVersionCode <= 0) {
fail('Current Android versionCode must be a positive integer')
}
const githubRef = input('GITHUB_REF')
const tagVersion = githubRef.startsWith(androidTagRefPrefix)
? githubRef.slice(androidTagRefPrefix.length)
: ''
const requestedVersion = input('MOBILE_ANDROID_RELEASE_VERSION')
const bumpPatch = truthy(input('MOBILE_ANDROID_BUMP_PATCH_VERSION'))
if (requestedVersion && bumpPatch) {
fail('Use either MOBILE_ANDROID_RELEASE_VERSION or MOBILE_ANDROID_BUMP_PATCH_VERSION, not both')
}
if (tagVersion) {
validateSemver(tagVersion, 'Android release tag version')
}
if (requestedVersion) {
validateSemver(requestedVersion, 'MOBILE_ANDROID_RELEASE_VERSION')
}
if (tagVersion && requestedVersion && requestedVersion !== tagVersion) {
fail('MOBILE_ANDROID_RELEASE_VERSION must match the mobile-android-v tag')
}
if (tagVersion && bumpPatch) {
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is only supported for manual branch runs')
}
const version =
requestedVersion || tagVersion || (bumpPatch ? bumpPatchVersion(currentVersion) : currentVersion)
const requestedVersionCode = input('MOBILE_ANDROID_VERSION_CODE')
const bumpVersionCode = truthy(input('MOBILE_ANDROID_BUMP_VERSION_CODE'))
if (requestedVersionCode && bumpVersionCode) {
fail('Use either MOBILE_ANDROID_VERSION_CODE or MOBILE_ANDROID_BUMP_VERSION_CODE, not both')
}
const versionCode = requestedVersionCode
? parsePositiveInteger(requestedVersionCode, 'MOBILE_ANDROID_VERSION_CODE')
: bumpVersionCode || version !== currentVersion
? currentVersionCode + 1
: currentVersionCode
expo.version = version
android.versionCode = versionCode
fs.writeFileSync(appConfigPath, `${JSON.stringify(config, null, 2)}\n`)
const tag = `mobile-android-v${version}`
const publishRelease =
githubRef.startsWith(androidTagRefPrefix) || truthy(input('MOBILE_ANDROID_PUBLISH_RELEASE'))
writeOutput('version', version)
writeOutput('android_version_code', String(versionCode))
writeOutput('tag', tag)
writeOutput('publish_release', publishRelease ? 'true' : 'false')
console.log(`Prepared Orca Mobile Android ${version} (${versionCode})`)
console.log(`Release tag: ${tag}`)
console.log(`Publish GitHub Release: ${publishRelease ? 'yes' : 'no'}`)
+155 -25
View File
@@ -9,6 +9,7 @@
* Options:
* --worktree <path> Worktree path (default: auto-detect)
* --device <name> Device name (default: 'iPhone 17 Pro')
* --port <port> Metro port (default: Expo default)
* --no-open Don't open the app URL automatically
* --wait-for-ready Wait for Metro to be ready before opening URL
* --screenshot Take a screenshot after opening
@@ -28,6 +29,7 @@ const args = process.argv.slice(2)
const options = {
worktree: null,
device: 'iPhone 17 Pro',
port: null,
open: true,
waitForReady: false,
screenshot: false
@@ -39,6 +41,8 @@ for (let i = 0; i < args.length; i++) {
options.worktree = args[++i]
} else if (arg === '--device' && i + 1 < args.length) {
options.device = args[++i]
} else if (arg === '--port' && i + 1 < args.length) {
options.port = args[++i]
} else if (arg === '--no-open') {
options.open = false
} else if (arg === '--wait-for-ready') {
@@ -51,6 +55,7 @@ for (let i = 0; i < args.length; i++) {
Options:
--worktree <path> Worktree path (default: auto-detect)
--device <name> Device name (default: 'iPhone 17 Pro')
--port <port> Metro port (default: Expo default)
--no-open Don't open the app URL automatically
--wait-for-ready Wait for Metro to be ready before opening URL
--screenshot Take a screenshot after opening
@@ -60,6 +65,8 @@ Options:
}
}
const ORCA_CLI = process.env.ORCA_CLI || 'orca'
// Colors for output
const colors = {
reset: '\x1b[0m',
@@ -101,7 +108,7 @@ function assertIosSimulatorPlatform() {
// Execute orca CLI command
async function orca(args, options = {}) {
const { stdout, stderr } = await execFileAsync('orca', args, {
const { stdout, stderr } = await execFileAsync(ORCA_CLI, args, {
cwd: options.cwd || process.cwd(),
encoding: 'utf8',
timeout: options.timeout || 30000
@@ -144,7 +151,7 @@ async function attachEmulator(worktree, device) {
logStep('1', `Attaching to emulator: ${device.name}`)
try {
await orca(['emulator', 'attach', device.udid, '--worktree', 'active', '--focus', '--json'], {
await orca(['emulator', 'attach', device.udid, '--worktree', worktree, '--focus', '--json'], {
cwd: worktree
})
logSuccess(`Attached to ${device.name}`)
@@ -219,6 +226,67 @@ async function findBestDevice(requestedDevice) {
return device
}
function lanIpCandidates() {
const entries = Object.entries(os.networkInterfaces()).flatMap(([name, interfaces]) =>
(interfaces || []).map((iface) => ({ name, iface }))
)
return entries
.filter(({ name, iface }) => {
if (!iface || iface.family !== 'IPv4' || iface.internal) {
return false
}
if (iface.address.startsWith('169.254.')) {
return false
}
return !/^(awdl|bridge|gif|llw|p2p|stf|utun)/.test(name)
})
.sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name))
.map(({ iface }) => iface.address)
}
function interfaceRank(name) {
if (/^(en|eth|wlan)/.test(name)) {
return 0
}
return 1
}
function isLoopbackHost(hostname) {
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '0.0.0.0'
}
function normalizeMetroUrl(rawUrl) {
try {
const url = new URL(rawUrl)
const lanIp = lanIpCandidates()[0]
if (lanIp && isLoopbackHost(url.hostname)) {
url.hostname = lanIp
}
return url.toString().replace(/\/$/, '')
} catch {
return rawUrl
}
}
function metroUrlCandidates(initialUrl) {
try {
const url = new URL(initialUrl)
const hosts = [url.hostname, ...lanIpCandidates(), 'localhost', '127.0.0.1']
const uniqueHosts = [...new Set(hosts.filter(Boolean))]
return uniqueHosts.map((host) => {
const candidate = new URL(url.toString())
candidate.hostname = host
return candidate.toString().replace(/\/$/, '')
})
} catch {
return [initialUrl]
}
}
function devClientUrlForMetroUrl(url) {
return `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(url)}`
}
// Start Metro bundler
async function startMetro(worktree) {
logStep('2', 'Starting Metro bundler...')
@@ -233,8 +301,12 @@ async function startMetro(worktree) {
// Use local expo CLI directly instead of pnpm start to avoid workspace issues
const expoPath = path.join(mobileDir, 'node_modules', '.bin', 'expo')
const expoArgs = ['start', '--host', 'lan']
if (options.port) {
expoArgs.push('--port', options.port)
}
logInfo(`Using expo at: ${expoPath}`)
const metro = spawn(expoPath, ['start', '--host', 'lan'], {
const metro = spawn(expoPath, expoArgs, {
cwd: mobileDir,
env,
stdio: ['pipe', 'pipe', 'pipe']
@@ -243,9 +315,26 @@ async function startMetro(worktree) {
let output = ''
let url = null
let resolved = false
let exited = false
let rl = null
let rlErr = null
const metroResult = () => ({
process: metro,
url,
output,
isExited: () => exited,
closeOutput: () => {
rl?.close()
rlErr?.close()
metro.stdin?.destroy()
metro.stdout?.destroy()
metro.stderr?.destroy()
}
})
// Parse Metro output for the development URL
const rl = readline.createInterface({ input: metro.stdout })
rl = readline.createInterface({ input: metro.stdout })
rl.on('line', (line) => {
output += line + '\n'
process.stdout.write(colors.dim + line + colors.reset + '\n')
@@ -256,43 +345,42 @@ async function startMetro(worktree) {
if (waitingMatch && !resolved) {
const host = waitingMatch[1]
const port = waitingMatch[2]
// Construct the dev-client URL with LAN IP
const lanIp =
Object.values(os.networkInterfaces())
.flat()
.find((iface) => iface?.family === 'IPv4' && !iface.internal)?.address || host
url = `http://${lanIp}:${port}`
url = normalizeMetroUrl(`${host}:${port}`)
logInfo(`Found Metro URL: ${url}`)
if (!options.waitForReady) {
resolved = true
resolve({ process: metro, url, output })
resolve(metroResult())
}
}
// Also check for the dev-client URL format directly
const urlMatch = line.match(/exp\+orca-mobile:\/\/expo-development-client\/\?url=(.+)/)
const urlMatch = line.match(/exp\+orca-mobile:\/\/expo-development-client\/\?url=([^\s]+)/)
if (urlMatch && !resolved) {
url = decodeURIComponent(urlMatch[1])
url = normalizeMetroUrl(decodeURIComponent(urlMatch[1]))
logInfo(`Found Metro URL: ${url}`)
if (!options.waitForReady) {
resolved = true
resolve({ process: metro, url, output })
resolve(metroResult())
}
}
// Also check for "packager-status:running" or ready indicator
if (line.includes('packager-status:running') || line.includes('Metro waiting')) {
if (
line.includes('packager-status:running') ||
line.includes('Metro waiting') ||
line.includes('Logs for your project will appear below')
) {
if (url && !resolved) {
resolved = true
resolve({ process: metro, url, output })
resolve(metroResult())
}
}
})
// Also check stderr
const rlErr = readline.createInterface({ input: metro.stderr })
rlErr = readline.createInterface({ input: metro.stderr })
rlErr.on('line', (line) => {
output += line + '\n'
process.stderr.write(colors.red + line + colors.reset + '\n')
@@ -306,12 +394,13 @@ async function startMetro(worktree) {
})
metro.on('exit', (code) => {
exited = true
if (!resolved) {
resolved = true
if (code !== 0) {
reject(new Error(`Metro exited with code ${code}`))
} else {
resolve({ process: metro, url, output })
resolve(metroResult())
}
}
})
@@ -331,7 +420,7 @@ async function startMetro(worktree) {
async function openInSimulator(url, deviceUdid) {
logStep('3', 'Opening app in simulator...')
const fullUrl = `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(url)}`
const fullUrl = devClientUrlForMetroUrl(url)
try {
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, fullUrl])
@@ -376,6 +465,15 @@ async function verifyMetro(url) {
}
}
async function findReachableMetroUrl(initialUrl) {
for (const candidate of metroUrlCandidates(initialUrl)) {
if (await verifyMetro(candidate)) {
return { url: candidate, reachable: true }
}
}
return { url: initialUrl, reachable: false }
}
// Main function
async function main() {
log(colors.bright + 'Starting Orca Mobile in Emulator\n' + colors.reset)
@@ -400,9 +498,14 @@ async function main() {
logSuccess('Metro is running')
// Verify Metro is reachable
const isReachable = await verifyMetro(metro.url)
if (!isReachable) {
logError('Metro is not reachable. The URL may be using 127.0.0.1 instead of LAN IP.')
const reachableMetro = await findReachableMetroUrl(metro.url)
if (reachableMetro.url !== metro.url) {
logInfo(`Using reachable Metro URL: ${reachableMetro.url}`)
}
metro.url = reachableMetro.url
if (!reachableMetro.reachable) {
logError('Metro is not reachable from this machine.')
logInfo('The app may still work if the simulator can access the LAN IP.')
} else {
logSuccess('Metro is reachable')
@@ -420,6 +523,7 @@ async function main() {
}
} else {
logInfo(`Metro URL: ${metro.url}`)
logInfo(`Dev-client URL: ${devClientUrlForMetroUrl(metro.url)}`)
logInfo('Omit --no-open to automatically open in simulator')
}
@@ -428,11 +532,37 @@ async function main() {
// Keep running until Metro exits
await new Promise((resolve) => {
metro.process.on('exit', resolve)
process.on('SIGINT', () => {
let stopping = false
let stopTimeout = null
const finish = () => {
if (stopTimeout) {
clearTimeout(stopTimeout)
}
metro.process.off('exit', finish)
process.off('SIGINT', stopMetro)
process.off('SIGTERM', stopMetro)
metro.closeOutput?.()
resolve()
}
const stopMetro = () => {
if (stopping) {
finish()
return
}
stopping = true
metro.process.kill('SIGINT')
})
stopTimeout = setTimeout(finish, 2000)
stopTimeout.unref?.()
}
metro.process.once('exit', finish)
if (metro.isExited()) {
finish()
return
}
process.once('SIGINT', stopMetro)
process.once('SIGTERM', stopMetro)
})
process.exit(0)
} catch (error) {
logError(error.message)
process.exit(1)
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
describe('parseMobileMarkdown', () => {
@@ -21,4 +22,31 @@ describe('parseMobileMarkdown', () => {
}
])
})
it('normalizes common README HTML into readable Markdown preview text', () => {
const normalized = normalizeMobileMarkdownPreviewHtml(`
<h1 align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" /></a>
Orca
</h1>
<p align="center">
<a href="https://github.com/stablyai/orca/stargazers"><img src="https://badgen.net/github/stars/stablyai/orca" alt="GitHub stars" /></a>
<strong>The AI Orchestrator</strong><br/>
Run Codex side-by-side.
</p>
`)
expect(normalized).toContain('# [Orca](https://onOrca.dev)')
expect(normalized).toContain('[GitHub stars](https://github.com/stablyai/orca/stargazers)')
expect(normalized).toContain('**The AI Orchestrator**')
expect(normalized).not.toContain('<h1')
expect(normalized).not.toContain('<img')
})
it('preserves documented HTML entities while normalizing preview HTML', () => {
expect(
normalizeMobileMarkdownPreviewHtml('<p>Use <code>&amp;lt;button&amp;gt;</code></p>')
).toBe('Use `&lt;button&gt;`')
})
})
+3 -1
View File
@@ -1,6 +1,7 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
type Props = {
@@ -87,7 +88,8 @@ function renderInline(text: string): ReactNode[] {
function MobileMarkdownInner({ content, fallback = '' }: Props) {
const text = content?.trim() ?? ''
const blocks = useMemo(() => parseMobileMarkdown(text), [text])
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
@@ -0,0 +1,42 @@
import { useEffect, useRef } from 'react'
import { TextInput, type TextInputProps } from 'react-native'
const MOBILE_WORKSPACE_NAME_FOCUS_DELAY_MS = 220
type MobileWorkspaceNameInputProps = TextInputProps & {
shouldAutoFocus: boolean
focusKey?: unknown
}
export function MobileWorkspaceNameInput({
shouldAutoFocus,
focusKey,
...props
}: MobileWorkspaceNameInputProps) {
const inputRef = useRef<TextInput>(null)
useEffect(() => {
if (!shouldAutoFocus) {
return
}
// Why: bottom drawers animate in before the field is visually settled;
// focusing after the animation makes mobile soft keyboards appear reliably.
const timeout = setTimeout(() => {
inputRef.current?.focus()
}, MOBILE_WORKSPACE_NAME_FOCUS_DELAY_MS)
return () => clearTimeout(timeout)
}, [focusKey, shouldAutoFocus])
return (
<TextInput
ref={inputRef}
placeholder="Workspace name"
autoCapitalize="none"
autoCorrect={false}
showSoftInputOnFocus
{...props}
/>
)
}
+3 -5
View File
@@ -16,6 +16,7 @@ import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
@@ -751,18 +752,15 @@ function NewWorktreeModalContent({
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<TextInput
<MobileWorkspaceNameInput
style={styles.input}
value={name}
onChangeText={(t) => {
setName(t)
setError('')
}}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus={repos.length <= 1}
shouldAutoFocus={visible && !loading && repos.length > 0}
returnKeyType="done"
onSubmitEditing={() => {
if (canCreate) {
+67 -4
View File
@@ -1,9 +1,9 @@
import { Bell, GitPullRequest } from 'lucide-react-native'
import { Bell, ChevronDown, ChevronRight, GitBranch, GitPullRequest } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { RepoIcon } from '../../../src/shared/repo-icon'
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import { triggerMediumImpact } from '../platform/haptics'
import { colors, spacing, typography } from '../theme/mobile-theme'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { AgentSpinner } from './AgentSpinner'
import { MobileRepoIcon } from './MobileRepoIcon'
import { WorktreeAgentList } from './WorktreeAgentList'
@@ -34,6 +34,9 @@ export type WorktreeListRowItem = {
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
lineageDepth?: number
lineageChildCount?: number
lineageCollapsed?: boolean
agents?: RuntimeWorktreeAgentRow[]
}
@@ -51,6 +54,7 @@ type Props<T extends WorktreeListRowItem> = {
status: WorktreeRollupStatus
onPress: (item: T) => void
onLongPress?: (item: T) => void
onToggleLineage?: (item: T) => void
}
export function WorktreeListRow<T extends WorktreeListRowItem>({
@@ -62,16 +66,20 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
hideRepo = false,
status,
onPress,
onLongPress
onLongPress,
onToggleLineage
}: Props<T>) {
const isFolderWorkspace = item.workspaceKind === 'folder-workspace'
const folderMeta = item.comment?.trim() || item.path || 'Folder'
const metaText = isFolderWorkspace ? folderMeta : displayBranch(item.branch)
const lineageDepth = Math.max(0, item.lineageDepth ?? 0)
const lineageChildCount = item.lineageChildCount ?? 0
return (
<Pressable
style={({ pressed }) => [
styles.worktreeRow,
lineageDepth > 0 && { paddingLeft: spacing.lg + lineageDepth * 18 },
item.isActive && styles.worktreeRowActive,
pressed && styles.worktreeRowPressed
]}
@@ -133,6 +141,12 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
/>
</View>
<View style={styles.worktreeMetaRow}>
{lineageDepth > 0 && (
<View style={styles.childBadge}>
<GitBranch size={10} color={colors.textMuted} />
<Text style={styles.childBadgeText}>Child</Text>
</View>
)}
{/* Repo glyph+name only when not already grouped under this repo;
MobileRepoIcon falls back to a Folder (matching desktop's default)
rather than a bare colored dot. */}
@@ -153,6 +167,25 @@ export function WorktreeListRow<T extends WorktreeListRowItem>({
{item.agents && item.agents.length > 0 ? (
<WorktreeAgentList agents={item.agents} now={now} unvisited={item.unread} />
) : null}
{lineageChildCount > 0 && onToggleLineage ? (
<Pressable
style={styles.lineageToggle}
onPress={(event) => {
event.stopPropagation()
onToggleLineage(item)
}}
>
{item.lineageCollapsed ? (
<ChevronRight size={12} color={colors.textSecondary} />
) : (
<ChevronDown size={12} color={colors.textSecondary} />
)}
<GitBranch size={12} color={colors.textSecondary} />
<Text style={styles.lineageToggleText}>
{lineageChildCount} {lineageChildCount === 1 ? 'child' : 'children'}
</Text>
</Pressable>
) : null}
</View>
{item.liveTerminalCount > 0 && (
@@ -167,7 +200,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'flex-start',
paddingVertical: spacing.sm + 2,
paddingHorizontal: spacing.lg,
paddingLeft: spacing.lg,
paddingRight: spacing.lg,
// Reserve the active accent bar width so active/inactive rows align.
borderLeftWidth: 2,
borderLeftColor: 'transparent'
@@ -254,6 +288,35 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
flexShrink: 1
},
childBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: colors.bgRaised,
paddingHorizontal: 5,
paddingVertical: 1,
borderRadius: 4
},
childBadgeText: {
fontSize: 10,
color: colors.textMuted
},
lineageToggle: {
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: radii.button
},
lineageToggleText: {
fontSize: 11,
color: colors.textSecondary,
fontWeight: '600'
},
terminalCount: {
fontSize: typography.metaSize,
color: colors.textMuted,
@@ -0,0 +1,98 @@
// Why: README HTML snippets can document escaped entities; repeated cleanup
// passes must not turn `&amp;lt;` into a real tag and strip it.
const escapedHtmlEntityTokens = [
{ pattern: /&amp;nbsp;/gi, token: '\uE000ORCA_MD_ENTITY_NBSP\uE000', value: '&nbsp;' },
{ pattern: /&amp;lt;/gi, token: '\uE000ORCA_MD_ENTITY_LT\uE000', value: '&lt;' },
{ pattern: /&amp;gt;/gi, token: '\uE000ORCA_MD_ENTITY_GT\uE000', value: '&gt;' },
{ pattern: /&amp;quot;/gi, token: '\uE000ORCA_MD_ENTITY_QUOT\uE000', value: '&quot;' },
{ pattern: /&amp;#39;/gi, token: '\uE000ORCA_MD_ENTITY_APOS\uE000', value: '&#39;' }
] as const
function protectEscapedHtmlEntities(value: string): string {
return escapedHtmlEntityTokens.reduce(
(next, entity) => next.replace(entity.pattern, entity.token),
value
)
}
function restoreEscapedHtmlEntities(value: string): string {
return escapedHtmlEntityTokens.reduce(
(next, entity) => next.replaceAll(entity.token, entity.value),
value
)
}
function decodeHtmlEntities(value: string, preserveEscapedEntities = false): string {
const next = preserveEscapedEntities ? protectEscapedHtmlEntities(value) : value
return next
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.replace(/&amp;/gi, '&')
}
function stripTags(value: string): string {
return decodeHtmlEntities(value.replace(/<[^>]+>/g, ''), true)
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
function attrValue(tag: string, name: string): string {
const pattern = new RegExp(`${name}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, 'i')
const match = tag.match(pattern)
const raw = match?.[1] ?? ''
return decodeHtmlEntities(raw.replace(/^["']|["']$/g, ''))
}
function normalizeInlineHtml(value: string): string {
return value
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<img\b[^>]*>/gi, (tag) => attrValue(tag, 'alt') || 'image')
.replace(
/<a\b[^>]*href\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)[^>]*>([\s\S]*?)<\/a>/gi,
(tag, _href, label) => {
const href = attrValue(tag, 'href')
const text = stripTags(label)
return href && text ? `[${text}](${href})` : text
}
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `**${text}**` : ''
})
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `*${text}*` : ''
})
.replace(/<(code|kbd)\b[^>]*>([\s\S]*?)<\/\1>/gi, (_tag, _name, inner) => {
const text = stripTags(inner)
return text ? `\`${text}\`` : ''
})
}
export function normalizeMobileMarkdownPreviewHtml(content: string): string {
let next = content.replace(/\r\n?/g, '\n')
// Why: repository Markdown often uses small HTML islands for centered README
// headers and badges. Preview mode should read like Markdown, while Source
// mode remains the exact file bytes.
next = next.replace(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi, (_tag, level, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${'#'.repeat(Number(level))} ${text}\n` : '\n'
})
next = next.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_tag, inner) => {
const text = stripTags(normalizeInlineHtml(inner))
return text ? `\n${text}\n` : '\n'
})
next = next.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, (_tag, inner) =>
stripTags(normalizeInlineHtml(inner))
)
next = normalizeInlineHtml(next)
next = stripTags(next)
return restoreEscapedHtmlEntities(next)
}
@@ -8,6 +8,7 @@ import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
import { ConfirmModal } from '../ConfirmModal'
import { PRSection } from './PRSection'
import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability'
import { resolvePrActionAvailability } from './pr-actions-state'
import { prActionsStyles as styles } from './pr-actions-styles'
@@ -62,6 +63,12 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const mergeBusy = actions.isBusy({ kind: 'merge' })
const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' })
const stateBusy = actions.isBusy({ kind: 'state' })
const showAutoMerge =
avail.canAutoMerge &&
canShowMobilePRAutoMergeControl({
...pr,
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
})
const unlink = useCallback(async (): Promise<void> => {
if (!client || unlinking) {
@@ -166,7 +173,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
) : null}
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{avail.canAutoMerge ? (
{showAutoMerge ? (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
<Pressable
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { GitPullRequestArrow, RefreshCw } from 'lucide-react-native'
import { GitPullRequestArrow, Link2, RefreshCw } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { RpcClient } from '../../transport/rpc-client'
import { resolveMobilePrPrefill, type MobilePrPrefill } from '../../source-control/mobile-pr-create'
import { fetchWorktreeLinkedPR } from '../../source-control/mobile-pr-link'
import { openMobilePrUrl } from '../MobilePrComposeSheet'
import { MobilePrComposeForm } from './MobilePrComposeForm'
import { MobileLinkPrForm } from './MobileLinkPrForm'
import { prCreateEmptyStateStyles as styles } from './pr-create-empty-state-styles'
type Props = {
@@ -17,18 +18,18 @@ type Props = {
onCreated: () => void
}
type Mode = 'choose' | 'create'
type Mode = 'choose' | 'create' | 'link'
// Empty state for a branch with no PR. Keep this scoped to desktop's no-PR
// surface: create/refresh here; linked-PR edits belong outside this panel.
// Empty state for a branch with no PR: create a new PR, or link an existing one
// (the no-PR surface is the natural home for linking — desktop's link entry lives
// on its PR card, but on mobile this is where a user lands with nothing linked).
export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCreated }: Props) {
const [prefill, setPrefill] = useState<MobilePrPrefill | null>(null)
const [mode, setMode] = useState<Mode>('choose')
const [loading, setLoading] = useState(false)
const [createWarning, setCreateWarning] = useState<string | null>(null)
// A persisted linkedPR while the branch shows no PR means the linked PR could
// not be resolved. Mention it, but keep link editing out of this desktop-parity
// create surface.
// not be resolved. Mention it while still allowing the user to relink.
const [orphanLinkedPR, setOrphanLinkedPR] = useState<number | null>(null)
useEffect(() => {
@@ -103,6 +104,22 @@ export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCre
)
}
if (mode === 'link') {
return (
<View style={styles.composerArea}>
<MobileLinkPrForm
client={client}
worktreeId={worktreeId}
onCancel={() => setMode('choose')}
onLinked={() => {
setMode('choose')
onCreated()
}}
/>
</View>
)
}
return (
<View style={styles.section}>
<View style={styles.header}>
@@ -148,6 +165,26 @@ export function PrSidebarCreateEmptyState({ client, worktreeId, gitBranch, onCre
: 'The current branch is not linked to an open PR.'}
</Text>
{createWarning ? <Text style={styles.bodyText}>{createWarning}</Text> : null}
<Pressable
style={({ pressed }) => [
styles.linkButton,
!client && styles.linkButtonDisabled,
pressed && styles.linkButtonPressed
]}
onPress={() => setMode('link')}
disabled={!client}
accessibilityRole="button"
accessibilityLabel="Link an existing pull request"
accessibilityState={{ disabled: !client }}
hitSlop={6}
>
<Link2
size={14}
color={client ? colors.textSecondary : colors.textMuted}
strokeWidth={2.2}
/>
<Text style={styles.linkButtonText}>Link an existing PR</Text>
</Pressable>
</View>
</View>
)
@@ -0,0 +1,45 @@
import type { PRInfo } from '../../../../src/shared/types'
type MobilePRAutoMergeAvailabilityInput = Pick<
PRInfo,
| 'state'
| 'mergeable'
| 'mergeStateStatus'
| 'reviewDecision'
| 'autoMergeEnabled'
| 'autoMergeAllowed'
| 'mergeQueueRequired'
>
function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
}
function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean {
if (item.mergeStateStatus === 'BLOCKED' || item.mergeStateStatus === 'BEHIND') {
return false
}
return item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN'
}
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
if (item.state !== 'open' || isConflicting(item)) {
return false
}
if (item.mergeQueueRequired === true) {
return true
}
return (
item.autoMergeAllowed !== false && (hasReviewRequirement(item) || !canMergeImmediately(item))
)
}
export function canShowMobilePRAutoMergeControl(item: MobilePRAutoMergeAvailabilityInput): boolean {
// Why: Metro/Expo cannot reliably transform runtime imports from root
// src/shared, so this mirrors github-pr-auto-merge-availability.ts.
return item.state === 'open' && (item.autoMergeEnabled === true || canRequestWhenReady(item))
}
@@ -83,5 +83,25 @@ export const prCreateEmptyStateStyles = StyleSheet.create({
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
padding: spacing.md
},
// Secondary link-an-existing-PR affordance, set apart from the body copy.
linkButton: {
marginTop: spacing.xs,
minHeight: 32,
alignSelf: 'flex-start',
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
linkButtonDisabled: {
opacity: 0.5
},
linkButtonPressed: {
opacity: 0.6
},
linkButtonText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
+28 -40
View File
@@ -21,7 +21,6 @@ import {
} from 'lucide-react-native'
import { useHostClient, useForceReconnect } from '../transport/client-context'
import { getWorktreeLabel } from '../session/worktree-label'
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import {
buildTree,
flattenTree,
@@ -31,9 +30,13 @@ import {
type TreeNode
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { triggerError, triggerSelection } from '../platform/haptics'
import { triggerSelection } from '../platform/haptics'
import { colors, spacing } from '../theme/mobile-theme'
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import {
canPreviewMobileFileRow,
navigateToMobileFilePreview
} from './mobile-file-preview-navigation'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -50,7 +53,6 @@ export function MobileFileExplorerPanel(props: {
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [openingPath, setOpeningPath] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
@@ -96,36 +98,22 @@ export function MobileFileExplorerPanel(props: {
})
}, [])
const openFile = useCallback(
async (relativePath: string) => {
if (!client) {
return
}
setOpeningPath(relativePath)
try {
const response = await client.sendRequest('files.open', {
worktree: `id:${worktreeId}`,
relativePath
})
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to open file')
}
triggerSelection()
// The file now opens in the session. Full-screen pops back to it; when
// docked the session is already visible, so just close the panel.
if (embedded) {
onRequestClose?.()
} else {
router.back()
}
} catch (err) {
triggerError()
setError(err instanceof Error ? err.message : 'Unable to open file')
} finally {
setOpeningPath(null)
}
const previewFile = useCallback(
(relativePath: string, displayName: string) => {
triggerSelection()
navigateToMobileFilePreview(
router,
{
hostId,
worktreeId,
relativePath,
name: displayName,
worktreeName: name
},
{ embedded, onRequestClose }
)
},
[client, embedded, onRequestClose, router, worktreeId]
[embedded, hostId, name, onRequestClose, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
@@ -133,8 +121,11 @@ export function MobileFileExplorerPanel(props: {
const isExpanded = expanded.has(item.relativePath)
// Images render in the mobile viewer (via files.readPreview), so a binary
// image is openable; only non-previewable binaries are unavailable.
const isImage = item.kind === 'binary' && classifyMobileArtifact(item.relativePath) === 'image'
const disabled = item.kind === 'binary' && !isImage
const previewable =
item.kind !== 'directory' &&
canPreviewMobileFileRow({ kind: item.kind, relativePath: item.relativePath })
const isImage = item.kind === 'binary' && previewable
const disabled = item.kind === 'binary' && !previewable
const markdown = item.kind === 'text' && isMarkdownPath(item.relativePath)
return (
<Pressable
@@ -144,12 +135,12 @@ export function MobileFileExplorerPanel(props: {
pressed && !disabled && styles.rowPressed,
disabled && styles.rowDisabled
]}
disabled={disabled || openingPath !== null}
disabled={disabled}
onPress={() => {
if (isDirectory) {
toggleDirectory(item.relativePath)
} else if (!disabled) {
void openFile(item.relativePath)
previewFile(item.relativePath, item.name)
}
}}
accessibilityLabel={
@@ -157,7 +148,7 @@ export function MobileFileExplorerPanel(props: {
? `Open folder ${item.name}`
: disabled
? `${item.name} unavailable on mobile`
: `Open file ${item.name}`
: `Preview file ${item.name}`
}
>
{isDirectory ? (
@@ -184,9 +175,6 @@ export function MobileFileExplorerPanel(props: {
</Text>
{disabled ? <Text style={styles.rowMeta}>Unavailable on mobile</Text> : null}
</View>
{openingPath === item.relativePath ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : null}
</Pressable>
)
}
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import {
MobileFilePreviewSourceText,
MobileFilePreviewTruncatedNote
} from './MobileFilePreviewSourceText'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
relativePath: string
content: string
truncated: boolean
byteLength: number
}
export function MobileFileMarkdownPreview({ relativePath, content, truncated, byteLength }: Props) {
const [mode, setMode] = useState<'preview' | 'source'>('preview')
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
return (
<View style={styles.modeContainer}>
<View style={styles.modeToolbar}>
<Pressable
style={[styles.modeToggle, sourceSelected && styles.modeToggleActive]}
onPress={() => setMode('source')}
accessibilityRole="button"
accessibilityState={{ selected: sourceSelected }}
accessibilityLabel="View Markdown source"
>
<Code
size={15}
color={sourceSelected ? colors.textPrimary : colors.textSecondary}
strokeWidth={2.2}
/>
</Pressable>
<Pressable
style={[styles.modeToggle, previewSelected && styles.modeToggleActive]}
onPress={() => setMode('preview')}
accessibilityRole="button"
accessibilityState={{ selected: previewSelected }}
accessibilityLabel="View rendered Markdown preview"
>
<Pencil
size={15}
color={previewSelected ? colors.textPrimary : colors.textSecondary}
strokeWidth={2.2}
/>
</Pressable>
</View>
{mode === 'preview' ? (
<ScrollView style={styles.scroll} contentContainerStyle={styles.markdownContent}>
{truncated ? <MobileFilePreviewTruncatedNote byteLength={byteLength} /> : null}
<MobileMarkdown content={content} />
</ScrollView>
) : (
<MobileFilePreviewSourceText
relativePath={relativePath}
content={content}
truncated={truncated}
byteLength={byteLength}
/>
)}
</View>
)
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useState } from 'react'
import {
ActivityIndicator,
Image,
Pressable,
ScrollView,
Text,
View,
useWindowDimensions
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { getWorktreeLabel } from '../session/worktree-label'
import { colors, spacing } from '../theme/mobile-theme'
import { useForceReconnect, useHostClient } from '../transport/client-context'
import {
loadMobileFilePreview,
previewError,
type MobileFilePreviewResult
} from './mobile-file-preview-request'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
import {
displayNameFromPreviewPath,
type MobileFilePreviewRouteState
} from './mobile-file-preview-route'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
type Props = {
route: MobileFilePreviewRouteState
}
export function MobileFilePreviewScreen({ route }: Props) {
const router = useRouter()
const previewParams = route.ok ? route.params : null
const { client, state: connState } = useHostClient(previewParams?.hostId)
const forceReconnect = useForceReconnect()
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
)
const { width, height } = useWindowDimensions()
const loadPreview = useCallback(async () => {
if (!previewParams) {
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
return
}
if (!client || connState !== 'connected') {
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
return
}
setPreview({ status: 'loading', message: 'Loading preview...' })
try {
const result = await loadMobileFilePreview(
client,
previewParams.worktreeId,
previewParams.relativePath
)
setPreview(result)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unable to load preview'
setPreview(previewError(message))
}
}, [client, connState, previewParams, route])
useEffect(() => {
void loadPreview()
}, [loadPreview])
const retry = useCallback(async () => {
if (!previewParams) {
void loadPreview()
return
}
if (
preview.status === 'waiting' ||
(preview.status === 'error' && preview.reconnect) ||
connState !== 'connected'
) {
await forceReconnect(previewParams.hostId)
return
}
void loadPreview()
}, [connState, forceReconnect, loadPreview, preview, previewParams])
const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '')
const worktreeLabel = getWorktreeLabel(
previewParams?.worktreeName,
previewParams?.worktreeId ?? ''
)
const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview'
return (
<View style={styles.container}>
<SafeAreaView style={styles.header} edges={['top']}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Back to files"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<View style={styles.titleBlock}>
<Text style={styles.title} numberOfLines={1}>
{title || 'Preview'}
</Text>
<Text style={styles.meta} numberOfLines={1}>
{meta}
</Text>
</View>
</View>
</SafeAreaView>
{renderPreviewBody(preview, {
relativePath: previewParams?.relativePath ?? '',
title: title || 'File',
imageWidth: Math.max(1, width - spacing.md * 2),
imageHeight: Math.max(240, height - 160),
onImageError: () =>
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }),
onRetry: retry
})}
</View>
)
}
function renderPreviewBody(
preview: MobileFilePreviewResult,
options: {
relativePath: string
title: string
imageWidth: number
imageHeight: number
onImageError: () => void
onRetry: () => void
}
) {
if (preview.status === 'loading') {
return (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.stateText}>{preview.message}</Text>
</View>
)
}
if (preview.status === 'error' || preview.status === 'waiting') {
return (
<View style={styles.state}>
<Text style={styles.errorText}>{preview.message}</Text>
<Pressable style={styles.retryButton} onPress={options.onRetry}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)
}
if (preview.status === 'empty') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>Empty file</Text>
</View>
)
}
if (preview.kind === 'image') {
return (
<View style={styles.imageContainer}>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.imageScrollContent}
maximumZoomScale={4}
minimumZoomScale={1}
centerContent
>
<Image
source={{ uri: preview.dataUri }}
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
resizeMode="contain"
onError={options.onImageError}
accessibilityLabel={`${options.title} image`}
/>
</ScrollView>
</View>
)
}
if (preview.kind === 'markdown') {
return (
<MobileFileMarkdownPreview
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
}
if (preview.kind === 'html') {
return (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
}
return (
<MobileFilePreviewSourceText
relativePath={options.relativePath}
content={preview.content}
truncated={preview.truncated}
byteLength={preview.byteLength}
/>
)
}
@@ -0,0 +1,41 @@
import { useMemo } from 'react'
import { ScrollView, Text } from 'react-native'
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
import { formatPreviewByteLength } from './mobile-file-preview-request'
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
export function MobileFilePreviewSourceText({
relativePath,
content,
truncated,
byteLength
}: {
relativePath: string
content: string
truncated?: boolean
byteLength?: number
}) {
const syntax = useMemo(
() => buildMobileFilePreviewSyntax(relativePath, content),
[content, relativePath]
)
return (
<ScrollView style={styles.scroll} contentContainerStyle={styles.textContent}>
{truncated ? (
<MobileFilePreviewTruncatedNote byteLength={byteLength ?? content.length} />
) : null}
<Text selectable style={styles.textPreview} accessibilityLabel="File preview">
<MobileSyntaxSegments segments={syntax.segments} />
</Text>
</ScrollView>
)
}
export function MobileFilePreviewTruncatedNote({ byteLength }: { byteLength: number }) {
return (
<Text style={styles.truncatedNote}>
Preview truncated. File size: {formatPreviewByteLength(byteLength)}.
</Text>
)
}
@@ -0,0 +1,68 @@
import { describe, expect, it, vi } from 'vitest'
import {
canPreviewMobileFileRow,
navigateToMobileFilePreview
} from './mobile-file-preview-navigation'
describe('mobile-file-preview-navigation', () => {
it('navigates preview interactions to the encoded Expo target without files.open', () => {
const push = vi.fn()
const relativePath = 'assets/a #b?c%25 d\\logo.png'
navigateToMobileFilePreview(
{ push },
{
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'logo.png',
worktreeName: 'Orca'
}
)
expect(push).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'logo.png',
worktreeName: 'Orca'
}
})
expect(push).toHaveBeenCalledTimes(1)
})
it('defers embedded close until after route push', () => {
const events: string[] = []
const scheduleClose = vi.fn((callback: () => void) => {
events.push('schedule')
callback()
})
navigateToMobileFilePreview(
{
push: () => events.push('push')
},
{
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath: 'README.md'
},
{
embedded: true,
scheduleClose,
onRequestClose: () => events.push('close')
}
)
expect(events).toEqual(['push', 'schedule', 'close'])
expect(scheduleClose).toHaveBeenCalledWith(expect.any(Function), 0)
})
it('keeps non-image binary rows disabled while previewing text and raster images', () => {
expect(canPreviewMobileFileRow({ kind: 'text', relativePath: 'src/app.ts' })).toBe(true)
expect(canPreviewMobileFileRow({ kind: 'binary', relativePath: 'assets/logo.webp' })).toBe(true)
expect(canPreviewMobileFileRow({ kind: 'binary', relativePath: 'archive.zip' })).toBe(false)
})
})
@@ -0,0 +1,37 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import {
createMobileFilePreviewHref,
type MobileFilePreviewHref,
type MobileFilePreviewRouteParams
} from './mobile-file-preview-route'
export type MobileFilePreviewRouter = {
push: (href: MobileFilePreviewHref) => void
}
type NavigateOptions = {
embedded?: boolean
onRequestClose?: () => void
scheduleClose?: (callback: () => void, delayMs: number) => unknown
}
export function navigateToMobileFilePreview(
router: MobileFilePreviewRouter,
params: MobileFilePreviewRouteParams,
options: NavigateOptions = {}
): void {
router.push(createMobileFilePreviewHref(params))
if (options.embedded && options.onRequestClose) {
// Why: closing the dock immediately can unmount the subtree before Expo
// commits the route transition.
const scheduleClose = options.scheduleClose ?? setTimeout
scheduleClose(options.onRequestClose, 0)
}
}
export function canPreviewMobileFileRow(item: {
kind: 'text' | 'binary'
relativePath: string
}): boolean {
return item.kind === 'text' || classifyMobileArtifact(item.relativePath) === 'image'
}
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import {
createMobileFilePreviewRequest,
formatPreviewByteLength,
loadMobileFilePreview,
normalizeMobileFilePreviewResponse
} from './mobile-file-preview-request'
function ok(result: unknown): RpcSuccess {
return { id: '1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function fail(message: string, code = 'error'): RpcFailure {
return { id: '1', ok: false, error: { code, message }, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(response: RpcResponse) {
return {
sendRequest: vi.fn(async () => response)
}
}
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
method: 'files.readPreview',
params: { worktree: 'id:wt-1', relativePath: 'assets/logo.png' }
})
expect(createMobileFilePreviewRequest('wt-1', 'docs/readme.md')).toEqual({
method: 'files.read',
params: { worktree: 'id:wt-1', relativePath: 'docs/readme.md' }
})
expect(createMobileFilePreviewRequest('wt-1', 'public/index.html')).toEqual({
method: 'files.read',
params: { worktree: 'id:wt-1', relativePath: 'public/index.html' }
})
})
it('loads images through readPreview and never calls files.open', async () => {
const client = clientWith(
ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
)
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
status: 'ready',
kind: 'image',
dataUri: 'data:image/png;base64,aW1hZ2U='
})
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
worktree: 'id:wt-1',
relativePath: 'assets/logo.png'
})
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it.each([
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
])('rejects invalid image preview results: %s', (_label, result) => {
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
status: 'error',
message: 'Binary preview unavailable',
reconnect: false
})
})
it('normalizes markdown, html, text, empty, and truncated reads', () => {
expect(
normalizeMobileFilePreviewResponse(
'README.md',
ok({ content: '# Hi', truncated: false, byteLength: 4 })
)
).toEqual({
status: 'ready',
kind: 'markdown',
content: '# Hi',
truncated: false,
byteLength: 4
})
expect(
normalizeMobileFilePreviewResponse(
'index.html',
ok({ content: '<h1>Hi</h1>', truncated: false, byteLength: 11 })
)
).toMatchObject({ status: 'ready', kind: 'html' })
expect(
normalizeMobileFilePreviewResponse(
'src/app.ts',
ok({ content: 'const a = 1', truncated: true, byteLength: 700_000 })
)
).toEqual({
status: 'ready',
kind: 'text',
content: 'const a = 1',
truncated: true,
byteLength: 700_000
})
expect(
normalizeMobileFilePreviewResponse(
'empty.txt',
ok({ content: '', truncated: false, byteLength: 0 })
)
).toEqual({ status: 'empty', kind: 'text' })
})
it.each([
['binary_file', 'Binary preview unavailable', false],
['file_too_large', 'File too large for mobile preview', false],
['ENOENT: no such file or directory', 'File not found', false],
[
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
'Unable to reach the desktop filesystem',
true
],
['provider unavailable', 'Unable to reach the desktop filesystem', true],
['permission denied', 'Unable to load preview', false]
])('maps preview failure %s', (message, expected, reconnect) => {
expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({
status: 'error',
message: expected,
reconnect
})
})
it('formats truncation byte counts for the UI note', () => {
expect(formatPreviewByteLength(512)).toBe('512 B')
expect(formatPreviewByteLength(4096)).toBe('4 KB')
expect(formatPreviewByteLength(1_572_864)).toBe('1.5 MB')
})
})
@@ -0,0 +1,195 @@
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { isMarkdownPath } from './file-tree'
export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview'
export type MobileFilePreviewRequest = {
method: MobileFilePreviewReadMethod
params: {
worktree: string
relativePath: string
}
}
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
export type MobileFilePreviewResult =
| {
status: 'loading'
message: string
}
| {
status: 'waiting'
message: string
reconnect: true
}
| {
status: 'ready'
kind: 'image'
dataUri: string
}
| {
status: 'ready'
kind: MobileFilePreviewTextKind
content: string
truncated: boolean
byteLength: number
}
| {
status: 'empty'
kind: MobileFilePreviewTextKind
}
| {
status: 'error'
message: string
reconnect: boolean
}
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
export function createMobileFilePreviewRequest(
worktreeId: string,
relativePath: string
): MobileFilePreviewRequest {
return {
method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read',
params: {
worktree: `id:${worktreeId}`,
relativePath
}
}
}
export async function loadMobileFilePreview(
client: MobileFilePreviewClient,
worktreeId: string,
relativePath: string
): Promise<MobileFilePreviewResult> {
const request = createMobileFilePreviewRequest(worktreeId, relativePath)
const response = await client.sendRequest(request.method, request.params)
return normalizeMobileFilePreviewResponse(relativePath, response)
}
export function normalizeMobileFilePreviewResponse(
relativePath: string,
response: RpcResponse
): MobileFilePreviewResult {
if (!response.ok) {
return previewError(
(response as RpcFailure).error.message || (response as RpcFailure).error.code
)
}
const result = (response as RpcSuccess).result
if (classifyMobileArtifact(relativePath) === 'image') {
return normalizeImagePreviewResult(result)
}
return normalizeTextPreviewResult(relativePath, result)
}
export function previewError(message: string): MobileFilePreviewResult {
const normalized = message.toLowerCase()
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
}
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
}
if (
normalized.includes('remote connection dropped') ||
normalized.includes('provider unavailable') ||
normalized.includes('disconnected')
) {
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
}
if (
normalized.includes('enoent') ||
normalized.includes('no such file') ||
normalized.includes('not found') ||
normalized.includes('does not exist')
) {
return { status: 'error', message: 'File not found', reconnect: false }
}
return { status: 'error', message: 'Unable to load preview', reconnect: false }
}
export function formatPreviewByteLength(byteLength: number): string {
if (!Number.isFinite(byteLength) || byteLength < 0) {
return 'unknown size'
}
if (byteLength < 1024) {
return `${byteLength} B`
}
if (byteLength < 1024 * 1024) {
return `${Math.round(byteLength / 1024)} KB`
}
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
}
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('binary_file')
}
const preview = result as {
content?: unknown
isBinary?: unknown
isImage?: unknown
mimeType?: unknown
}
if (
preview.isBinary !== true ||
preview.isImage !== true ||
typeof preview.mimeType !== 'string' ||
preview.mimeType.length === 0 ||
typeof preview.content !== 'string' ||
preview.content.length === 0
) {
return previewError('binary_file')
}
return {
status: 'ready',
kind: 'image',
dataUri: `data:${preview.mimeType};base64,${preview.content}`
}
}
function normalizeTextPreviewResult(
relativePath: string,
result: unknown
): MobileFilePreviewResult {
if (!result || typeof result !== 'object') {
return previewError('Unable to load preview')
}
const preview = result as {
content?: unknown
truncated?: unknown
byteLength?: unknown
isBinary?: unknown
}
if (preview.isBinary === true) {
return previewError('binary_file')
}
if (typeof preview.content !== 'string') {
return previewError('Unable to load preview')
}
const kind = textKindForPreviewPath(relativePath)
if (preview.content.length === 0) {
return { status: 'empty', kind }
}
return {
status: 'ready',
kind,
content: preview.content,
truncated: preview.truncated === true,
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
}
}
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
if (classifyMobileArtifact(relativePath) === 'html') {
return 'html'
}
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import {
createMobileFilePreviewHref,
displayNameFromPreviewPath,
normalizeMobileFilePreviewRouteParams
} from './mobile-file-preview-route'
describe('mobile-file-preview-route', () => {
it('normalizes valid route params without changing encoded-sensitive path characters', () => {
const relativePath = 'docs/a #b?c%25 d\\note.md'
const route = normalizeMobileFilePreviewRouteParams({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'note.md',
worktreeName: 'Orca'
})
expect(route).toEqual({
ok: true,
params: {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'note.md',
worktreeName: 'Orca'
}
})
})
it('rejects missing, empty, or array-valued required params', () => {
expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({
ok: false,
message: 'Unable to load preview'
})
expect(
normalizeMobileFilePreviewRouteParams({
hostId: 'h',
worktreeId: 'w',
relativePath: ''
})
).toEqual({ ok: false, message: 'Unable to load preview' })
expect(
normalizeMobileFilePreviewRouteParams({
hostId: 'h',
worktreeId: 'w',
relativePath: ['a.ts', 'b.ts']
})
).toEqual({ ok: false, message: 'Unable to load preview' })
})
it('builds the Expo href object so Expo owns URL encoding', () => {
const relativePath = 'src/#hash ?query%20\\file.ts'
expect(
createMobileFilePreviewHref({
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'file.ts'
})
).toEqual({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: {
hostId: 'host-1',
worktreeId: 'wt-1',
relativePath,
name: 'file.ts'
}
})
})
it('derives display names from slash or backslash paths only for display', () => {
expect(displayNameFromPreviewPath('src/app.ts')).toBe('app.ts')
expect(displayNameFromPreviewPath('src\\app.ts')).toBe('app.ts')
})
})
@@ -0,0 +1,68 @@
export type MobileFilePreviewParamValue = string | string[] | undefined
export type MobileFilePreviewRouteParams = {
hostId: string
worktreeId: string
relativePath: string
name?: string
worktreeName?: string
}
export type MobileFilePreviewRouteState =
| { ok: true; params: MobileFilePreviewRouteParams }
| { ok: false; message: string }
export type MobileFilePreviewHref = {
pathname: '/h/[hostId]/files/preview/[worktreeId]'
params: MobileFilePreviewRouteParams
}
type RawPreviewRouteParams = {
hostId?: MobileFilePreviewParamValue
worktreeId?: MobileFilePreviewParamValue
relativePath?: MobileFilePreviewParamValue
name?: MobileFilePreviewParamValue
worktreeName?: MobileFilePreviewParamValue
}
function singleParam(value: MobileFilePreviewParamValue): string | null {
return typeof value === 'string' ? value : null
}
function optionalSingleParam(value: MobileFilePreviewParamValue): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
export function normalizeMobileFilePreviewRouteParams(
params: RawPreviewRouteParams
): MobileFilePreviewRouteState {
const hostId = singleParam(params.hostId)
const worktreeId = singleParam(params.worktreeId)
const relativePath = singleParam(params.relativePath)
if (!hostId || !worktreeId || !relativePath) {
return { ok: false, message: 'Unable to load preview' }
}
return {
ok: true,
params: {
hostId,
worktreeId,
relativePath,
name: optionalSingleParam(params.name),
worktreeName: optionalSingleParam(params.worktreeName)
}
}
}
export function createMobileFilePreviewHref(
params: MobileFilePreviewRouteParams
): MobileFilePreviewHref {
return {
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params
}
}
export function displayNameFromPreviewPath(relativePath: string): string {
return relativePath.split(/[\\/]/).filter(Boolean).at(-1) ?? relativePath
}
@@ -0,0 +1,140 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const filePreviewStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
header: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
topBar: {
minHeight: 58,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
paddingHorizontal: spacing.md
},
backButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
backButtonPressed: {
backgroundColor: colors.bgRaised
},
titleBlock: {
flex: 1,
minWidth: 0
},
title: {
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '600'
},
meta: {
marginTop: 2,
color: colors.textSecondary,
fontSize: typography.metaSize
},
state: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.md,
padding: spacing.xl
},
stateText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
textAlign: 'center'
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
textAlign: 'center'
},
retryButton: {
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.lg
},
retryText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
scroll: {
flex: 1,
backgroundColor: colors.editorSurface
},
textContent: {
padding: spacing.md,
paddingBottom: spacing.xl
},
textPreview: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: 13,
lineHeight: 19
},
markdownContent: {
padding: spacing.md,
paddingBottom: spacing.xl
},
modeContainer: {
flex: 1,
backgroundColor: colors.editorSurface
},
modeToolbar: {
flexDirection: 'row',
alignSelf: 'flex-start',
marginHorizontal: spacing.md,
marginVertical: spacing.sm,
padding: 1,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
backgroundColor: colors.bgPanel
},
modeToggle: {
width: 34,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.row,
backgroundColor: 'transparent',
opacity: 0.72
},
modeToggleActive: {
backgroundColor: colors.bgRaised,
opacity: 1
},
truncatedNote: {
marginBottom: spacing.md,
color: colors.textSecondary,
fontSize: typography.metaSize
},
imageContainer: {
flex: 1,
backgroundColor: colors.editorSurface
},
imageScrollContent: {
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.md
},
image: {
backgroundColor: colors.editorSurface
}
})
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
describe('mobile-file-preview-syntax', () => {
it('returns syntax segments for supported source paths', () => {
const syntax = buildMobileFilePreviewSyntax('src/app.ts', 'const answer = 42')
expect(syntax.language).toBe('typescript')
expect(syntax.segments.map((segment) => segment.text).join('')).toBe('const answer = 42')
})
it('falls back to a plain segment when highlighting is unavailable', () => {
expect(buildMobileFilePreviewSyntax('README.unknown', 'plain text')).toEqual({
language: 'plaintext',
segments: [{ kind: 'plain', text: 'plain text' }]
})
})
})
@@ -0,0 +1,30 @@
import {
highlightMobileCode,
resolveMobileSyntaxLanguage,
type MobileSyntaxSegment
} from '../session/mobile-file-syntax'
export type MobileFilePreviewSyntax = {
language: string
segments: MobileSyntaxSegment[]
}
export function buildMobileFilePreviewSyntax(
relativePath: string,
content: string
): MobileFilePreviewSyntax {
try {
const language = resolveMobileSyntaxLanguage(relativePath)
const result = highlightMobileCode(content, language)
return {
language,
segments: result.segments.length > 0 ? result.segments : plainSegments(content)
}
} catch {
return { language: 'plaintext', segments: plainSegments(content) }
}
}
function plainSegments(content: string): MobileSyntaxSegment[] {
return [{ kind: 'plain', text: content }]
}
@@ -36,6 +36,7 @@ describe('mobile session startup', () => {
)
expect(startupEffect).toContain("void client\n .sendRequest('worktree.activate'")
expect(startupEffect).toContain('notifyClients: false')
expect(startupEffect).not.toContain("await client\n .sendRequest('worktree.activate'")
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await fetchSessionTabs()')
@@ -58,12 +59,22 @@ describe('mobile session startup', () => {
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
expect(pendingActivationEffect).toContain('notifyClients: false')
expect(pendingActivationEffect).toContain(
'applySessionTabs((response as RpcSuccess).result as SessionTabsResult)'
)
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
})
it('keeps mobile session tab activation local to the phone', () => {
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
expect(activationRequests).toHaveLength(4)
for (const request of activationRequests) {
expect(request.slice(0, request.indexOf('})'))).toContain('notifyClients: false')
}
})
it('keeps dynamic agent rows above fixed New Tab actions', () => {
const newTabActions = sliceBetween('title="New Tab"', 'onClose={() => setShowCreateTabDrawer')
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
type AppliedSnapshotMarker
} from './session-tab-snapshot-gate'
describe('acceptSessionSnapshot', () => {
it('accepts a newer version from the same publisher and advances the floor', () => {
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 5 }
expect(
acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 6 }, marker)
).toBe(true)
expect(marker).toEqual({ epoch: 'renderer:a', version: 6 })
})
it('rejects a strictly older version from the same publisher without moving the floor', () => {
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 }
expect(
acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 41 }, marker)
).toBe(false)
expect(marker).toEqual({ epoch: 'renderer:a', version: 42 })
})
it('accepts an equal version (reprocess) so close tombstones can expire and clear', () => {
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 42 }
// Polling returns the same cached version repeatedly; reprocessing is needed
// so applyClosedTabTombstones can run its TTL/clear pass.
expect(
acceptSessionSnapshot({ publicationEpoch: 'renderer:a', snapshotVersion: 42 }, marker)
).toBe(true)
expect(marker).toEqual({ epoch: 'renderer:a', version: 42 })
})
it('accepts any version from a new publisher (epoch change) and resets the floor', () => {
const marker: AppliedSnapshotMarker = { epoch: 'renderer:a', version: 99 }
// Lower version but different epoch (renderer reload / headless) → accepted.
expect(
acceptSessionSnapshot({ publicationEpoch: 'headless:b', snapshotVersion: 1 }, marker)
).toBe(true)
expect(marker).toEqual({ epoch: 'headless:b', version: 1 })
})
it('treats a missing epoch as its own publisher key', () => {
const marker: AppliedSnapshotMarker = { epoch: null, version: -1 }
expect(acceptSessionSnapshot({ snapshotVersion: 5 }, marker)).toBe(true)
// A strictly older snapshot from the same (null) publisher is rejected.
expect(acceptSessionSnapshot({ snapshotVersion: 4 }, marker)).toBe(false)
expect(marker).toEqual({ epoch: null, version: 5 })
})
})
describe('applyClosedTabTombstones', () => {
const tab = (id: string): { id: string } => ({ id })
it('returns the tabs untouched when there are no tombstones', () => {
const tabs = [tab('a'), tab('b')]
expect(applyClosedTabTombstones(tabs, new Map(), 1000)).toBe(tabs)
})
it('suppresses a tombstoned tab while it is still present and not expired', () => {
const tombstones = new Map([['a', 5000]])
const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 1000)
expect(result.map((t) => t.id)).toEqual(['b'])
// Still present in the publisher snapshot → tombstone retained for next time.
expect(tombstones.has('a')).toBe(true)
})
it('clears the tombstone once the publisher snapshot no longer includes the tab', () => {
const tombstones = new Map([['a', 5000]])
const result = applyClosedTabTombstones([tab('b')], tombstones, 1000)
expect(result.map((t) => t.id)).toEqual(['b'])
expect(tombstones.has('a')).toBe(false)
})
it('stops suppressing and clears an expired tombstone even if still present', () => {
const tombstones = new Map([['a', 5000]])
const result = applyClosedTabTombstones([tab('a'), tab('b')], tombstones, 5000)
expect(result.map((t) => t.id)).toEqual(['a', 'b'])
expect(tombstones.has('a')).toBe(false)
})
})
@@ -0,0 +1,70 @@
// Pure helpers for reconciling incoming session-tab snapshots on mobile. Kept
// free of react-native imports so they stay unit-testable in the node test env.
// Two races motivate these: out-of-order snapshots (subscription vs poll vs
// post-mutation refetch) and the brief window after a local close before the
// publisher's snapshot reflects it.
/** The last snapshot applied from a publisher, used to reject older ones. */
export type AppliedSnapshotMarker = { epoch: string | null; version: number }
/**
* Whether an incoming snapshot should be applied. Rejects only snapshots
* STRICTLY older than the last applied one from the same publisher (epoch) so an
* out-of-order response can't revive a tab a newer snapshot dropped. Equal
* versions are accepted (and reprocessed) — polling returns the same cached
* version repeatedly, and reprocessing is what lets close tombstones expire and
* clear; rejecting equal versions would strand them. A different epoch is a new
* publisher (renderer reload / headless), accepted as the new floor. Mutates
* `marker` to record the accepted snapshot.
*/
export function acceptSessionSnapshot(
incoming: { publicationEpoch?: string; snapshotVersion: number },
marker: AppliedSnapshotMarker
): boolean {
const incomingEpoch = incoming.publicationEpoch ?? null
if (incomingEpoch === marker.epoch) {
if (incoming.snapshotVersion < marker.version) {
return false
}
} else {
marker.epoch = incomingEpoch
}
marker.version = incoming.snapshotVersion
return true
}
/**
* Drops tabs the user just closed locally (tombstoned) until the publisher's
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
* clearing entries the publisher has confirmed gone (absent from `tabs`) or
* whose TTL elapsed — the TTL guards against a failed host-side close hiding a
* tab forever.
*/
export function applyClosedTabTombstones<T extends { id: string }>(
tabs: T[],
tombstones: Map<string, number>,
now: number
): T[] {
if (tombstones.size === 0) {
return tabs
}
const suppressed = new Set<string>()
const next = tabs.filter((tab) => {
const expiry = tombstones.get(tab.id)
if (expiry === undefined) {
return true
}
if (now >= expiry) {
tombstones.delete(tab.id)
return true
}
suppressed.add(tab.id)
return false
})
for (const [id, expiry] of tombstones) {
if (!suppressed.has(id) || now >= expiry) {
tombstones.delete(id)
}
}
return next
}
@@ -41,9 +41,9 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
unstagedCount,
branchLabel,
syncLabel,
primaryAction,
stageAll,
unstageAll,
commit,
generateCommitMessage,
cancelGenerateCommitMessage,
abortConflictOperation,
@@ -52,6 +52,7 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
runGitAction
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
return (
<>
@@ -224,46 +225,50 @@ export function MobileSourceControlContent({ state, hostId, worktreeId, name }:
placeholderTextColor={colors.textMuted}
editable={busyAction === null && openingPath === null && openingBranchPath === null}
returnKeyType="done"
onSubmitEditing={() => void commit()}
onSubmitEditing={primaryAction.onPress}
/>
)}
<Pressable
style={({ pressed }) => [
styles.generateButton,
(stagedCount === 0 || busyAction !== null) && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: stay tappable while generating so the press can cancel
// (disabling it here made the cancel branch below unreachable).
disabled={stagedCount === 0 || busyAction !== null}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? 'Cancel commit message generation'
: 'Generate commit message with AI'
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
{shouldShowGenerateButton ? (
<Pressable
style={({ pressed }) => [
styles.generateButton,
busyAction !== null && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
// Why: commit-message AI belongs to the commit path; hiding it
// during Stage All keeps the quick action visually unambiguous.
disabled={busyAction !== null}
onPress={() =>
generatingMessage ? cancelGenerateCommitMessage() : void generateCommitMessage()
}
accessibilityLabel={
generatingMessage
? 'Cancel commit message generation'
: 'Generate commit message with AI'
}
>
{generatingMessage ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Sparkles size={16} color={colors.textSecondary} strokeWidth={2.1} />
)}
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.commitButton,
(!commitMessage.trim() || stagedCount === 0 || ioBusy) && styles.commitButtonDisabled,
primaryAction.disabled && styles.commitButtonDisabled,
pressed && styles.commitButtonPressed
]}
onPress={() => void commit()}
disabled={!commitMessage.trim() || stagedCount === 0 || ioBusy}
onPress={primaryAction.onPress}
disabled={primaryAction.disabled}
accessibilityLabel={primaryAction.accessibilityLabel}
accessibilityHint={primaryAction.accessibilityHint}
>
{busyAction === 'commit' ? (
{primaryAction.loading ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.commitButtonText}>Commit</Text>
<Text style={styles.commitButtonText}>{primaryAction.label}</Text>
)}
</Pressable>
</View>
@@ -0,0 +1,268 @@
import type { MobileGitUpstreamStatus } from './mobile-git-status'
export type MobileSourceControlPrimaryActionKind =
| 'commit'
| 'stage'
| 'push'
| 'pull'
| 'sync'
| 'publish'
export type MobileSourceControlRemoteOpKind =
| 'push'
| 'force_push'
| 'pull'
| 'sync'
| 'fetch'
| 'fast_forward'
| 'publish'
| 'rebase'
export type MobileSourceControlPrimaryActionTitleIntent =
| 'commit_in_progress'
| 'force_push_in_progress'
| 'action_in_progress'
| 'remote_operation_in_progress'
| 'remote_operation_blocks_commit'
| 'resolve_conflicts_before_commit'
| 'commit_staged_changes'
| 'enter_commit_message'
| 'stage_all_changes'
| 'stage_file_to_commit'
| 'checkout_branch_before_publish'
| 'publish_branch'
| 'force_push_with_lease'
| 'sync_counts'
| 'pull_count'
| 'push_count'
| 'nothing_to_commit_up_to_date'
export type MobileSourceControlPrimaryActionDecision = {
kind: MobileSourceControlPrimaryActionKind
disabled: boolean
labelIntent: MobileSourceControlPrimaryActionKind | 'force_push'
titleIntent: MobileSourceControlPrimaryActionTitleIntent
count?: number
ahead?: number
behind?: number
upstreamName?: string
requiresForceWithLease?: boolean
}
export type MobileSourceControlPrimaryActionDecisionInputs = {
stagedCount: number
hasUnstagedChanges: boolean
hasStageableChanges: boolean
hasPartiallyStagedChanges: boolean
hasMessage: boolean
hasUnresolvedConflicts: boolean
isCommitting: boolean
isRemoteOperationActive: boolean
upstreamStatus: MobileGitUpstreamStatus | undefined
inFlightRemoteOpKind?: MobileSourceControlRemoteOpKind | null
branchCommitsAhead?: number
hasCurrentBranch?: boolean
}
// Why: Metro cannot load runtime modules from the desktop/root `src/shared`
// tree. Keep this mobile mirror narrow and parity-tested against the shared
// commit-area decision core so the semantic ladder cannot drift silently.
export function resolveMobileSourceControlCommitAreaPrimaryActionDecision(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const {
stagedCount,
hasUnstagedChanges,
hasStageableChanges,
hasMessage,
hasUnresolvedConflicts,
isCommitting,
isRemoteOperationActive,
upstreamStatus,
branchCommitsAhead,
hasCurrentBranch = true
} = inputs
if (isCommitting) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_in_progress',
disabled: true
}
}
if (isRemoteOperationActive) {
return resolveMobilePrimaryActionDuringRemoteOp(inputs)
}
if (hasUnresolvedConflicts) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'resolve_conflicts_before_commit',
disabled: true
}
}
const hasStaged = stagedCount > 0
if (hasStaged && hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'commit_staged_changes',
disabled: false
}
}
if (hasStaged && !hasMessage) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'enter_commit_message',
disabled: true
}
}
if (!hasStaged && hasStageableChanges) {
return {
kind: 'stage',
labelIntent: 'stage',
titleIntent: 'stage_all_changes',
disabled: false
}
}
if (!upstreamStatus) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'stage_file_to_commit',
disabled: true
}
}
if (!upstreamStatus.hasUpstream) {
if (!hasCurrentBranch) {
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: 'checkout_branch_before_publish',
disabled: true
}
}
return {
kind: 'publish',
labelIntent: 'publish',
titleIntent: 'publish_branch',
disabled: false
}
}
if (upstreamStatus.ahead > 0 && upstreamStatus.behind > 0) {
if (shouldForcePushWithLeaseForMobileUpstream(upstreamStatus)) {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_with_lease',
disabled: false,
count: branchCommitsAhead,
upstreamName: upstreamStatus.upstreamName,
requiresForceWithLease: true
}
}
return {
kind: 'sync',
labelIntent: 'sync',
titleIntent: 'sync_counts',
disabled: false,
ahead: upstreamStatus.ahead,
behind: upstreamStatus.behind
}
}
if (upstreamStatus.behind > 0) {
return {
kind: 'pull',
labelIntent: 'pull',
titleIntent: 'pull_count',
disabled: false,
count: upstreamStatus.behind
}
}
if (upstreamStatus.ahead > 0) {
return {
kind: 'push',
labelIntent: 'push',
titleIntent: 'push_count',
disabled: false,
count: upstreamStatus.ahead
}
}
return {
kind: 'commit',
labelIntent: 'commit',
titleIntent: hasUnstagedChanges ? 'stage_file_to_commit' : 'nothing_to_commit_up_to_date',
disabled: true
}
}
function resolveMobilePrimaryActionDuringRemoteOp(
inputs: MobileSourceControlPrimaryActionDecisionInputs
): MobileSourceControlPrimaryActionDecision {
const { inFlightRemoteOpKind, hasUnresolvedConflicts } = inputs
const candidate = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
...inputs,
isRemoteOperationActive: false
})
const inFlightIsPrimaryKind =
inFlightRemoteOpKind === 'push' ||
inFlightRemoteOpKind === 'pull' ||
inFlightRemoteOpKind === 'sync' ||
inFlightRemoteOpKind === 'publish'
if (inFlightRemoteOpKind === 'force_push') {
return {
kind: 'push',
labelIntent: 'force_push',
titleIntent: 'force_push_in_progress',
disabled: true,
requiresForceWithLease: true
}
}
if (inFlightIsPrimaryKind && candidate.kind !== inFlightRemoteOpKind) {
return {
kind: inFlightRemoteOpKind,
labelIntent: inFlightRemoteOpKind,
titleIntent: 'action_in_progress',
disabled: true
}
}
const titleIntent = hasUnresolvedConflicts
? 'resolve_conflicts_before_commit'
: candidate.kind === 'commit'
? 'remote_operation_blocks_commit'
: 'remote_operation_in_progress'
return {
...candidate,
titleIntent,
disabled: true
}
}
function shouldForcePushWithLeaseForMobileUpstream(
status: MobileGitUpstreamStatus | undefined
): boolean {
return (
status?.hasUpstream === true &&
status.ahead > 0 &&
status.behind > 0 &&
status.behindCommitsArePatchEquivalent === true
)
}
@@ -0,0 +1,271 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveSourceControlCommitAreaPrimaryActionDecision } from '../../../src/shared/source-control-primary-action-decision'
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecisionInputs
} from './mobile-source-control-primary-action-decision'
import {
buildMobileSourceControlPrimaryAction,
type MobileSourceControlPrimaryActionArgs,
type MobileSourceControlPrimaryActionHandlers
} from './mobile-source-control-primary-action'
import type { MobileGitStatusResult } from './mobile-git-status'
function handlers(): MobileSourceControlPrimaryActionHandlers {
return {
commit: vi.fn(async () => true),
stageAll: vi.fn(async () => undefined),
runActionSheetGitSequence: vi.fn(async () => undefined),
runActionSheetGitSync: vi.fn(async () => undefined)
}
}
function status(overrides: Partial<MobileGitStatusResult> = {}): MobileGitStatusResult {
return {
entries: [],
conflictOperation: 'unknown',
branch: 'feature',
head: 'abc123',
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 0 },
...overrides
}
}
function args(overrides: Partial<MobileSourceControlPrimaryActionArgs> = {}) {
return {
status: status(),
hasUnresolvedConflicts: false,
stageablePaths: [],
stagedCount: 0,
unstagedCount: 0,
commitMessage: '',
busyAction: null,
openingPath: null,
openingBranchPath: null,
branchCompareResult: null,
handlers: handlers(),
...overrides
}
}
describe('buildMobileSourceControlPrimaryAction', () => {
it('selects Stage All for unstaged work and dispatches the stage runner', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
stageablePaths: ['a.ts'],
unstagedCount: 1,
handlers: h
})
)
expect(action.label).toBe('Stage All')
expect(action.disabled).toBe(false)
action.onPress()
expect(h.stageAll).toHaveBeenCalledTimes(1)
})
it('selects Commit for staged work with a message and dispatches commit', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
stagedCount: 1,
commitMessage: 'Ship it',
handlers: h
})
)
expect(action.label).toBe('Commit')
expect(action.disabled).toBe(false)
action.onPress()
expect(h.commit).toHaveBeenCalledTimes(1)
})
it('selects Publish Branch only when a current branch exists', () => {
expect(
buildMobileSourceControlPrimaryAction(
args({ status: status({ upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 } }) })
).label
).toBe('Publish Branch')
const detached = buildMobileSourceControlPrimaryAction(
args({
status: status({
branch: undefined,
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 }
})
})
)
expect(detached.label).toBe('Commit')
expect(detached.disabled).toBe(true)
})
it('dispatches force push with lease when the shared decision requires it', () => {
const h = handlers()
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({
upstreamStatus: {
hasUpstream: true,
ahead: 10,
behind: 2,
behindCommitsArePatchEquivalent: true
}
}),
branchCompareResult: {
entries: [],
summary: {
status: 'ready',
baseRef: 'main',
baseOid: 'base',
compareRef: 'HEAD',
changedFiles: 0,
commitsAhead: 3,
headOid: 'abc',
mergeBase: 'def'
}
},
handlers: h
})
)
expect(action.label).toBe('Force Push')
expect(action.requiresForceWithLease).toBe(true)
action.onPress()
expect(h.runActionSheetGitSequence).toHaveBeenCalledWith('force-push', [
{ method: 'git.push', params: { forceWithLease: true } }
])
})
it('disables the button for unresolved entries even during a conflict operation', () => {
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({ conflictOperation: 'merge' }),
hasUnresolvedConflicts: true,
stagedCount: 1,
commitMessage: 'Resolve'
})
)
expect(action.label).toBe('Commit')
expect(action.disabled).toBe(true)
expect(action.accessibilityHint).toBe('Resolve conflicts before committing.')
})
it('does not block solely because a conflict operation exists without unresolved entries', () => {
const action = buildMobileSourceControlPrimaryAction(
args({
status: status({ conflictOperation: 'merge' }),
hasUnresolvedConflicts: false,
stagedCount: 1,
commitMessage: 'Resolve'
})
)
expect(action.disabled).toBe(false)
})
})
function decisionInputs(
overrides: Partial<MobileSourceControlPrimaryActionDecisionInputs> = {}
): MobileSourceControlPrimaryActionDecisionInputs {
return {
stagedCount: 0,
hasUnstagedChanges: false,
hasStageableChanges: false,
hasPartiallyStagedChanges: false,
hasMessage: false,
hasUnresolvedConflicts: false,
isCommitting: false,
isRemoteOperationActive: false,
upstreamStatus: undefined,
...overrides
}
}
describe('mobile source-control primary action decision parity', () => {
it.each([
{
name: 'dirty tree stages first',
input: decisionInputs({
hasUnstagedChanges: true,
hasStageableChanges: true,
upstreamStatus: { hasUpstream: true, ahead: 0, behind: 2 }
})
},
{
name: 'staged message commits',
input: decisionInputs({ stagedCount: 1, hasMessage: true })
},
{
name: 'staged without message blocks commit',
input: decisionInputs({ stagedCount: 1, hasMessage: false })
},
{
name: 'unresolved conflicts block commit',
input: decisionInputs({ stagedCount: 1, hasMessage: true, hasUnresolvedConflicts: true })
},
{
name: 'unpublished branch publishes',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: true
})
},
{
name: 'detached head blocks publish',
input: decisionInputs({
upstreamStatus: { hasUpstream: false, ahead: 0, behind: 0 },
hasCurrentBranch: false
})
},
{
name: 'tracked ahead pushes',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 } })
},
{
name: 'tracked behind pulls',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 0, behind: 3 } })
},
{
name: 'tracked diverged syncs',
input: decisionInputs({ upstreamStatus: { hasUpstream: true, ahead: 2, behind: 3 } })
},
{
name: 'patch-equivalent diverged force-pushes with lease',
input: decisionInputs({
branchCommitsAhead: 4,
upstreamStatus: {
hasUpstream: true,
upstreamName: 'origin/feature',
ahead: 10,
behind: 2,
behindCommitsArePatchEquivalent: true
}
})
},
{
name: 'in-flight pull mirrors pull',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'pull',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight force push mirrors force push',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'force_push',
upstreamStatus: { hasUpstream: true, ahead: 2, behind: 0 }
})
},
{
name: 'in-flight push blocks committable candidate',
input: decisionInputs({
isRemoteOperationActive: true,
inFlightRemoteOpKind: 'push',
stagedCount: 1,
hasMessage: true
})
}
])('matches the shared commit-area decision for $name', ({ input }) => {
expect(resolveMobileSourceControlCommitAreaPrimaryActionDecision(input)).toEqual(
resolveSourceControlCommitAreaPrimaryActionDecision(input)
)
})
})
@@ -0,0 +1,241 @@
import {
resolveMobileSourceControlCommitAreaPrimaryActionDecision,
type MobileSourceControlPrimaryActionDecision,
type MobileSourceControlPrimaryActionKind,
type MobileSourceControlRemoteOpKind
} from './mobile-source-control-primary-action-decision'
import type { MobileGitBranchCompareResult } from './mobile-branch-compare'
import type { MobileGitStatusResult } from './mobile-git-status'
type GitStep = { method: string; params?: Record<string, unknown> }
export type MobileSourceControlPrimaryAction = {
kind: MobileSourceControlPrimaryActionKind
label: string
accessibilityLabel: string
accessibilityHint: string
disabled: boolean
loading: boolean
requiresForceWithLease?: boolean
onPress: () => void
}
export type MobileSourceControlPrimaryActionHandlers = {
commit: () => Promise<boolean>
stageAll: () => Promise<void>
runActionSheetGitSequence: (actionId: string, steps: GitStep[]) => Promise<void>
runActionSheetGitSync: () => Promise<void>
}
export type MobileSourceControlPrimaryActionArgs = {
status: MobileGitStatusResult | null
hasUnresolvedConflicts: boolean
stageablePaths: readonly string[]
stagedCount: number
unstagedCount: number
commitMessage: string
busyAction: string | null
openingPath: string | null
openingBranchPath: string | null
branchCompareResult: MobileGitBranchCompareResult | null
handlers: MobileSourceControlPrimaryActionHandlers
}
export function buildMobileSourceControlPrimaryAction(
args: MobileSourceControlPrimaryActionArgs
): MobileSourceControlPrimaryAction {
const decision = resolveMobileSourceControlCommitAreaPrimaryActionDecision({
stagedCount: args.stagedCount,
hasUnstagedChanges: args.unstagedCount > 0,
hasStageableChanges: args.stageablePaths.length > 0,
// Why: the commit-area decision keeps the desktop input shape, but partial
// staging only matters to commit eligibility/dropdowns. Avoid an extra entry scan.
hasPartiallyStagedChanges: false,
hasMessage: args.commitMessage.trim().length > 0,
hasUnresolvedConflicts: args.hasUnresolvedConflicts,
isCommitting: args.busyAction === 'commit',
isRemoteOperationActive: isMobileRemoteOperationActive(args.busyAction),
inFlightRemoteOpKind: getInFlightRemoteOpKind(args.busyAction),
upstreamStatus: args.status?.upstreamStatus,
branchCommitsAhead: getMobileBranchCommitsAhead(args),
hasCurrentBranch: Boolean(args.status?.branch)
})
const ioBusy =
args.busyAction !== null || args.openingPath !== null || args.openingBranchPath !== null
const disabled = decision.disabled || ioBusy
return {
kind: decision.kind,
label: getMobilePrimaryActionLabel(decision),
accessibilityLabel: getMobilePrimaryActionLabel(decision),
accessibilityHint: getMobilePrimaryActionHint(decision),
disabled,
loading: isLoadingDecision(decision, args.busyAction),
requiresForceWithLease: decision.requiresForceWithLease,
onPress: () => {
if (disabled) {
return
}
void runMobilePrimaryAction(decision, args.handlers)
}
}
}
function isMobileRemoteOperationActive(busyAction: string | null): boolean {
return getInFlightRemoteOpKind(busyAction) !== null
}
function getInFlightRemoteOpKind(
busyAction: string | null
): MobileSourceControlRemoteOpKind | null {
switch (busyAction) {
case 'push':
case 'commit-push':
case 'push-create-pr':
return 'push'
case 'force-push':
return 'force_push'
case 'pull':
return 'pull'
case 'sync':
case 'commit-sync':
return 'sync'
case 'fetch':
return 'fetch'
case 'publish':
return 'publish'
case 'fast-forward':
return 'fast_forward'
case 'rebase':
return 'rebase'
default:
return null
}
}
function getMobileBranchCommitsAhead(
args: MobileSourceControlPrimaryActionArgs
): number | undefined {
const summary = args.branchCompareResult?.summary
if (summary?.status === 'ready' && summary.commitsAhead !== undefined) {
return summary.commitsAhead
}
const upstream = args.status?.upstreamStatus
return upstream?.hasUpstream ? upstream.ahead : undefined
}
function getMobilePrimaryActionLabel(decision: MobileSourceControlPrimaryActionDecision): string {
if (decision.requiresForceWithLease) {
return 'Force Push'
}
switch (decision.kind) {
case 'commit':
return 'Commit'
case 'stage':
return 'Stage All'
case 'push':
return 'Push'
case 'pull':
return 'Pull'
case 'sync':
return 'Sync'
case 'publish':
return 'Publish Branch'
}
}
function getMobilePrimaryActionHint(decision: MobileSourceControlPrimaryActionDecision): string {
switch (decision.titleIntent) {
case 'commit_in_progress':
return 'Commit in progress.'
case 'force_push_in_progress':
return 'Force push in progress.'
case 'action_in_progress':
case 'remote_operation_in_progress':
return 'Remote operation in progress.'
case 'remote_operation_blocks_commit':
return 'Try again once the remote operation finishes.'
case 'resolve_conflicts_before_commit':
return 'Resolve conflicts before committing.'
case 'commit_staged_changes':
return 'Commit staged changes.'
case 'enter_commit_message':
return 'Enter a commit message to commit.'
case 'stage_all_changes':
return 'Stage all changes.'
case 'stage_file_to_commit':
return 'Stage at least one file to commit.'
case 'checkout_branch_before_publish':
return 'Check out a branch before publishing commits.'
case 'publish_branch':
return 'Publish this branch to origin.'
case 'force_push_with_lease':
return 'Force push with lease to update the remote branch.'
case 'sync_counts':
return `Pull ${decision.behind ?? 0}, push ${decision.ahead ?? 0}.`
case 'pull_count':
return `Pull ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.`
case 'push_count':
return `Push ${decision.count ?? 0} commit${decision.count === 1 ? '' : 's'}.`
case 'nothing_to_commit_up_to_date':
return 'Nothing to commit. Branch is up to date.'
}
}
function isLoadingDecision(
decision: MobileSourceControlPrimaryActionDecision,
busyAction: string | null
): boolean {
switch (decision.kind) {
case 'commit':
return busyAction === 'commit'
case 'stage':
return busyAction === 'stage-all'
case 'push':
return (
busyAction === 'push' ||
busyAction === 'force-push' ||
busyAction === 'commit-push' ||
busyAction === 'push-create-pr'
)
case 'pull':
return busyAction === 'pull'
case 'sync':
return busyAction === 'sync' || busyAction === 'commit-sync'
case 'publish':
return busyAction === 'publish'
}
}
async function runMobilePrimaryAction(
decision: MobileSourceControlPrimaryActionDecision,
handlers: MobileSourceControlPrimaryActionHandlers
): Promise<void> {
switch (decision.kind) {
case 'commit':
await handlers.commit()
return
case 'stage':
await handlers.stageAll()
return
case 'push': {
const params = decision.requiresForceWithLease ? { forceWithLease: true } : undefined
await handlers.runActionSheetGitSequence(
decision.requiresForceWithLease ? 'force-push' : 'push',
[{ method: 'git.push', params }]
)
return
}
case 'pull':
await handlers.runActionSheetGitSequence('pull', [{ method: 'git.pull' }])
return
case 'sync':
await handlers.runActionSheetGitSync()
return
case 'publish':
await handlers.runActionSheetGitSequence('publish', [
{ method: 'git.push', params: { publish: true } }
])
return
}
}
@@ -7,6 +7,7 @@ import type { MobilePrPrefill } from './mobile-pr-create'
import { useMobileGitRequests } from './use-mobile-git-requests'
import { useMobileSourceControlLoaders } from './use-mobile-source-control-loaders'
import { useMobileSourceControlOpeners } from './use-mobile-source-control-openers'
import { buildMobileSourceControlPrimaryAction } from './mobile-source-control-primary-action'
import { useMobileSourceControlRunners } from './use-mobile-source-control-runners'
import type { RuntimeGitLocalBranches } from '../../../src/shared/runtime-types'
import {
@@ -100,8 +101,9 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
const onShow = Keyboard.addListener(showEvent, (event) => {
const height = event.endCoordinates.height - (Platform.OS === 'ios' ? insets.bottom : 0)
setKeyboardLift(Math.max(0, height))
// Why: iOS keyboard height already describes the obscured screen area.
// Subtracting the safe-area inset lets the commit bar tuck under the keyboard.
setKeyboardLift(Math.max(0, event.endCoordinates.height))
})
const onHide = Keyboard.addListener(hideEvent, () => setKeyboardLift(0))
@@ -109,7 +111,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
onShow.remove()
onHide.remove()
}
}, [insets.bottom])
}, [])
const status = screenState.kind === 'ready' ? screenState.status : null
const entries = status?.entries ?? []
@@ -157,6 +159,10 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
const unstageablePaths = useMemo(() => getUnstageablePaths(entries), [entries])
const stagedCount = useMemo(() => countStagedEntries(entries), [entries])
const unstagedCount = useMemo(() => countUnstagedEntries(entries), [entries])
const hasUnresolvedConflicts = useMemo(
() => entries.some((entry) => entry.conflictStatus === 'unresolved'),
[entries]
)
const branchLabel = formatBranchLabel(status?.branch, status?.head)
const upstream = status?.upstreamStatus
const upstreamKnown = upstream !== undefined
@@ -200,6 +206,43 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
setPrPrefill,
setShowPrSheet
})
const primaryAction = useMemo(
() =>
buildMobileSourceControlPrimaryAction({
status,
hasUnresolvedConflicts,
stageablePaths,
stagedCount,
unstagedCount,
commitMessage,
busyAction,
openingPath,
openingBranchPath,
branchCompareResult,
handlers: {
commit: runners.commit,
stageAll: runners.stageAll,
runActionSheetGitSequence: runners.runActionSheetGitSequence,
runActionSheetGitSync: runners.runActionSheetGitSync
}
}),
[
branchCompareResult,
busyAction,
commitMessage,
hasUnresolvedConflicts,
openingBranchPath,
openingPath,
runners.commit,
runners.runActionSheetGitSequence,
runners.runActionSheetGitSync,
runners.stageAll,
stageablePaths,
stagedCount,
status,
unstagedCount
]
)
return {
client,
@@ -253,6 +296,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
upstream,
upstreamKnown,
syncLabel,
primaryAction,
// actions
loadStatus,
openFile,
+4
View File
@@ -11,6 +11,7 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [
'grok',
'copilot',
'opencode',
'mimo-code',
'ante',
'pi',
'omp',
@@ -47,6 +48,7 @@ export const MOBILE_TUI_AGENT_LABELS: Record<TuiAgent, string> = {
grok: 'Grok',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
'mimo-code': 'MiMo Code',
ante: 'Ante',
pi: 'Pi',
omp: 'OMP',
@@ -80,6 +82,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
grok: 'x.ai',
copilot: 'github.com',
opencode: 'opencode.ai',
'mimo-code': 'mimo.xiaomi.com',
ante: 'antigma.ai',
omp: 'omp.sh',
gemini: 'gemini.google.com',
@@ -114,6 +117,7 @@ export const MOBILE_TUI_AGENT_LAUNCH_COMMANDS: Record<TuiAgent, string> = {
grok: 'grok',
copilot: 'copilot',
opencode: 'opencode',
'mimo-code': 'mimo',
ante: 'ante',
pi: 'pi',
omp: 'omp',
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
describe('terminal iOS IME keyboard', () => {
it('does not force terminal inputs onto the ASCII-only iOS keyboard', () => {
expect(sessionRouteSource).not.toContain("'ascii-capable'")
expect(sessionRouteSource).not.toContain('"ascii-capable"')
})
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
expect(sessionRouteSource).not.toContain('textContentType="none"')
expect(sessionRouteSource).toContain('autoComplete="off"')
})
})
@@ -14,9 +14,9 @@ describe('terminal keyboard type', () => {
expect(getTerminalCommandKeyboardType('android', true)).toBe('default')
})
it('keeps the iOS ASCII keyboard when terminal autocomplete is disabled', () => {
expect(getTerminalLiveInputKeyboardType('ios')).toBe('ascii-capable')
expect(getTerminalCommandKeyboardType('ios', false)).toBe('ascii-capable')
it('keeps iOS IME keyboards available for terminal input', () => {
expect(getTerminalLiveInputKeyboardType('ios')).toBe('default')
expect(getTerminalCommandKeyboardType('ios', false)).toBe('default')
expect(getTerminalCommandKeyboardType('ios', true)).toBe('default')
})
})
+8 -11
View File
@@ -1,20 +1,17 @@
export type TerminalKeyboardPlatform = 'android' | 'ios' | 'web' | 'windows' | 'macos'
export type TerminalKeyboardType = 'ascii-capable' | 'default'
export type TerminalKeyboardType = 'default'
// Why: default keyboards keep non-Latin IMEs selectable; ASCII-only keyboards hide them.
// Parameters stay for call-site stability while autocomplete no longer changes the keyboard.
export function getTerminalLiveInputKeyboardType(
platform: TerminalKeyboardPlatform
_platform: TerminalKeyboardPlatform
): TerminalKeyboardType {
// Why: Android CJK IMEs need the normal system keyboard; password-style
// input types suppress composition and break Chinese terminal input.
return platform === 'ios' ? 'ascii-capable' : 'default'
return 'default'
}
export function getTerminalCommandKeyboardType(
platform: TerminalKeyboardPlatform,
autocompleteEnabled: boolean
_platform: TerminalKeyboardPlatform,
_autocompleteEnabled: boolean
): TerminalKeyboardType {
if (autocompleteEnabled) {
return 'default'
}
return platform === 'ios' ? 'ascii-capable' : 'default'
return 'default'
}
+12 -1
View File
@@ -224,6 +224,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
var userScale = 1;
var BASE_FONT_PX = 13;
var MIN_FONT_PX = 6;
var MIN_FIT_COLS = 20;
var currentTextScale = 1;
var TEXT_SCALE_PRESETS = ${JSON.stringify([...TERMINAL_TEXT_SCALES])};
var MIN_TEXT_SCALE = TEXT_SCALE_PRESETS[0];
@@ -255,7 +256,8 @@ export const XTERM_HTML = `<!DOCTYPE html>
var cellW = getCellWidth();
var cellH = getCellHeight();
if (cellW > 0 && cellH > 0) {
var cols = Math.max(1, Math.floor(window.innerWidth / cellW));
var cols = Math.floor(window.innerWidth / cellW);
if (cols < MIN_FIT_COLS) return;
var rows = Math.max(8, Math.floor(window.innerHeight / cellH));
term.resize(cols, rows);
}
@@ -844,6 +846,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
? containerHeightPx
: window.innerHeight;
var cols = Math.floor(vpWidth / cellWidth);
if (cols < MIN_FIT_COLS) {
flog('measure-skip-small-width', {
vpWidth: vpWidth,
cellWidth: cellWidth,
cols: cols
});
notify({ type: 'measure-result', cols: null, rows: null });
return;
}
// Why: the rows we report become the PTY's actual row count after the
// server fits to viewport, and xterm renders exactly that many lines
// anchored top-left of the WebView. Subtracting rows here would leave
@@ -49,6 +49,13 @@ describe('terminal WebView reflow', () => {
expect(handleSource).toContain("postMessage({ type: 'reflow', cols, rows })")
})
it('does not locally resize hidden WebViews to a one-column grid', () => {
expect(htmlSource).toContain('var MIN_FIT_COLS = 20;')
expect(htmlSource).toContain('if (cols < MIN_FIT_COLS) return;')
expect(htmlSource).toContain("flog('measure-skip-small-width'")
expect(htmlSource).toContain("notify({ type: 'measure-result', cols: null, rows: null });")
})
// Why: the raw-source assertions above pass even if the reflow module is
// dropped from the XTERM_HTML concatenation (a broken/removed import or an
// emptied TERMINAL_REFLOW_JS leaves the `${...}` placeholder in the template
+2 -1
View File
@@ -80,7 +80,8 @@ export function agentIdentityLabel(agentType: string | null): string {
copilot: 'CP',
amp: 'AM',
aider: 'AI',
opencode: 'OC'
opencode: 'OC',
'mimo-code': 'MC'
}
return known[normalized] ?? normalized.slice(0, 2).toUpperCase()
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
import type { Worktree } from './workspace-list-sections'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
return {
workspaceKind: 'git',
worktreeId: 'worktree',
repoId: 'repo-1',
repo: 'orca',
branch: 'feature/mobile-parity',
displayName: 'worktree',
path: '/tmp/worktree',
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null,
status: 'inactive',
agents: [],
...overrides
}
}
describe('applyMobileWorkspaceLineage', () => {
it('places visible children directly under their parent', () => {
const parent = worktree({ worktreeId: 'parent', displayName: 'parent' })
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent'
})
const rows = applyMobileWorkspaceLineage([child, parent])
expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child'])
expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1])
expect(rows[0]?.lineageChildCount).toBe(1)
})
it('supports nested lineage chains', () => {
const parent = worktree({ worktreeId: 'parent' })
const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
const grandchild = worktree({ worktreeId: 'grandchild', parentWorktreeId: 'child' })
const rows = applyMobileWorkspaceLineage([grandchild, child, parent])
expect(rows.map((row) => row.worktreeId)).toEqual(['parent', 'child', 'grandchild'])
expect(rows.map((row) => row.lineageDepth)).toEqual([0, 1, 2])
})
it('collapses descendants under lineage parents', () => {
const parent = worktree({ worktreeId: 'parent' })
const child = worktree({ worktreeId: 'child', parentWorktreeId: 'parent' })
const rows = applyMobileWorkspaceLineage([child, parent], new Set(['workspace-lineage:parent']))
expect(rows.map((row) => row.worktreeId)).toEqual(['parent'])
expect(rows[0]?.lineageChildCount).toBe(1)
expect(rows[0]?.lineageCollapsed).toBe(true)
})
it('keeps rows visible when lineage is cyclic', () => {
const first = worktree({ worktreeId: 'first', parentWorktreeId: 'second' })
const second = worktree({ worktreeId: 'second', parentWorktreeId: 'first' })
const rows = applyMobileWorkspaceLineage([first, second])
expect(rows.map((row) => row.worktreeId).sort()).toEqual(['first', 'second'])
})
})
@@ -0,0 +1,74 @@
import type { Worktree } from './workspace-list-types'
export function getMobileWorkspaceLineageGroupKey(worktreeId: string): string {
return `workspace-lineage:${encodeURIComponent(worktreeId)}`
}
export function applyMobileWorkspaceLineage(
worktrees: readonly Worktree[],
collapsedGroups: ReadonlySet<string> = new Set()
): Worktree[] {
const visibleIds = new Set(worktrees.map((worktree) => worktree.worktreeId))
const childrenByParentId = new Map<string, Worktree[]>()
const childIds = new Set<string>()
for (const worktree of worktrees) {
const parentId = worktree.parentWorktreeId
if (!parentId || parentId === worktree.worktreeId || !visibleIds.has(parentId)) {
continue
}
childIds.add(worktree.worktreeId)
const children = childrenByParentId.get(parentId) ?? []
children.push(worktree)
childrenByParentId.set(parentId, children)
}
const result: Worktree[] = []
const emitted = new Set<string>()
const markDescendantsEmitted = (worktree: Worktree): void => {
for (const child of childrenByParentId.get(worktree.worktreeId) ?? []) {
if (!emitted.has(child.worktreeId)) {
emitted.add(child.worktreeId)
markDescendantsEmitted(child)
}
}
}
const emit = (worktree: Worktree, depth: number, isLastChild: boolean): void => {
if (emitted.has(worktree.worktreeId)) {
return
}
const children = childrenByParentId.get(worktree.worktreeId) ?? []
const lineageCollapsed =
children.length > 0 &&
collapsedGroups.has(getMobileWorkspaceLineageGroupKey(worktree.worktreeId))
emitted.add(worktree.worktreeId)
result.push({
...worktree,
lineageDepth: depth,
lineageChildCount: children.length,
lineageCollapsed,
isLastLineageChild: isLastChild
})
if (lineageCollapsed) {
markDescendantsEmitted(worktree)
return
}
children.forEach((child, index) => {
emit(child, depth + 1, index === children.length - 1)
})
}
const roots = worktrees.filter((worktree) => !childIds.has(worktree.worktreeId))
roots.forEach((worktree, index) => {
emit(worktree, 0, index === roots.length - 1)
})
for (const worktree of worktrees) {
if (!emitted.has(worktree.worktreeId)) {
// Why: malformed cyclic lineage should not hide every participant.
emit(worktree, 0, true)
}
}
return result
}
@@ -0,0 +1,42 @@
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
export const DEFAULT_MOBILE_WORKSPACE_STATUS_ID = 'in-progress'
export const DEFAULT_MOBILE_WORKSPACE_STATUSES = [
{ id: 'completed', label: 'Done', color: 'conductor-done', icon: 'conductor-done' },
{ id: 'in-review', label: 'In review', color: 'conductor-review', icon: 'conductor-review' },
{
id: DEFAULT_MOBILE_WORKSPACE_STATUS_ID,
label: 'In progress',
color: 'conductor-progress',
icon: 'conductor-progress'
},
{ id: 'todo', label: 'Todo', color: 'neutral', icon: 'circle' }
] as const satisfies readonly WorkspaceStatusDefinition[]
export function coerceMobileWorkspaceStatuses(
statuses: readonly WorkspaceStatusDefinition[]
): readonly WorkspaceStatusDefinition[] {
return statuses.length > 0 ? statuses : DEFAULT_MOBILE_WORKSPACE_STATUSES
}
export function getMobileWorkspaceStatus(
worktree: { workspaceStatus?: string | null },
statuses: readonly WorkspaceStatusDefinition[]
): string {
const availableStatuses = coerceMobileWorkspaceStatuses(statuses)
if (
worktree.workspaceStatus &&
availableStatuses.some((status) => status.id === worktree.workspaceStatus)
) {
return worktree.workspaceStatus
}
if (availableStatuses.some((status) => status.id === DEFAULT_MOBILE_WORKSPACE_STATUS_ID)) {
return DEFAULT_MOBILE_WORKSPACE_STATUS_ID
}
return availableStatuses[0]?.id ?? DEFAULT_MOBILE_WORKSPACE_STATUS_ID
}
export function getMobileWorkspaceStatusGroupKey(status: string): string {
return `workspace-status:${encodeURIComponent(status)}`
}
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(new URL('../../app/h/[hostId]/index.tsx', 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('mobile worktree activation', () => {
it('opens mobile sessions without foregrounding other paired clients', () => {
const openSession = sliceBetween(
'const openWorktreeSession = useCallback(',
'const handleSortChange = useCallback'
)
expect(openSession).toContain("sendRequest('worktree.activate'")
expect(openSession).toContain('notifyClients: false')
})
})
+19 -4
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
import {
buildSections,
@@ -123,6 +124,7 @@ export function useWorkspaceSections(args: {
collapsedGroups: Set<string>
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
}): {
sections: Section[]
rawSections: Section[]
@@ -141,7 +143,8 @@ export function useWorkspaceSections(args: {
repoColorsByName,
collapsedGroups,
workspaceHostScope,
visibleWorkspaceHostIds
visibleWorkspaceHostIds,
workspaceStatuses
} = args
const visibleRepoIdsByName = useMemo(() => {
@@ -189,16 +192,28 @@ export function useWorkspaceSections(args: {
search,
groupMode,
pinnedIds,
visibleRepoIdsByName
visibleRepoIdsByName,
workspaceStatuses,
collapsedGroups
),
[hostScopedWorktrees, sortMode, filters, search, groupMode, pinnedIds, visibleRepoIdsByName]
[
hostScopedWorktrees,
sortMode,
filters,
search,
groupMode,
pinnedIds,
visibleRepoIdsByName,
workspaceStatuses,
collapsedGroups
]
)
const sections = useMemo(
() =>
rawSections.map((s) => ({
...s,
data: collapsedGroups.has(s.title) ? [] : s.data
data: collapsedGroups.has(s.key) ? [] : s.data
})),
[rawSections, collapsedGroups]
)
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { WORKSPACE_SORT_OPTIONS } from './workspace-list-picker-options'
describe('WORKSPACE_SORT_OPTIONS', () => {
it('keeps the persisted sort values stable for desktop compatibility', () => {
expect(WORKSPACE_SORT_OPTIONS.map((option) => option.value)).toEqual([
'smart',
'name',
'recent',
'repo',
'manual'
])
})
it('keeps the smart sort value while showing the agent activity label', () => {
expect(WORKSPACE_SORT_OPTIONS.find((option) => option.value === 'smart')).toEqual({
value: 'smart',
label: 'Agent activity',
subtitle: 'Agents that need attention, then recent activity'
})
})
})
@@ -2,7 +2,12 @@ import type { PickerOption } from '../components/PickerModal'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
export const WORKSPACE_SORT_OPTIONS: PickerOption<MobileSortMode>[] = [
{ value: 'smart', label: 'Smart', subtitle: 'Unread and active first' },
// Why: desktop and persisted state keep the `smart` key, while mobile shows the product label.
{
value: 'smart',
label: 'Agent activity',
subtitle: 'Agents that need attention, then recent activity'
},
{ value: 'name', label: 'Name', subtitle: 'Alphabetical by name' },
{ value: 'recent', label: 'Recent', subtitle: 'Most recent output first' },
{ value: 'repo', label: 'Repo', subtitle: 'Repository, then workspace name' },
@@ -2,6 +2,7 @@ import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import type { Worktree } from './workspace-list-sections'
import { buildSections, filterWorktrees, getWorktreeStatus } from './workspace-list-sections'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
function worktree(overrides: Partial<Worktree> = {}): Worktree {
const worktreePath = join('/tmp', 'orca', 'worktrees', 'feature')
@@ -25,6 +26,22 @@ function worktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
function withoutSectionListKeys(sections: ReturnType<typeof buildSections>) {
return sections.map((section) => ({
...section,
data: section.data.map(
({
sectionListKey: _sectionListKey,
lineageDepth: _lineageDepth,
lineageChildCount: _lineageChildCount,
lineageCollapsed: _lineageCollapsed,
isLastLineageChild: _isLastLineageChild,
...item
}) => item
)
}))
}
describe('filterWorktrees', () => {
it('hides archived worktrees', () => {
const visible = worktree({ worktreeId: 'visible' })
@@ -128,6 +145,29 @@ describe('getWorktreeStatus', () => {
})
describe('buildSections', () => {
it('uses desktop manual order ranks in Manual sort mode', () => {
const low = worktree({ worktreeId: 'low', displayName: 'low', manualOrder: 10 })
const high = worktree({ worktreeId: 'high', displayName: 'high', manualOrder: 30 })
const fallback = worktree({ worktreeId: 'fallback', displayName: 'fallback', sortOrder: 20 })
const sections = buildSections(
[low, high, fallback],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual([
'high',
'fallback',
'low'
])
})
it('renders empty repo sections from repo placeholders in repo grouping', () => {
const sections = buildSections(
[worktree({ repoId: 'repo-1', repo: 'orca' })],
@@ -142,9 +182,13 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([
{ title: 'orca', data: [worktree({ repoId: 'repo-1', repo: 'orca' })] },
{ title: 'zoom-img', data: [] }
expect(withoutSectionListKeys(sections)).toEqual([
{
key: 'repo:repo-1',
title: 'orca',
data: [worktree({ repoId: 'repo-1', repo: 'orca' })]
},
{ key: 'repo:repo-missing', title: 'zoom-img', data: [] }
])
})
@@ -159,7 +203,7 @@ describe('buildSections', () => {
new Map([['zoom-img', 'repo-missing']])
)
expect(sections).toEqual([])
expect(withoutSectionListKeys(sections)).toEqual([])
})
it('applies repo filters and search to empty repo sections', () => {
@@ -181,7 +225,9 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([{ title: 'zoom-img', data: [] }])
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'repo:repo-matching', title: 'zoom-img', data: [] }
])
})
it('does not add an empty repo section when all of its worktrees are filtered out', () => {
@@ -203,6 +249,142 @@ describe('buildSections', () => {
])
)
expect(sections).toEqual([{ title: 'empty-repo', data: [] }])
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'repo:repo-empty', title: 'empty-repo', data: [] }
])
})
it('groups by desktop workspace status labels and order', () => {
const review = worktree({
worktreeId: 'review',
workspaceStatus: 'in-review',
status: 'active'
})
const progress = worktree({
worktreeId: 'progress',
workspaceStatus: 'in-progress',
status: 'working'
})
const sections = buildSections(
[progress, review],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
{ key: 'workspace-status:in-review', title: 'In review' },
{ key: 'workspace-status:in-progress', title: 'In progress' }
])
})
it('falls back to the default status catalog when desktop sends none', () => {
const progress = worktree({
worktreeId: 'progress',
workspaceStatus: 'in-progress',
status: 'working'
})
const sections = buildSections(
[progress],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
[]
)
expect(sections.map((section) => ({ key: section.key, title: section.title }))).toEqual([
{ key: 'workspace-status:in-progress', title: 'In progress' }
])
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['progress'])
})
it('does not duplicate pinned worktrees in their canonical status group', () => {
const pinned = worktree({
worktreeId: 'pinned',
workspaceStatus: 'in-progress',
isPinned: true
})
const sections = buildSections(
[pinned],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(withoutSectionListKeys(sections)).toEqual([
{ key: 'pinned', title: 'Pinned', icon: 'pin', data: [pinned] }
])
})
it('nests child workspaces under visible parents in grouped sections', () => {
const parent = worktree({
worktreeId: 'parent',
displayName: 'parent',
workspaceStatus: 'in-progress'
})
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent',
workspaceStatus: 'in-progress'
})
const sections = buildSections(
[child, parent],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent', 'child'])
expect(sections[0]?.data.map((worktree) => worktree.lineageDepth)).toEqual([0, 1])
})
it('collapses child workspaces under lineage parent rows', () => {
const parent = worktree({
worktreeId: 'parent',
displayName: 'parent',
workspaceStatus: 'in-progress'
})
const child = worktree({
worktreeId: 'child',
displayName: 'child',
parentWorktreeId: 'parent',
workspaceStatus: 'in-progress'
})
const sections = buildSections(
[child, parent],
'manual',
{ filterRepoIds: new Set(), hideSleeping: false, hideDefaultBranch: false },
'',
'workspaceStatus',
new Set(),
new Map(),
DEFAULT_MOBILE_WORKSPACE_STATUSES,
new Set(['workspace-lineage:parent'])
)
expect(sections[0]?.data.map((worktree) => worktree.worktreeId)).toEqual(['parent'])
expect(sections[0]?.data[0]?.lineageChildCount).toBe(1)
expect(sections[0]?.data[0]?.lineageCollapsed).toBe(true)
})
})
+94 -112
View File
@@ -1,54 +1,36 @@
// Pure data transforms for the host workspaces list: status derivation,
// filtering, sorting, and grouping into SectionList sections. Kept out of the
// screen component so the screen stays under its line cap and the logic is
// unit-testable in isolation.
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import {
DEFAULT_MOBILE_WORKSPACE_STATUSES,
coerceMobileWorkspaceStatuses,
getMobileWorkspaceStatus,
getMobileWorkspaceStatusGroupKey
} from './mobile-workspace-statuses'
import { applyMobileWorkspaceLineage } from './mobile-workspace-lineage'
import { getPRGroupKey, PR_GROUP_LABELS, PR_GROUP_ORDER } from './workspace-pr-status-groups'
import type { FilterState, Section, Worktree } from './workspace-list-types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
export type Worktree = {
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
repoId: string
repo: string
branch: string
displayName: string
// Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
// marine-creature fallback dedupes against the actual filesystem basenames
// (matching the desktop's collision check), not against displayName which
// the user may have renamed.
path: string
isArchived?: boolean
isMainWorktree?: boolean
hasHostSidebarActivity?: boolean
liveTerminalCount: number
hasAttachedPty: boolean
preview: string
unread: boolean
lastOutputAt?: number
isPinned: boolean
isActive?: boolean
linkedPR: { number: number; state: string } | null
linkedIssue?: number | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
agents?: RuntimeWorktreeAgentRow[]
}
export type { FilterState, Section, Worktree } from './workspace-list-types'
// Desktop's filter model (shared via PersistedUIState): repos selected by id,
// plus two hide toggles. There is no "active only" — hideSleeping is the
// inverse intent.
export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
function makeSection(
key: string,
title: string,
data: Worktree[],
icon?: 'pin',
collapsedGroups?: ReadonlySet<string>
): Section {
const rows = collapsedGroups ? applyMobileWorkspaceLineage(data, collapsedGroups) : data
return {
key,
title,
...(icon ? { icon } : {}),
data: rows.map((worktree) => ({
...worktree,
sectionListKey: `${key}:${worktree.worktreeId}`
}))
}
}
export type Section = { title: string; icon?: 'pin'; data: Worktree[] }
export function getWorktreeStatus(
w: Worktree
): 'working' | 'active' | 'permission' | 'done' | 'inactive' {
@@ -104,26 +86,28 @@ function isDefaultBranchWorkspace(w: Worktree): boolean {
return branch === 'main' || branch === 'master'
}
export const WORKSPACE_STATUS_LABELS: Record<ReturnType<typeof getWorktreeStatus>, string> = {
permission: 'Needs Permission',
working: 'Working',
done: 'Done',
active: 'Active',
inactive: 'Inactive'
function getManualSortRank(worktree: Worktree): number | null {
const rank = worktree.manualOrder ?? worktree.sortOrder
return typeof rank === 'number' && Number.isFinite(rank) ? rank : null
}
export const WORKSPACE_STATUS_ORDER: ReturnType<typeof getWorktreeStatus>[] = [
'permission',
'working',
'done',
'active',
'inactive'
]
export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Worktree[] {
// 'manual' keeps the server (worktree.ps) order untouched.
if (mode === 'manual') {
return worktrees
return [...worktrees].sort((a, b) => {
const aRank = getManualSortRank(a)
const bRank = getManualSortRank(b)
if (aRank !== null && bRank !== null && aRank !== bRank) {
// Why: desktop assigns higher sort/manual ranks to earlier list positions.
return bRank - aRank
}
if (aRank !== null && bRank === null) {
return -1
}
if (aRank === null && bRank !== null) {
return 1
}
return 0
})
}
return [...worktrees].sort((a, b) => {
if (mode === 'name') {
@@ -136,7 +120,6 @@ export function sortWorktrees(worktrees: Worktree[], mode: MobileSortMode): Work
const repoComparison = a.repo.localeCompare(b.repo, undefined, { sensitivity: 'base' })
return repoComparison || (a.displayName || a.repo).localeCompare(b.displayName || b.repo)
}
// 'smart' — attention-first
if (a.unread !== b.unread) {
return a.unread ? -1 : 1
}
@@ -180,36 +163,6 @@ export function filterWorktrees(
return result
}
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// no PR/draft/unknown → "In Progress", open → "In Review", merged → "Done", closed → "Closed"
type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
const PR_GROUP_LABELS: Record<PRGroupKey, string> = {
done: 'Done',
'in-review': 'In Review',
'in-progress': 'In Progress',
closed: 'Closed'
}
const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
function getPRGroupKey(w: Worktree): PRGroupKey {
if (!w.linkedPR) {
return 'in-progress'
}
const s = w.linkedPR.state.toLowerCase()
if (s === 'merged') {
return 'done'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'draft') {
return 'in-progress'
}
return 'in-review'
}
export function isWorktreePinned(w: Worktree, localPins: Set<string>): boolean {
return w.isPinned || localPins.has(w.worktreeId)
}
@@ -221,33 +174,44 @@ export function buildSections(
search: string,
groupMode: MobileGroupMode,
pinnedIds: Set<string>,
repoIdsByName: ReadonlyMap<string, string> = new Map()
repoIdsByName: ReadonlyMap<string, string> = new Map(),
workspaceStatuses: readonly WorkspaceStatusDefinition[] = DEFAULT_MOBILE_WORKSPACE_STATUSES,
collapsedGroups: ReadonlySet<string> = new Set()
): Section[] {
const filtered = filterWorktrees(worktrees, filters, search)
const sorted = sortWorktrees(filtered, sortMode)
const pinned = sorted.filter((w) => isWorktreePinned(w, pinnedIds))
const unpinned = sorted.filter((w) => !isWorktreePinned(w, pinnedIds))
const active = unpinned.filter(isWorktreeActive)
const inactive = unpinned.filter((w) => !isWorktreeActive(w))
// Why: mobile shows pinned workspaces once in the pinned section; duplicating
// them in status/project sections makes the phone list harder to scan.
const canonicalGroupWorktrees = unpinned
const active = canonicalGroupWorktrees.filter(isWorktreeActive)
const inactive = canonicalGroupWorktrees.filter((w) => !isWorktreeActive(w))
const sections: Section[] = []
if (pinned.length > 0) {
sections.push({ title: 'Pinned', icon: 'pin', data: pinned })
sections.push(makeSection('pinned', 'Pinned', pinned, 'pin'))
}
if (groupMode === 'none') {
if (active.length > 0) {
// Why: without explicit grouping, mobile's primary workflow is jumping
// back into running sessions before browsing the full worktree archive.
sections.push({ title: 'Active', data: active })
sections.push(makeSection('all-active', 'Active', active, undefined, collapsedGroups))
}
if (inactive.length > 0) {
sections.push({ title: pinned.length > 0 || active.length > 0 ? 'All' : '', data: inactive })
sections.push(
makeSection(
'all',
pinned.length > 0 || active.length > 0 ? 'All' : '',
inactive,
undefined,
collapsedGroups
)
)
}
} else if (groupMode === 'repo') {
const byRepo = new Map<string, Worktree[]>()
for (const w of unpinned) {
for (const w of canonicalGroupWorktrees) {
const key = w.repo || 'Unknown'
const list = byRepo.get(key)
if (list) {
@@ -273,12 +237,14 @@ export function buildSections(
}
}
for (const [repo, items] of byRepo) {
sections.push({ title: repo, data: items })
const key = `repo:${repoIdsByName.get(repo) ?? repo}`
sections.push(makeSection(key, repo, items, undefined, collapsedGroups))
}
} else if (groupMode === 'workspaceStatus') {
const byStatus = new Map<ReturnType<typeof getWorktreeStatus>, Worktree[]>()
for (const w of unpinned) {
const key = getWorktreeStatus(w)
const renderableWorkspaceStatuses = coerceMobileWorkspaceStatuses(workspaceStatuses)
const byStatus = new Map<string, Worktree[]>()
for (const w of canonicalGroupWorktrees) {
const key = getMobileWorkspaceStatus(w, renderableWorkspaceStatuses)
const list = byStatus.get(key)
if (list) {
list.push(w)
@@ -286,15 +252,23 @@ export function buildSections(
byStatus.set(key, [w])
}
}
for (const status of WORKSPACE_STATUS_ORDER) {
const items = byStatus.get(status)
for (const status of renderableWorkspaceStatuses) {
const items = byStatus.get(status.id)
if (items && items.length > 0) {
sections.push({ title: WORKSPACE_STATUS_LABELS[status], data: items })
sections.push(
makeSection(
getMobileWorkspaceStatusGroupKey(status.id),
status.label,
items,
undefined,
collapsedGroups
)
)
}
}
} else if (groupMode === 'prStatus') {
const byGroup = new Map<PRGroupKey, Worktree[]>()
for (const w of unpinned) {
const byGroup = new Map<string, Worktree[]>()
for (const w of canonicalGroupWorktrees) {
const key = getPRGroupKey(w)
const list = byGroup.get(key)
if (list) {
@@ -306,7 +280,15 @@ export function buildSections(
for (const groupKey of PR_GROUP_ORDER) {
const items = byGroup.get(groupKey)
if (items && items.length > 0) {
sections.push({ title: PR_GROUP_LABELS[groupKey], data: items })
sections.push(
makeSection(
`pr:${groupKey}`,
PR_GROUP_LABELS[groupKey],
items,
undefined,
collapsedGroups
)
)
}
}
}
@@ -0,0 +1,49 @@
import type { RuntimeWorktreeAgentRow } from '../../../src/shared/runtime-types'
export type Worktree = {
sectionListKey?: string
workspaceKind?: 'git' | 'folder-workspace'
worktreeId: string
repoId: string
repo: string
branch: string
displayName: string
workspaceStatus?: string
sortOrder?: number
manualOrder?: number
// Why: on-disk worktree directory path. Needed by NewWorktreeModal so the
// marine-creature fallback dedupes against filesystem basenames.
path: string
isArchived?: boolean
isMainWorktree?: boolean
hasHostSidebarActivity?: boolean
parentWorktreeId?: string | null
childWorktreeIds?: string[]
lineageDepth?: number
lineageChildCount?: number
lineageCollapsed?: boolean
isLastLineageChild?: boolean
liveTerminalCount: number
hasAttachedPty: boolean
preview: string
unread: boolean
lastOutputAt?: number
isPinned: boolean
isActive?: boolean
linkedPR: { number: number; state: string } | null
linkedIssue?: number | null
linkedLinearIssue?: string | null
linkedGitLabMR?: number | null
linkedGitLabIssue?: number | null
comment?: string
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
agents?: RuntimeWorktreeAgentRow[]
}
export type FilterState = {
filterRepoIds: Set<string>
hideSleeping: boolean
hideDefaultBranch: boolean
}
export type Section = { key: string; title: string; icon?: 'pin'; data: Worktree[] }
@@ -0,0 +1,31 @@
import type { Worktree } from './workspace-list-sections'
// Why: matches desktop's PR_GROUP_META naming from worktree-list-groups.ts.
// no PR/draft/unknown -> "In Progress", open -> "In Review", merged -> "Done", closed -> "Closed"
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
export const PR_GROUP_LABELS: Record<PRGroupKey, string> = {
done: 'Done',
'in-review': 'In Review',
'in-progress': 'In Progress',
closed: 'Closed'
}
export const PR_GROUP_ORDER: PRGroupKey[] = ['done', 'in-review', 'in-progress', 'closed']
export function getPRGroupKey(w: Worktree): PRGroupKey {
if (!w.linkedPR) {
return 'in-progress'
}
const s = w.linkedPR.state.toLowerCase()
if (s === 'merged') {
return 'done'
}
if (s === 'closed') {
return 'closed'
}
if (s === 'draft') {
return 'in-progress'
}
return 'in-review'
}
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_MOBILE_WORKSPACE_STATUSES } from './mobile-workspace-statuses'
import {
applyDesktopViewSettings,
groupModeFromDesktop,
@@ -13,7 +14,8 @@ const base: MobileViewState = {
hideSleeping: false,
hideDefaultBranch: false,
filterRepoIds: [],
collapsedGroups: []
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
}
describe('group mode mapping', () => {
@@ -47,12 +49,10 @@ describe('applyDesktopViewSettings', () => {
filterRepoIds: ['repo-1']
})
expect(next).toEqual({
...base,
groupMode: 'prStatus',
sortMode: 'recent', // unchanged (sortBy absent)
hideSleeping: true,
hideDefaultBranch: false, // unchanged
filterRepoIds: ['repo-1'],
collapsedGroups: []
filterRepoIds: ['repo-1']
})
})
@@ -60,6 +60,12 @@ describe('applyDesktopViewSettings', () => {
expect(applyDesktopViewSettings(base, {})).toEqual(base)
})
it('keeps renderable workspace statuses when desktop sends an empty catalog', () => {
const next = applyDesktopViewSettings(base, { workspaceStatuses: [] })
expect(next.workspaceStatuses).toBe(DEFAULT_MOBILE_WORKSPACE_STATUSES)
})
it('preserves current host visibility when desktop omits host fields', () => {
const current: MobileViewState = {
...base,
+12 -1
View File
@@ -3,6 +3,9 @@
// RPCs). Keeping these settings in the same global store is what lets a grouping
// or filter change on the phone show up on desktop and vice-versa.
import type { WorkspaceStatusDefinition } from '../../../src/shared/types'
import { coerceMobileWorkspaceStatuses } from './mobile-workspace-statuses'
export type MobileGroupMode = 'none' | 'workspaceStatus' | 'repo' | 'prStatus'
// Desktop sort adds 'manual'; mobile renders it but sorts by server order.
export type MobileSortMode = 'smart' | 'name' | 'recent' | 'repo' | 'manual'
@@ -17,6 +20,7 @@ export type WorkspaceViewSettings = {
collapsedGroups?: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses?: WorkspaceStatusDefinition[]
}
const GROUP_TO_DESKTOP: Record<MobileGroupMode, NonNullable<WorkspaceViewSettings['groupBy']>> = {
@@ -62,6 +66,7 @@ export type MobileViewState = {
collapsedGroups: string[]
workspaceHostScope?: string
visibleWorkspaceHostIds?: string[] | null
workspaceStatuses: readonly WorkspaceStatusDefinition[]
}
// Apply a desktop PersistedUIState onto the local view state, leaving any field
@@ -72,13 +77,19 @@ export function applyDesktopViewSettings(
): MobileViewState {
const groupMode = groupModeFromDesktop(settings.groupBy)
const sortMode = sortModeFromDesktop(settings.sortBy)
// Why: a partially hydrated desktop settings payload may carry an empty
// status catalog; mobile must keep renderable groups instead of hiding rows.
const workspaceStatuses = settings.workspaceStatuses
? coerceMobileWorkspaceStatuses(settings.workspaceStatuses)
: current.workspaceStatuses
const next: MobileViewState = {
groupMode: groupMode ?? current.groupMode,
sortMode: sortMode ?? current.sortMode,
hideSleeping: settings.hideSleepingWorkspaces ?? current.hideSleeping,
hideDefaultBranch: settings.hideDefaultBranchWorkspace ?? current.hideDefaultBranch,
filterRepoIds: settings.filterRepoIds ?? current.filterRepoIds,
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups
collapsedGroups: settings.collapsedGroups ?? current.collapsedGroups,
workspaceStatuses
}
if (settings.workspaceHostScope !== undefined) {
next.workspaceHostScope = settings.workspaceHostScope
@@ -29,6 +29,7 @@ function worktree(overrides: Partial<Worktree> = {}): Worktree {
repo: 'orca',
branch: 'feature/mobile-lag',
displayName: 'manta',
workspaceStatus: 'in-progress',
path: worktreePath,
liveTerminalCount: 1,
hasAttachedPty: true,
@@ -77,6 +78,37 @@ describe('areWorktreeListsEqual', () => {
)
})
it('detects workspace status changes', () => {
expect(
areWorktreeListsEqual(
[worktree({ workspaceStatus: 'in-progress' })],
[worktree({ workspaceStatus: 'in-review' })]
)
).toBe(false)
})
it('detects manual order changes', () => {
expect(
areWorktreeListsEqual(
[worktree({ manualOrder: 10, sortOrder: 1 })],
[worktree({ manualOrder: 20, sortOrder: 1 })]
)
).toBe(false)
})
it('detects lineage changes', () => {
const base = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-a' })
const changedParent = worktree({ worktreeId: 'child', parentWorktreeId: 'parent-b' })
const changedChildren = worktree({
worktreeId: 'child',
parentWorktreeId: 'parent-a',
childWorktreeIds: ['grandchild']
})
expect(areWorktreeListsEqual([base], [changedParent])).toBe(false)
expect(areWorktreeListsEqual([base], [changedChildren])).toBe(false)
})
it('detects agent status changes', () => {
const first = [worktree({ agents: [agent({ state: 'working' })] })]
const second = [worktree({ agents: [agent({ state: 'waiting' })] })]
@@ -26,10 +26,15 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
left.repo === right.repo &&
left.branch === right.branch &&
left.displayName === right.displayName &&
(left.workspaceStatus ?? null) === (right.workspaceStatus ?? null) &&
(left.sortOrder ?? null) === (right.sortOrder ?? null) &&
(left.manualOrder ?? null) === (right.manualOrder ?? null) &&
left.path === right.path &&
(left.isArchived ?? false) === (right.isArchived ?? false) &&
(left.isMainWorktree ?? false) === (right.isMainWorktree ?? false) &&
(left.hasHostSidebarActivity ?? false) === (right.hasHostSidebarActivity ?? false) &&
(left.parentWorktreeId ?? null) === (right.parentWorktreeId ?? null) &&
areStringArraysEqual(left.childWorktreeIds ?? [], right.childWorktreeIds ?? []) &&
left.liveTerminalCount === right.liveTerminalCount &&
left.hasAttachedPty === right.hasAttachedPty &&
left.preview === right.preview &&
@@ -48,6 +53,21 @@ function areWorktreesEqual(left: Worktree, right: Worktree): boolean {
)
}
function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean {
if (left === right) {
return true
}
if (left.length !== right.length) {
return false
}
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) {
return false
}
}
return true
}
function arePullRequestsEqual(left: Worktree['linkedPR'], right: Worktree['linkedPR']): boolean {
if (left === right) {
return true
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vitest/config'
import { fileURLToPath } from 'node:url'
const tsconfigRaw = JSON.stringify({
compilerOptions: {
jsx: 'react-jsx',
module: 'esnext',
moduleResolution: 'bundler',
strict: true,
target: 'es2022'
}
})
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
esbuild: {
tsconfigRaw
},
optimizeDeps: {
esbuildOptions: {
tsconfigRaw
}
},
test: {
environment: 'node',
include: ['src/**/*.test.ts']
}
})
@@ -1508,6 +1508,7 @@ private final class TreeRenderer {
var focusedElementId: Int?
var truncated = false
var maxDepthReached = false
private let reader = AXSnapshotReader()
private var nextIndex = 0
static let maxNodes = 1200
static let maxDepth = 64
@@ -1530,22 +1531,26 @@ private final class TreeRenderer {
}
guard !ancestors.contains(where: { CFEqual($0, element) }) else { return }
let role = stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
let children = primaryChildren(element, role: role, windowBounds: windowBounds)
let value = valueString(element)
let placeholder = placeholderString(element)
let rawActions = actions(element)
let rowSummary = rowTextSummary(element, role: role)
let linkText = role == "AXLink" ? descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
let role = reader.stringAttribute(element, kAXRoleAttribute as String) ?? "AXUnknown"
let children = reader.primaryChildren(element, role: role, windowBounds: windowBounds)
let value = reader.valueString(element, role: role)
let placeholder = reader.placeholderString(element)
let rawActions = reader.actions(element)
let rowSummary = reader.rowTextSummary(element, role: role)
let roleDescription = reader.stringAttribute(element, kAXRoleDescriptionAttribute as String)
let title = reader.stringAttribute(element, kAXTitleAttribute as String)
let label = reader.stringAttribute(element, kAXDescriptionAttribute as String)
let url = reader.stringAttribute(element, kAXURLAttribute as String)
let linkText = role == "AXLink" ? reader.descendantTextSnippets(element, limit: 2, maxDepth: 3).first : nil
let baseNode = SnapshotRenderNode(
role: role,
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
title: stringAttribute(element, kAXTitleAttribute as String),
label: stringAttribute(element, kAXDescriptionAttribute as String),
roleDescription: roleDescription,
title: title,
label: label,
linkText: linkText,
value: value,
placeholder: placeholder,
url: stringAttribute(element, kAXURLAttribute as String),
url: url,
traits: [],
rawActions: rawActions,
childCount: children.count,
@@ -1553,19 +1558,19 @@ private final class TreeRenderer {
)
let name = SnapshotRenderHeuristics.displayName(baseNode)
let meaningful = SnapshotRenderHeuristics.meaningfulActions(rawActions, role: role)
let localFrame = frame(element, windowBounds: windowBounds)
let traits = traitsFor(element, role: role)
let webAreaDepth = webAreaDepth(role: role, ancestors: ancestors)
let summary = genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
let localFrame = reader.frame(element, windowBounds: windowBounds)
let traits = reader.traitsFor(element, role: role)
let webAreaDepth = reader.webAreaDepth(role: role, ancestors: ancestors)
let summary = reader.genericTextSummary(element, role: role, name: name, actions: meaningful, traits: traits)
let node = SnapshotRenderNode(
role: role,
roleDescription: stringAttribute(element, kAXRoleDescriptionAttribute as String),
title: stringAttribute(element, kAXTitleAttribute as String),
label: stringAttribute(element, kAXDescriptionAttribute as String),
roleDescription: roleDescription,
title: title,
label: label,
linkText: linkText,
value: value,
placeholder: placeholder,
url: stringAttribute(element, kAXURLAttribute as String),
url: url,
traits: traits,
rawActions: rawActions,
childCount: children.count,
@@ -1617,6 +1622,23 @@ private final class TreeRenderer {
}
}
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
let childNodes = children.map { child in
let role = reader.stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
return SnapshotRenderNode(
role: role,
roleDescription: reader.stringAttribute(child, kAXRoleDescriptionAttribute as String),
title: reader.stringAttribute(child, kAXTitleAttribute as String),
label: reader.stringAttribute(child, kAXDescriptionAttribute as String),
value: reader.valueString(child, role: role),
traits: reader.traitsFor(child, role: role)
)
}
// Why: browsers expose every open tab through AX; retaining only the active
// tab keeps snapshots focused on the current page instead of stale tab titles.
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
}
private func compactRenderedBrowserTabs(parent: SnapshotRenderNode, startLine: Int, depth: Int) {
guard SnapshotRenderHeuristics.roleText(parent) == "scroll area" else { return }
let indent = String(repeating: "\t", count: depth)
@@ -1647,44 +1669,301 @@ private final class TreeRenderer {
}
}
private func valueString(_ element: AXUIElement) -> String? {
if isSecureTextElement(element) {
return "[redacted]"
private final class AXSnapshotReader {
private enum CachedAttribute {
case missing
case found(CFTypeRef)
}
if let string = stringAttribute(element, kAXValueAttribute as String) {
return string
private final class ElementCache {
let element: AXUIElement
var loadedAttributeNames = false
var advertisedAttributes: Set<String>?
var attributes: [String: CachedAttribute] = [:]
var actions: [String]?
var settable: [String: Bool] = [:]
init(element: AXUIElement) {
self.element = element
}
}
if let number = numberAttribute(element, kAXValueAttribute as String) {
return number.stringValue
private var elementsByHash: [CFHashCode: [ElementCache]] = [:]
func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
guard let value = copyAttribute(element, attribute) else { return nil }
if CFGetTypeID(value) == CFStringGetTypeID(), let string = value as? String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
if CFGetTypeID(value) == CFURLGetTypeID(), let url = value as? URL {
return url.absoluteString
}
return nil
}
return nil
}
private func isSecureTextElement(_ element: AXUIElement) -> Bool {
let role = stringAttribute(element, kAXRoleAttribute as String)?.lowercased() ?? ""
let subrole = stringAttribute(element, kAXSubroleAttribute as String)?.lowercased() ?? ""
let title = stringAttribute(element, kAXTitleAttribute as String)?.lowercased() ?? ""
let description = stringAttribute(element, kAXDescriptionAttribute as String)?.lowercased() ?? ""
let placeholder = placeholderString(element)?.lowercased() ?? ""
let haystack = [role, subrole, title, description, placeholder].joined(separator: " ")
return haystack.contains("secure") ||
haystack.contains("password") ||
haystack.contains("passcode") ||
haystack.contains("verification code") ||
haystack.contains("one-time code")
}
func boolAttribute(_ element: AXUIElement, _ attribute: String) -> Bool? {
copyAttribute(element, attribute) as? Bool
}
private func placeholderString(_ element: AXUIElement) -> String? {
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
}
func numberAttribute(_ element: AXUIElement, _ attribute: String) -> NSNumber? {
copyAttribute(element, attribute) as? NSNumber
}
private func traitsFor(_ element: AXUIElement, role: String) -> [String] {
var traits: [String] = []
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
return traits
func copyArray(_ element: AXUIElement, _ attribute: String) -> [AXUIElement]? {
copyAttribute(element, attribute) as? [AXUIElement]
}
func actions(_ element: AXUIElement) -> [String] {
let cache = cache(for: element)
if let actions = cache.actions {
return actions
}
var value: CFArray?
let actions = AXUIElementCopyActionNames(element, &value) == .success ? value as? [String] ?? [] : []
cache.actions = actions
return actions
}
func isSettable(_ element: AXUIElement, _ attribute: String) -> Bool {
let cache = cache(for: element)
if let cached = cache.settable[attribute] {
return cached
}
var settable = DarwinBoolean(false)
let value = AXUIElementIsAttributeSettable(element, attribute as CFString, &settable) == .success && settable.boolValue
cache.settable[attribute] = value
return value
}
func frame(_ element: AXUIElement, windowBounds: CGRect) -> CGRect? {
guard let absolute = absoluteFrame(element) else { return nil }
return CGRect(
x: absolute.minX - windowBounds.minX,
y: absolute.minY - windowBounds.minY,
width: absolute.width,
height: absolute.height
)
}
func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
return visibleRows(rows, parent: element, windowBounds: windowBounds)
}
return copyArray(element, kAXChildrenAttribute as String) ?? []
}
func valueString(_ element: AXUIElement, role: String) -> String? {
if isSecureTextElement(element, role: role) {
return "[redacted]"
}
if let string = stringAttribute(element, kAXValueAttribute as String) {
return string
}
if let number = numberAttribute(element, kAXValueAttribute as String) {
return number.stringValue
}
return nil
}
func placeholderString(_ element: AXUIElement) -> String? {
stringAttribute(element, "AXPlaceholderValue") ?? stringAttribute(element, "AXPlaceholder")
}
func traitsFor(_ element: AXUIElement, role: String) -> [String] {
var traits: [String] = []
if boolAttribute(element, kAXSelectedAttribute as String) == true { traits.append("selected") }
if boolAttribute(element, kAXExpandedAttribute as String) == true { traits.append("expanded") }
if boolAttribute(element, kAXEnabledAttribute as String) == false { traits.append("disabled") }
if valueSettableRoles.contains(role), isSettable(element, kAXValueAttribute as String) { traits.append("settable") }
return traits
}
func genericTextSummary(
_ element: AXUIElement,
role: String,
name: String?,
actions: [String],
traits: [String]
) -> String? {
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
name == nil,
actions.isEmpty,
traits.isEmpty,
isPlainTextSubtree(element, maxDepth: 4)
else {
return nil
}
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
guard texts.count >= 2 else { return nil }
let summary = texts.joined(separator: " ")
guard summary.count <= 220 else { return nil }
return summary
}
func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
guard !texts.isEmpty else { return nil }
return texts.joined(separator: " ")
}
func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
var values: [String] = []
var seen = Set<String>()
func collect(_ node: AXUIElement, depth: Int) {
guard values.count < limit, depth <= maxDepth else { return }
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
if role == kAXStaticTextRole as String || role == "AXLink" {
for candidate in [
stringAttribute(node, kAXValueAttribute as String),
stringAttribute(node, kAXTitleAttribute as String),
stringAttribute(node, kAXDescriptionAttribute as String),
] {
guard let candidate else { continue }
let text = preview(candidate, maxLength: 80)
guard !text.isEmpty, seen.insert(text).inserted else { continue }
values.append(text)
if values.count >= limit { return }
}
}
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
collect(child, depth: depth + 1)
if values.count >= limit { return }
}
}
collect(element, depth: 0)
return values
}
func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
if role == "AXWebArea" { return 0 }
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
return nil
}
return ancestors.count - index
}
private func copyAttribute(_ element: AXUIElement, _ attribute: String) -> CFTypeRef? {
let cache = cache(for: element)
if let cached = cache.attributes[attribute] {
switch cached {
case .missing:
return nil
case let .found(value):
return value
}
}
if let advertisedAttributes = advertisedAttributes(cache),
!SnapshotRenderHeuristics.supportsAttribute(attribute, advertisedAttributes: advertisedAttributes) {
cache.attributes[attribute] = .missing
return nil
}
var value: CFTypeRef?
guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, let value else {
cache.attributes[attribute] = .missing
return nil
}
cache.attributes[attribute] = .found(value)
return value
}
private func advertisedAttributes(_ cache: ElementCache) -> Set<String>? {
if cache.loadedAttributeNames {
return cache.advertisedAttributes
}
cache.loadedAttributeNames = true
var value: CFArray?
guard AXUIElementCopyAttributeNames(cache.element, &value) == .success, let attributes = value as? [String] else {
return nil
}
cache.advertisedAttributes = Set(attributes)
return cache.advertisedAttributes
}
private func absoluteFrame(_ element: AXUIElement) -> CGRect? {
guard let positionValue = copyAttribute(element, kAXPositionAttribute as String),
let sizeValue = copyAttribute(element, kAXSizeAttribute as String)
else {
return nil
}
var point = CGPoint.zero
var size = CGSize.zero
guard AXValueGetValue(positionValue as! AXValue, .cgPoint, &point),
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
else {
return nil
}
return CGRect(origin: point, size: size)
}
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
return Array(rows.prefix(20))
}
let visible = rows.filter { row in
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
return rowFrame.intersects(parentFrame)
}
return Array((visible.isEmpty ? rows : visible).prefix(20))
}
private func isSecureTextElement(_ element: AXUIElement, role: String) -> Bool {
guard SnapshotRenderHeuristics.shouldProbeSecureTextMetadata(role: role) else {
return false
}
let haystack = [
role,
stringAttribute(element, kAXSubroleAttribute as String) ?? "",
stringAttribute(element, kAXTitleAttribute as String) ?? "",
stringAttribute(element, kAXDescriptionAttribute as String) ?? "",
placeholderString(element) ?? "",
].joined(separator: " ").lowercased()
return haystack.contains("secure") ||
haystack.contains("password") ||
haystack.contains("passcode") ||
haystack.contains("verification code") ||
haystack.contains("one-time code")
}
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
var sawText = false
let allowedContainerRoles: Set<String> = [
kAXGroupRole as String,
kAXUnknownRole as String,
kAXStaticTextRole as String,
"AXLink",
"AXImage",
]
func visit(_ node: AXUIElement, depth: Int) -> Bool {
guard depth <= maxDepth else { return false }
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
guard allowedContainerRoles.contains(role) else { return false }
if role == kAXStaticTextRole as String || role == "AXLink" {
sawText = true
}
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
guard visit(child, depth: depth + 1) else { return false }
}
return true
}
return visit(element, depth: 0) && sawText
}
private func cache(for element: AXUIElement) -> ElementCache {
let hash = CFHash(element)
if let cache = elementsByHash[hash]?.first(where: { CFEqual($0.element, element) }) {
return cache
}
let cache = ElementCache(element: element)
elementsByHash[hash, default: []].append(cache)
return cache
}
}
private let valueSettableRoles: Set<String> = [
@@ -1697,24 +1976,6 @@ private let valueSettableRoles: Set<String> = [
kAXTextFieldRole as String,
]
private func primaryChildren(_ element: AXUIElement, role: String, windowBounds: CGRect) -> [AXUIElement] {
if usesRowsAsPrimaryChildren(role: role), let rows = copyArray(element, kAXRowsAttribute as String), !rows.isEmpty {
return visibleRows(rows, parent: element, windowBounds: windowBounds)
}
return copyArray(element, kAXChildrenAttribute as String) ?? []
}
private func visibleRows(_ rows: [AXUIElement], parent: AXUIElement, windowBounds: CGRect) -> [AXUIElement] {
guard let parentFrame = frame(parent, windowBounds: windowBounds) else {
return Array(rows.prefix(20))
}
let visible = rows.filter { row in
guard let rowFrame = frame(row, windowBounds: windowBounds) else { return false }
return rowFrame.intersects(parentFrame)
}
return Array((visible.isEmpty ? rows : visible).prefix(20))
}
private func usesRowsAsPrimaryChildren(role: String) -> Bool {
[
kAXBrowserRole as String,
@@ -1724,23 +1985,6 @@ private func usesRowsAsPrimaryChildren(role: String) -> Bool {
].contains(role)
}
private func tabStripCompaction(parent: SnapshotRenderNode, children: [AXUIElement]) -> SnapshotTabStripCompaction? {
let childNodes = children.map { child in
let role = stringAttribute(child, kAXRoleAttribute as String) ?? "AXUnknown"
return SnapshotRenderNode(
role: role,
roleDescription: stringAttribute(child, kAXRoleDescriptionAttribute as String),
title: stringAttribute(child, kAXTitleAttribute as String),
label: stringAttribute(child, kAXDescriptionAttribute as String),
value: valueString(child),
traits: traitsFor(child, role: role)
)
}
// Why: browsers expose every open tab through AX; retaining only the active
// tab keeps snapshots focused on the current page instead of stale tab titles.
return SnapshotRenderHeuristics.tabStripCompaction(parent: parent, children: childNodes)
}
private func isDirectRenderedBrowserTabLine(_ line: String, indent: String) -> Bool {
guard line.hasPrefix(indent), !line.dropFirst(indent.count).hasPrefix("\t") else {
return false
@@ -1761,100 +2005,6 @@ private func renderedElementIndex(_ line: String, indent: String) -> Int? {
return Int(digits)
}
private func genericTextSummary(
_ element: AXUIElement,
role: String,
name: String?,
actions: [String],
traits: [String]
) -> String? {
guard (role == kAXGroupRole as String || role == kAXUnknownRole as String),
name == nil,
actions.isEmpty,
traits.isEmpty,
isPlainTextSubtree(element, maxDepth: 4)
else {
return nil
}
let texts = descendantTextSnippets(element, limit: 8, maxDepth: 4)
guard texts.count >= 2 else { return nil }
let summary = texts.joined(separator: " ")
guard summary.count <= 220 else { return nil }
return summary
}
private func rowTextSummary(_ element: AXUIElement, role: String) -> String? {
guard ["AXRow", "AXCell", "AXOutlineRow"].contains(role) else { return nil }
let texts = descendantTextSnippets(element, limit: 6, maxDepth: 3)
guard !texts.isEmpty else { return nil }
return texts.joined(separator: " ")
}
private func isPlainTextSubtree(_ element: AXUIElement, maxDepth: Int) -> Bool {
var sawText = false
let allowedContainerRoles: Set<String> = [
kAXGroupRole as String,
kAXUnknownRole as String,
kAXStaticTextRole as String,
"AXLink",
"AXImage",
]
func visit(_ node: AXUIElement, depth: Int) -> Bool {
guard depth <= maxDepth else { return false }
let role = stringAttribute(node, kAXRoleAttribute as String) ?? "AXUnknown"
guard allowedContainerRoles.contains(role) else { return false }
if role == kAXStaticTextRole as String || role == "AXLink" {
sawText = true
}
guard SnapshotRenderHeuristics.meaningfulActions(actions(node), role: role).isEmpty else { return false }
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
guard visit(child, depth: depth + 1) else { return false }
}
return true
}
return visit(element, depth: 0) && sawText
}
private func descendantTextSnippets(_ element: AXUIElement, limit: Int, maxDepth: Int) -> [String] {
var values: [String] = []
var seen = Set<String>()
func collect(_ node: AXUIElement, depth: Int) {
guard values.count < limit, depth <= maxDepth else { return }
let role = stringAttribute(node, kAXRoleAttribute as String) ?? ""
if role == kAXStaticTextRole as String || role == "AXLink" {
for candidate in [
stringAttribute(node, kAXValueAttribute as String),
stringAttribute(node, kAXTitleAttribute as String),
stringAttribute(node, kAXDescriptionAttribute as String),
] {
guard let candidate else { continue }
let text = preview(candidate, maxLength: 80)
guard !text.isEmpty, seen.insert(text).inserted else { continue }
values.append(text)
if values.count >= limit { return }
}
}
for child in copyArray(node, kAXChildrenAttribute as String) ?? [] {
collect(child, depth: depth + 1)
if values.count >= limit { return }
}
}
collect(element, depth: 0)
return values
}
private func webAreaDepth(role: String, ancestors: [AXUIElement]) -> Int? {
if role == "AXWebArea" { return 0 }
guard let index = ancestors.firstIndex(where: { stringAttribute($0, kAXRoleAttribute as String) == "AXWebArea" }) else {
return nil
}
return ancestors.count - index
}
private func sanitize(_ value: String) -> String {
value.replacingOccurrences(of: "\n", with: " ").replacingOccurrences(of: "\r", with: " ")
}
@@ -60,6 +60,20 @@ public struct SnapshotTabStripCompaction: Equatable {
}
public enum SnapshotRenderHeuristics {
public static func shouldProbeSecureTextMetadata(role: String) -> Bool {
let normalized = role.lowercased()
return normalized.contains("text") ||
normalized.contains("field") ||
normalized.contains("password") ||
normalized.contains("search") ||
normalized.contains("combo")
}
public static func supportsAttribute(_ attribute: String, advertisedAttributes: Set<String>?) -> Bool {
guard let advertisedAttributes else { return true }
return advertisedAttributes.contains(attribute)
}
public static func displayName(_ node: SnapshotRenderNode) -> String? {
if let title = clean(node.title) {
return title

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