Merge remote-tracking branch 'origin/main' into brennanb2025/perf-sc-virtualization

This commit is contained in:
Jinjing
2026-07-09 22:04:27 -07:00
932 changed files with 82177 additions and 8289 deletions
+13
View File
@@ -10,7 +10,14 @@ on:
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
# Node, so any change to the daemon bundle graph or the main build must
# re-run it (the v1.4.129-rc.1 daemon outage shipped with green CI).
- 'electron.vite.config.ts'
- 'build-plugins/**'
- 'src/main/daemon/**'
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
@@ -98,6 +105,12 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
- if: runner.os == 'Linux'
env:
ORCA_COMPUTER_E2E: '1'
@@ -0,0 +1,97 @@
name: Daemon Relocation Spike
# Why: Phase 1 of the Windows update-survival work relocates the terminal
# daemon's file closure out of the install dir so it survives an update. Before
# implementing it in the app, this spike finds the MINIMAL set of packaged files
# that a copied Orca.exe (run as node) needs to start the daemon and drive a
# ConPTY session from a relocated directory, holding no install-dir file locks.
# Runs from the feature branch via push (workflow_dispatch only works on the
# default branch); main is never touched.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/daemon-relocation-spike/**'
- '.github/workflows/daemon-relocation-spike.yml'
workflow_dispatch: {}
permissions:
contents: read
concurrency:
group: daemon-relocation-spike-${{ github.ref }}
cancel-in-progress: true
jobs:
spike:
name: relocation file-set spike
runs-on: windows-2022
timeout-minutes: 40
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo to build/run the spike; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the unpacked build keyed on the inputs that affect it, so
# spike-script-only edits skip the ~15 min electron-builder build.
- name: Cache unpacked build
id: cache-unpacked
uses: actions/cache@v4
with:
path: dist/win-unpacked
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build unpacked app
if: steps.cache-unpacked.outputs.cache-hit != 'true'
run: pnpm run build:unpack
- name: Run relocation spike (all tiers)
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$log = "artifacts/spike-output.log"
$tiers = @('full', 'no-gpu', 'minimal')
$any = $false
foreach ($tier in $tiers) {
"===== TIER: $tier =====" | Tee-Object -FilePath $log -Append
$work = Join-Path $env:RUNNER_TEMP "spike-$tier"
# --keep-work-dir so the per-tier daemon stdout/stderr logs survive
# for the artifact upload (the spike otherwise removes work-dir),
# which is what diagnoses a tier that fails to reach ready.
node tools/daemon-relocation-spike/spike.mjs `
--app-dir dist/win-unpacked `
--work-dir "$work" `
--tier $tier `
--keep-work-dir 2>&1 | Tee-Object -FilePath $log -Append
if ($LASTEXITCODE -eq 0) { $any = $true }
}
if (-not $any) { throw "No tier passed the relocation spike" }
- name: Upload spike output
if: always()
uses: actions/upload-artifact@v7
with:
name: daemon-relocation-spike-output
path: |
artifacts/spike-output.log
${{ runner.temp }}/spike-*/**/*.log
retention-days: 7
if-no-files-found: warn
+330 -1
View File
@@ -719,6 +719,10 @@ jobs:
~/.cache/electron-builder
runs-on: ${{ matrix.os }}
# Why: hosted runners hard-cap jobs at 6h; the Windows SignPath waits
# (1h inner + 4h installer) are budgeted to fit under this with the
# build itself, so a slow approval can't kill the job mid-flow.
timeout-minutes: 360
permissions:
actions: read
@@ -958,6 +962,244 @@ jobs:
}
}
# ── Windows inner-binary signing (issue #7785) ─────────────────────
# Why: SignPath cannot deep-sign inside NSIS installers, so inner PE
# files (Orca.exe, node-pty *.node, DLLs) are signed via a separate zip
# request, then the installer is rebuilt from the signed tree before the
# existing installer signing request below. Every step in this chain is
# fail-open (continue-on-error + outcome gating): any failure ships the
# original installer with unsigned inner binaries, exactly like releases
# did before this chain existed. Rehearsed end to end in run 28988432001
# (.github/workflows/windows-signing-rehearsal.yml).
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe) must keep their signer.
- name: Stage unsigned inner PE files for signing
id: stage-inner
if: matrix.platform == 'win'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
if: matrix.platform == 'win' && steps.stage-inner.outcome == 'success'
continue-on-error: true
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ needs.cut.outputs.tag }}
path: signing-stage/**
if-no-files-found: error
- name: Submit inner binaries signing request
id: submit-inner-signing
if: matrix.platform == 'win' && steps.upload-unsigned-inner.outcome == 'success'
continue-on-error: true
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: release-signing
artifact-configuration-slug: windows-inner-binaries-zip
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Notify Slack that inner-binary signing is waiting for approval
id: notify-inner-signing
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
SIGNPATH_ORGANIZATION_ID: c37aa192-a27a-4377-9c90-5d6c95912dc0
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
SIGNPATH_REQUEST_URL: ${{ steps.submit-inner-signing.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
}
$requestUrl = $env:SIGNPATH_REQUEST_URL
if ([string]::IsNullOrWhiteSpace($requestUrl)) {
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG inner-binaries signing request (1 of 2) is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@{
type = 'section'
text = @{
type = 'mrkdwn'
text = $message
}
}
)
} | ConvertTo-Json -Depth 5
Invoke-RestMethod -Method Post -Uri $env:SLACK_WEBHOOK_URL -ContentType 'application/json' -Body $payload
# Why gate on the notify outcome too: if nobody was told to approve,
# don't hold the release for the approval window — fall through and
# ship like today instead. The 1h wait (vs the installer's 4h) keeps
# both waits plus the build inside the 360-minute job cap; missing it
# falls through to today's unsigned-inner flow rather than blocking.
- name: Download signed inner binaries from SignPath
id: download-signed-inner
if: matrix.platform == 'win' && steps.submit-inner-signing.outcome == 'success' && steps.notify-inner-signing.outcome == 'success'
continue-on-error: true
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-OrganizationId c37aa192-a27a-4377-9c90-5d6c95912dc0 `
-ApiToken $env:SIGNPATH_API_TOKEN `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly (into fail-open) instead of silently
# shipping a mix of signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
id: restore-signed-inner
if: matrix.platform == 'win' && steps.download-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
# Why this step exists: electron-builder's CopyElevateHelper re-copies a
# pristine elevate.exe from its download cache over resources\elevate.exe
# on EVERY nsis pack — including the --prepackaged rebuild below — which
# clobbered the SignPath signature in v1.4.129-rc.4. There is no supported
# way to disable just the copy, so we overwrite the cache's copy with our
# signed one (identical bytes plus signature) so the clobber becomes a
# no-op. Known quirk: the cache persists across releases via actions/cache,
# so later runs may see elevate.exe as already signed and skip staging it —
# that is fine (the signature is timestamped) and the evidence gate checks
# elevate.exe in the shipped installer unconditionally. If this ever causes
# trouble, delete this step; the only effect is elevate.exe shipping
# unsigned again, which the evidence gate will flag.
- name: Replace cached elevate.exe with the signed copy
id: sign-elevate-cache
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
$signed = 'dist/win-unpacked/resources/elevate.exe'
if (-not (Test-Path $signed)) {
Write-Host '::warning::No elevate.exe in win-unpacked resources; nothing to protect from the rebuild clobber.'
exit 0
}
$signature = Get-AuthenticodeSignature -FilePath $signed
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
Write-Host "::warning::win-unpacked elevate.exe is not SignPath-signed ($($signature.Status), $subject); skipping cache swap."
exit 0
}
$cached = @(Get-ChildItem "$env:LOCALAPPDATA\electron-builder\Cache\nsis" -Recurse -Filter elevate.exe -ErrorAction SilentlyContinue)
if ($cached.Count -eq 0) {
Write-Host '::warning::No cached elevate.exe found (electron-builder cache layout changed?); the rebuild will pack the unsigned copy and the evidence gate will flag it.'
exit 0
}
foreach ($file in $cached) {
Copy-Item -Path $signed -Destination $file.FullName -Force
Write-Host "Replaced $($file.FullName) with the SignPath-signed copy."
}
- name: Rebuild NSIS installer from signed unpacked app
id: rebuild-nsis-signed
if: matrix.platform == 'win' && steps.restore-signed-inner.outcome == 'success'
continue-on-error: true
shell: pwsh
run: |
# Why: keep the pre-rebuild artifacts so a failed rebuild can fall
# back to shipping them unchanged (fail-open).
New-Item -ItemType Directory -Path prepack-backup -Force | Out-Null
Copy-Item 'dist/orca-windows-setup.exe' 'prepack-backup/orca-windows-setup.exe' -Force
Copy-Item 'dist/latest.yml' 'prepack-backup/latest.yml' -Force
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Roll back to original installer after failed rebuild
if: matrix.platform == 'win' && steps.rebuild-nsis-signed.outcome == 'failure'
shell: pwsh
run: |
if (Test-Path 'prepack-backup/orca-windows-setup.exe') {
Copy-Item 'prepack-backup/orca-windows-setup.exe' 'dist/orca-windows-setup.exe' -Force
Copy-Item 'prepack-backup/latest.yml' 'dist/latest.yml' -Force
Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.'
}
# ── End Windows inner-binary signing ───────────────────────────────
- name: Upload unsigned Windows installer for SignPath
if: matrix.platform == 'win'
id: upload-unsigned-windows-installer
@@ -992,6 +1234,7 @@ jobs:
SIGNPATH_REQUEST_URL: ${{ steps.submit-signing-request.outputs.signing-request-web-url }}
TAG: ${{ needs.cut.outputs.tag }}
GITHUB_RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
INNER_SIGNING_SUBMITTED: ${{ steps.submit-inner-signing.outcome == 'success' }}
run: |
if ([string]::IsNullOrWhiteSpace($env:SLACK_WEBHOOK_URL)) {
throw 'SLACK_WEBHOOK_URL secret is required so release approvers know when SignPath is waiting.'
@@ -1002,7 +1245,9 @@ jobs:
$requestUrl = "https://app.signpath.io/Web/$env:SIGNPATH_ORGANIZATION_ID/SigningRequests/$env:SIGNPATH_REQUEST_ID"
}
$message = "Orca Windows release $env:TAG is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
# Why: releases where inner signing fell through have only this one request.
$stage = if ($env:INNER_SIGNING_SUBMITTED -eq 'true') { 'installer signing request (2 of 2)' } else { 'signing request' }
$message = "Orca Windows release $env:TAG $stage is ready for SignPath approval.`n<$requestUrl|Open SignPath signing request>`n<$env:GITHUB_RUN_URL|Open GitHub Actions run>"
$payload = @{
text = $message
blocks = @(
@@ -1085,6 +1330,90 @@ jobs:
}
$signature.SignerCertificate | Format-List Subject,Issuer,NotBefore,NotAfter,Thumbprint
# Why: evidence gate for inner-binary signing (issue #7785, supersedes
# PR #7170's Orca.exe-only gate — this covers every staged .exe/.dll/.node
# by extracting the shipped installer). Warn-only until the flow has been
# proven on a real release, then flip ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED
# to 'true' so unsigned inner binaries block the release.
- name: Verify Windows inner binary signatures
if: matrix.platform == 'win'
shell: pwsh
env:
ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED: 'false'
INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }}
run: |
$required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true'
if ($env:INNER_SIGNING_COMPLETED -ne 'true') {
$message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).'
if ($required) { throw $message }
Write-Host "::warning::$message"
exit 0
}
# Why try/catch: while the gate is warn-only, even an unexpected
# script error (extraction hiccup, missing file) must not block
# the release — only the flip to required makes failures fatal.
try {
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
# Why: verify the files a user actually gets on disk, not the build
# tree — 7z parses the NSIS exe directly as its embedded payload.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null
$root = Resolve-Path 'inner-evidence-extract'
# Why elevate.exe is always appended: staging skips already-signed
# files, and the persisted electron-builder cache can carry a
# previously signed elevate.exe — so it may be absent from the list
# in some runs, yet it is the file most at risk of losing its
# signature in the NSIS rebuild. Verify it in every release.
$targets = @(Get-Content 'inner-signing-list.txt')
if ($targets -notcontains 'resources\elevate.exe') {
$targets += 'resources\elevate.exe'
}
foreach ($relative in $targets) {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $relative, $subject
$report.Add($line)
Write-Host $line
if ($signature.Status -ne 'Valid' -or $subject -notlike '*CN=SignPath Foundation*') {
$failures.Add("not signed by SignPath Foundation: $relative ($($signature.Status), $subject)")
}
}
Set-Content -Path 'inner-signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::warning::$_" }
$message = "Windows inner-binary evidence gate found $($failures.Count) problems."
if ($required) { throw $message }
Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'."
} else {
Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation."
}
} catch {
if ($required) { throw }
Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)."
}
- name: Upload Windows inner signing evidence
if: always() && matrix.platform == 'win'
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-signing-evidence-${{ needs.cut.outputs.tag }}
path: |
inner-signing-evidence.txt
inner-signing-list.txt
if-no-files-found: ignore
retention-days: 30
- name: Publish signed Windows release artifacts
if: matrix.platform == 'win'
uses: nick-fields/retry@v4
+145
View File
@@ -0,0 +1,145 @@
name: Windows Update-Survival E2E
# Why: proves what happens to terminal sessions across a real Windows app update
# by installing one released version, updating to another, and asserting on the
# packaged artifacts (daemon survival, terminal interactivity, zero console
# flashes). This MUST run on a disposable CI Windows: electron-builder's oneClick
# installer uninstalls the registry-registered copy of the app before every
# install, so running the harness on a machine with a real Orca install would
# delete it. A fresh runner has no Orca registered, so it is the only safe home
# for install-based testing. Manual dispatch only.
# Why: workflow_dispatch only works once a workflow is on the default branch.
# To exercise this from the unmerged feature branch WITHOUT touching main, a
# push trigger scoped to that exact branch runs the workflow from the branch's
# own tree. The paths filter keeps it to harness/workflow edits so ordinary
# commits don't spend 30 min of Windows runner time. Push runs have no inputs,
# so every parameter falls back to a default below.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'tools/win-update-e2e/**'
- '.github/workflows/win-update-e2e.yml'
workflow_dispatch:
inputs:
from_tag:
description: Release tag to install first (e.g. v1.4.124-rc.8)
required: true
type: string
to_tag:
description: Release tag to update to (e.g. v1.4.124-rc.9)
required: true
type: string
expect:
description: Expected outcome profile
required: true
type: choice
default: cold-restore
options:
- cold-restore
- survival
asset_pattern:
description: Installer asset glob
required: false
type: string
default: '*windows-setup.exe'
soak_seconds:
description: Post-relaunch console-window watch duration
required: false
type: string
default: '60'
permissions:
contents: read
# Why: cancel a superseded run when iterating — a new push to the branch makes
# the in-flight Windows job obsolete, so free the runner immediately.
concurrency:
group: win-update-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
update-e2e:
name: update ${{ inputs.from_tag || 'v1.4.124-rc.8' }} -> ${{ inputs.to_tag || 'v1.4.124-rc.9' }} (${{ inputs.expect || 'cold-restore' }})
runs-on: windows-2022
timeout-minutes: 30
env:
FROM_TAG: ${{ inputs.from_tag || 'v1.4.124-rc.8' }}
TO_TAG: ${{ inputs.to_tag || 'v1.4.124-rc.9' }}
EXPECT: ${{ inputs.expect || 'cold-restore' }}
ASSET_PATTERN: ${{ inputs.asset_pattern || '*windows-setup.exe' }}
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only reads the repo and downloads a release; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: the harness only needs its runtime deps (the Playwright Electron
# driver); it drives already-built installer artifacts, so no app build.
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: fail fast with a readable message if a tag has no matching Windows
# installer, rather than deep inside the harness.
- name: Download installers
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: pwsh
run: |
New-Item -ItemType Directory -Force artifacts/from | Out-Null
New-Item -ItemType Directory -Force artifacts/to | Out-Null
gh release download "$env:FROM_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/from
gh release download "$env:TO_TAG" --repo "${{ github.repository }}" --pattern "$env:ASSET_PATTERN" --dir artifacts/to
$from = Get-ChildItem artifacts/from -Filter *.exe | Select-Object -First 1
$to = Get-ChildItem artifacts/to -Filter *.exe | Select-Object -First 1
if (-not $from) { throw "No installer matching '$env:ASSET_PATTERN' in $env:FROM_TAG" }
if (-not $to) { throw "No installer matching '$env:ASSET_PATTERN' in $env:TO_TAG" }
"FROM_EXE=$($from.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
"TO_EXE=$($to.FullName)" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Why: no --install-dir here. A CI runner has no real Orca registered, so
# the harness installs to the default per-user location and owns it — the
# isolated-install machinery exists only to (imperfectly) protect a real
# developer install, which does not apply on a throwaway runner.
- name: Run update-survival harness
id: harness
shell: pwsh
env:
# Why: on a driving failure the harness dumps a screenshot + DOM state
# here so the actual post-onboarding UI is visible in the artifacts.
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
$log = "artifacts/harness-output.log"
node tools/win-update-e2e/run.mjs `
--from "$env:FROM_EXE" `
--to "$env:TO_EXE" `
--expect "$env:EXPECT" `
--soak-seconds "$env:SOAK_SECONDS" 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload harness output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-e2e-output
# Why: log + diagnostics only, never the multi-hundred-MB installers.
path: |
artifacts/harness-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,116 @@
name: Windows Update-Survival E2E (branch build)
# Why: proves the Phase 1 daemon-host relocation actually makes terminal
# sessions SURVIVE a Windows update. Builds an (unsigned) installer FROM THIS
# BRANCH, then runs the win-update-e2e harness with --expect survival: install,
# open a terminal, silently update over it, relaunch, and assert the daemon PID
# is unchanged and the pre-update terminal is still interactive with no console
# flashing. Runs from the feature branch via push (workflow_dispatch only works
# on the default branch); main is never touched. A CI runner is the only safe
# place to install/update — see the relocation post-mortem.
on:
push:
branches:
- Jinwoo-H/windows-update-survival
paths:
- 'src/main/daemon/**'
- 'src/main/pty/**'
- 'tools/win-update-e2e/**'
- 'config/electron-builder.config.cjs'
- '.github/workflows/win-update-survival-e2e.yml'
workflow_dispatch:
inputs:
expect:
description: Expected outcome profile
required: true
type: choice
default: survival
options:
- survival
- cold-restore
permissions:
contents: read
concurrency:
group: win-update-survival-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
survival:
name: survival (branch build over itself)
runs-on: windows-2022
timeout-minutes: 50
env:
EXPECT: ${{ inputs.expect || 'survival' }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# This job only builds and runs the harness locally; it never pushes.
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Why: cache the built installer keyed on the inputs that affect it, so a
# harness-only edit skips the ~20 min electron-builder build. The daemon
# relocation code lives under src/, so changing it correctly rebuilds.
- name: Cache branch installer
id: cache-installer
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
pnpm run build:desktop
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never
# Why: install the branch build, open a terminal, update the SAME build
# over it, and assert the relocated daemon survives. Same build on both
# sides isolates the survival mechanism (NSIS kill sweep + userData daemon
# + adoption) from cross-version staleness policy. No --install-dir: a CI
# runner has no real Orca to protect.
- name: Run survival harness
id: harness
shell: pwsh
env:
ORCA_E2E_DIAG_DIR: artifacts/diag
run: |
New-Item -ItemType Directory -Force artifacts | Out-Null
$exe = "dist/orca-windows-setup.exe"
if (-not (Test-Path $exe)) { throw "Installer not found at $exe" }
$log = "artifacts/survival-output.log"
node tools/win-update-e2e/run.mjs `
--from "$exe" `
--to "$exe" `
--expect "$env:EXPECT" `
--soak-seconds 60 2>&1 | Tee-Object -FilePath $log
exit $LASTEXITCODE
- name: Upload survival output
if: always()
uses: actions/upload-artifact@v7
with:
name: win-update-survival-output
path: |
artifacts/survival-output.log
artifacts/diag/**
retention-days: 7
if-no-files-found: warn
@@ -0,0 +1,360 @@
# Windows inner-binary signing rehearsal.
#
# Why: SignPath cannot deep-sign inside NSIS installers, so shipping signed
# inner binaries (Orca.exe, node-pty *.node, DLLs — see issue #7785) requires
# a two-request flow: sign the unpacked PE files first, then build the NSIS
# installer from the signed tree, then sign the installer. This workflow
# rehearses that entire flow from a branch, end to end, without publishing
# anything — so the release pipeline on main is never at risk while we verify.
#
# Runs only via manual dispatch. Use the test-signing policy for iteration
# (auto-approved test certificate) and release-signing to rehearse the
# production flow (requires manual SignPath approvals). Rehearsed successfully
# with test-signing (run 28987534795) and release-signing (run 28988432001).
name: Windows signing rehearsal
on:
workflow_dispatch:
inputs:
signing-policy-slug:
description: SignPath signing policy
type: choice
default: test-signing
options:
- test-signing
- release-signing
inner-artifact-configuration-slug:
description: SignPath artifact configuration for the inner-binaries zip
type: string
default: windows-inner-binaries-zip
jobs:
rehearse:
# Why: SignPath origin verification requires GitHub-hosted runners, and
# windows-2022 matches the real release job's build environment.
runs-on: windows-2022
timeout-minutes: 360
permissions:
actions: read
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: nothing here pushes; keep the token out of .git/config.
persist-credentials: false
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Cache electron-builder downloads
uses: actions/cache@v5
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-builder-win-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
electron-builder-win-
- name: Install dependencies
uses: nick-fields/retry@v4
with:
timeout_minutes: 10
max_attempts: 3
retry_wait_seconds: 30
command: pnpm install --frozen-lockfile
# Why: rehearsal builds are never published, so the official-build
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
- name: Build app
run: pnpm build:release
env:
NODE_OPTIONS: --max-old-space-size=4096
- name: Package unpacked Windows app
shell: pwsh
run: |
node config/scripts/ensure-native-runtime.mjs --runtime=electron
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --dir --publish never
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/win-unpacked/Orca.exe')) {
throw 'electron-builder --dir did not produce dist/win-unpacked/Orca.exe'
}
# Why: only unsigned PE files go to SignPath. Files that already carry a
# valid signature (Microsoft's OpenConsole.exe, signed vendor DLLs) must
# keep their original signer, so they are excluded from the request.
- name: Stage unsigned PE files for inner signing
id: stage-inner
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$stage = New-Item -ItemType Directory -Force -Path 'signing-stage'
$list = New-Object System.Collections.Generic.List[string]
$skipped = New-Object System.Collections.Generic.List[string]
Get-ChildItem -Path $root -Recurse -File |
Where-Object { $_.Extension -in '.exe', '.dll', '.node' } |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($root, $_.FullName)
$signature = Get-AuthenticodeSignature -FilePath $_.FullName
if ($signature.Status -eq 'Valid') {
$skipped.Add("$relative <already signed: $($signature.SignerCertificate.Subject)>")
return
}
$destination = Join-Path $stage.FullName $relative
New-Item -ItemType Directory -Force -Path (Split-Path $destination) | Out-Null
Copy-Item -Path $_.FullName -Destination $destination -Force
$list.Add($relative)
}
if (-not ($list -contains 'Orca.exe')) {
throw 'Orca.exe was not staged for signing; unpacked layout changed?'
}
if (-not ($list | Where-Object { $_ -like '*conpty_console_list.node' })) {
throw 'node-pty conpty_console_list.node was not staged; this is the file from issue #7785.'
}
Set-Content -Path 'inner-signing-list.txt' -Value ($list -join "`n")
Write-Host "Staged $($list.Count) unsigned PE files for signing:"
$list | ForEach-Object { Write-Host " $_" }
Write-Host "Skipped $($skipped.Count) already-signed files:"
$skipped | ForEach-Object { Write-Host " $_" }
- name: Upload unsigned inner binaries for SignPath
id: upload-unsigned-inner
uses: actions/upload-artifact@v7
with:
name: orca-windows-inner-unsigned-${{ github.run_id }}
path: signing-stage/**
if-no-files-found: error
- name: Install SignPath PowerShell module
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$useResourceGet = $null -ne (Get-Command -Name Install-PSResource -ErrorAction SilentlyContinue)
if ($useResourceGet) {
if ($null -eq (Get-PSResourceRepository -Name PSGallery -ErrorAction SilentlyContinue)) {
Register-PSResourceRepository -PSGallery -Trusted
} else {
Set-PSResourceRepository -Name PSGallery -Trusted
}
Install-PSResource -Name SignPath -Version '[4.0.0,5.0.0)' -Repository PSGallery -Scope CurrentUser -TrustRepository -Reinstall -ErrorAction Stop
} else {
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
- name: Submit inner binaries signing request
id: submit-inner-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: ${{ inputs.inner-artifact-configuration-slug || 'windows-inner-binaries-zip' }}
github-artifact-id: ${{ steps.upload-unsigned-inner.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed inner binaries from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-inner-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-inner.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 3600
New-Item -ItemType Directory -Path signed-inner -Force
Expand-Archive -Path signed-inner.zip -DestinationPath signed-inner -Force
# Why: copy back strictly by the staged list so a layout mismatch in the
# returned artifact fails loudly instead of silently shipping a mix of
# signed and unsigned binaries.
- name: Restore signed inner binaries into unpacked app
shell: pwsh
run: |
$root = Resolve-Path 'dist/win-unpacked'
$failures = New-Object System.Collections.Generic.List[string]
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$signed = Get-ChildItem -Path signed-inner -Recurse -File |
Where-Object { [System.IO.Path]::GetRelativePath((Resolve-Path 'signed-inner'), $_.FullName).TrimStart('\', '/') -like "*$relative" } |
Select-Object -First 1
if ($null -eq $signed) {
$failures.Add("missing from signed artifact: $relative")
continue
}
$signature = Get-AuthenticodeSignature -FilePath $signed.FullName
if ($null -eq $signature.SignerCertificate) {
$failures.Add("returned without a signature: $relative")
continue
}
Copy-Item -Path $signed.FullName -Destination (Join-Path $root $relative) -Force
Write-Host ("{0,-14} {1} <{2}>" -f $signature.Status, $relative, $signature.SignerCertificate.Subject)
}
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signed inner artifact did not round-trip cleanly ($($failures.Count) failures)."
}
- name: Build NSIS installer from signed unpacked app
shell: pwsh
run: |
pnpm exec electron-builder --config config/electron-builder.config.cjs --win --publish never --prepackaged "$env:GITHUB_WORKSPACE\dist\win-unpacked"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not (Test-Path 'dist/orca-windows-setup.exe')) {
throw 'electron-builder --prepackaged did not produce dist/orca-windows-setup.exe'
}
- name: Upload unsigned Windows installer for SignPath
id: upload-unsigned-installer
uses: actions/upload-artifact@v7
with:
name: orca-windows-installer-unsigned-${{ github.run_id }}
path: dist/orca-windows-setup.exe
if-no-files-found: error
- name: Submit Windows installer signing request
id: submit-installer-signing
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: c37aa192-a27a-4377-9c90-5d6c95912dc0
project-slug: orca
signing-policy-slug: ${{ inputs.signing-policy-slug || 'test-signing' }}
artifact-configuration-slug: github-actions-windows-installer
github-artifact-id: ${{ steps.upload-unsigned-installer.outputs.artifact-id }}
wait-for-completion: false
- name: Download signed Windows installer from SignPath
shell: pwsh
env:
SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }}
SIGNPATH_REQUEST_ID: ${{ steps.submit-installer-signing.outputs.signing-request-id }}
run: |
Get-SignedArtifact `
-ApiToken $env:SIGNPATH_API_TOKEN `
-OrganizationId 'c37aa192-a27a-4377-9c90-5d6c95912dc0' `
-SigningRequestId $env:SIGNPATH_REQUEST_ID `
-OutputArtifactPath signed-windows.zip `
-Force `
-WaitForCompletionTimeoutInSeconds 14400
New-Item -ItemType Directory -Path signed-windows -Force
Expand-Archive -Path signed-windows.zip -DestinationPath signed-windows -Force
# Why: signing changes installer bytes, so the updater metadata must be
# regenerated exactly like the release job's staging step does.
- name: Stage signed Windows installer and regenerate updater metadata
shell: pwsh
run: |
$signedInstaller = Get-ChildItem -Path signed-windows -Recurse -File -Filter 'orca-windows-setup.exe' | Select-Object -First 1
if ($null -eq $signedInstaller) {
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
& 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
$stream = [System.IO.File]::OpenRead($installer.FullName)
try {
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hash = [Convert]::ToBase64String($sha512.ComputeHash($stream))
} finally {
if ($null -ne $sha512) { $sha512.Dispose() }
$stream.Dispose()
}
$latestYml = Get-Content -Path 'dist/latest.yml' -Raw
$latestYml = [regex]::Replace($latestYml, '(?m)^(\s*)sha512: .+$', {
param($match)
"$($match.Groups[1].Value)sha512: $hash"
})
$latestYml = $latestYml -replace '(?m)^ size: \d+$', " size: $($installer.Length)"
$latestYml = $latestYml -replace '(?m)^ blockMapSize: \d+$', " blockMapSize: $($blockmap.Length)"
Set-Content -Path 'dist/latest.yml' -Value $latestYml -NoNewline
# Why: this is the pass/fail heart of the rehearsal — the same evidence
# check that will later gate releases (see PR #7170). Test certificates
# do not chain to a trusted root, so Status=Valid is only required for
# release-signing runs; test-signing runs require a present signature.
- name: Verify signatures end to end
shell: pwsh
env:
SIGNING_POLICY: ${{ inputs.signing-policy-slug || 'test-signing' }}
run: |
$report = New-Object System.Collections.Generic.List[string]
$failures = New-Object System.Collections.Generic.List[string]
$requireValid = $env:SIGNING_POLICY -eq 'release-signing'
function Test-Signature([string]$label, [string]$path) {
$signature = Get-AuthenticodeSignature -FilePath $path
$subject = if ($null -eq $signature.SignerCertificate) { '<none>' } else { $signature.SignerCertificate.Subject }
$line = "{0,-14} {1} <{2}>" -f $signature.Status, $label, $subject
$script:report.Add($line)
Write-Host $line
if ($null -eq $signature.SignerCertificate -or $signature.Status -eq 'NotSigned') {
$script:failures.Add("unsigned: $label")
} elseif ($script:requireValid -and $signature.Status -ne 'Valid') {
$script:failures.Add("not Valid under release-signing: $label ($($signature.Status))")
} elseif ($script:requireValid -and $subject -notlike '*CN=SignPath Foundation*') {
$script:failures.Add("unexpected signer: $label ($subject)")
}
}
Test-Signature 'installer: orca-windows-setup.exe' 'dist/orca-windows-setup.exe'
# Extract the signed installer and verify the files a user actually
# gets on disk — including the exact file from issue #7785.
$7za = 'node_modules/7zip-bin/win/x64/7za.exe'
New-Item -ItemType Directory -Path extracted-app -Force | Out-Null
& $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null
$root = Resolve-Path 'extracted-app'
foreach ($relative in Get-Content 'inner-signing-list.txt') {
$path = Join-Path $root $relative
if (-not (Test-Path $path)) {
$failures.Add("missing from installer payload: $relative")
continue
}
Test-Signature "installed: $relative" $path
}
Set-Content -Path 'signing-evidence.txt' -Value ($report -join "`n")
if ($failures.Count -gt 0) {
$failures | ForEach-Object { Write-Host "::error::$_" }
throw "Signing rehearsal failed with $($failures.Count) problems."
}
Write-Host "All $((Get-Content 'inner-signing-list.txt').Count) inner binaries plus the installer are signed."
- name: Upload rehearsal evidence and installer
if: always()
uses: actions/upload-artifact@v7
with:
name: windows-signing-rehearsal-${{ inputs.signing-policy-slug || 'test-signing' }}-${{ github.run_id }}
path: |
signing-evidence.txt
inner-signing-list.txt
dist/orca-windows-setup.exe
dist/orca-windows-setup.exe.blockmap
dist/latest.yml
retention-days: 7
+4 -3
View File
@@ -36,7 +36,7 @@
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/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.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Download APK 0.0.24](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.24/app-release.apk)
- **Android:** [Download APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -238,9 +238,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.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup 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.
+130
View File
@@ -0,0 +1,130 @@
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup'
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
// chunk gained `require("electron")` (an import edge added in #7642), and the
// daemon is forked as a plain-Node process where electron cannot be required.
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
// stayed invisible until an adopted old daemon died. This guard fails the
// build when any chunk reachable from a plain-Node fork entry requires
// electron, and smoke-loads daemon-entry under plain Node to prove its module
// graph still resolves.
// Entries executed as plain Node (ELECTRON_RUN_AS_NODE / no electron runtime):
// forked daemon, parcel-watcher and computer sidecars, and the CLI-run
// agent-hooks entry. require("electron") throws MODULE_NOT_FOUND in all of them.
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls'
] as const
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): OutputChunk[] {
const seen = new Set<string>()
const reachable: OutputChunk[] = []
const stack = [entry.fileName]
while (stack.length > 0) {
const fileName = stack.pop() as string
if (seen.has(fileName)) {
continue
}
seen.add(fileName)
const chunk = byFileName.get(fileName)
if (!chunk) {
continue
}
reachable.push(chunk)
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
stack.push(imported)
}
}
return reachable
}
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
}
}
}
// Why: proves the whole daemon-entry graph resolves under plain Node (no
// unresolved requires). require("electron") does not throw in a dev tree with
// node_modules present, so the static scan above — not this smoke — is the
// electron regression guard; this only catches gross load failures.
function smokeLoadDaemonEntry(outputDir: string): void {
const entryPath = join(outputDir, 'daemon-entry.js')
const result = spawnSync(process.execPath, [entryPath], {
encoding: 'utf8',
timeout: 15_000
})
if (result.error) {
throw new Error(
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
`${result.error.message}`
)
}
const stderr = result.stderr ?? ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[plain-node-entry-guard] daemon-entry.js did not reach argv parsing under plain Node ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
}
export function createPlainNodeEntryGuardPlugin(): Plugin {
return {
name: 'orca-plain-node-entry-guard',
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
// every rebuild, and the guard only needs to gate produced builds.
if (this.meta.watchMode) {
return
}
const chunks = Object.values(bundle).filter(
(item): item is OutputChunk => item.type === 'chunk'
)
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
const entryByName = new Map<string, OutputChunk>()
for (const chunk of chunks) {
if (chunk.isEntry && chunk.name) {
entryByName.set(chunk.name, chunk)
}
}
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
}
}
if (entryByName.has('daemon-entry') && options.dir) {
smokeLoadDaemonEntry(options.dir)
}
}
}
}
+72 -1
View File
@@ -2,6 +2,10 @@ const { chmodSync, existsSync, readdirSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const { join, resolve } = require('node:path')
const electronBuilderNativeRebuild = require('./scripts/electron-builder-native-rebuild.cjs')
const {
assertPackagedDaemonEntryExists,
verifyPackagedDaemonEntryBoots
} = require('./scripts/verify-packaged-daemon-entry.cjs')
const {
createPackagedRuntimeNodeModuleResources,
prunePackagedRuntimeNodeModules,
@@ -106,6 +110,7 @@ module.exports = {
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
@@ -129,6 +134,24 @@ module.exports = {
}
prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch)
verifyPackagedMainRuntimeDeps(resourcesDir)
// Why: boot the packaged daemon-entry under plain Node, but only for the
// slice matching the packaging host's arch — daemon-entry.js is JS, yet it
// require()s the native (N-API) node-pty for the TARGET arch, which the host
// Node cannot load cross-arch. `Arch` enum: ia32=0, x64=1, armv7l=2,
// arm64=3, universal=4 (universal contains the host slice, so run it).
const archEnumByNodeArch = { ia32: 0, x64: 1, armv7l: 2, arm64: 3 }
const hostArchEnum = archEnumByNodeArch[process.arch]
if (context.arch === hostArchEnum || context.arch === 4) {
verifyPackagedDaemonEntryBoots(resourcesDir)
} else {
// Why: a cross-arch slice can't be booted by the host Node, but the
// unpacked entry must still exist — its absence is a layout regression
// regardless of arch, so only the boot is skipped, not the check.
assertPackagedDaemonEntryExists(resourcesDir)
console.log(
`[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})`
)
}
chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName)
chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName)
for (const filename of readdirSync(resourcesDir)) {
@@ -142,6 +165,10 @@ module.exports = {
}
if (context.electronPlatformName === 'darwin') {
await signMacComputerUseHelper(join(resourcesDir, 'Orca Computer Use.app'), context.packager)
await signMacNotificationStatusHelper(
join(resourcesDir, '..', 'MacOS', 'orca-notification-status'),
context.packager
)
}
},
win: {
@@ -173,7 +200,11 @@ module.exports = {
artifactName: 'orca-windows-setup.${ext}',
shortcutName: '${productName}',
uninstallDisplayName: '${productName}',
createDesktopShortcut: 'always'
createDesktopShortcut: 'always',
// Why: on a real uninstall, stop and remove the relocated terminal daemon
// (which lives outside the install dir under LOCALAPPDATA by design). Guarded
// by ${isUpdated} inside so it never runs during an update's uninstallOldVersion.
include: resolve(__dirname, 'nsis', 'daemon-host-uninstall.nsh')
},
mac: {
icon: 'resources/build/icon.icns',
@@ -229,6 +260,15 @@ module.exports = {
},
featureWallResources
],
// Why: the notification-status helper must execute from Contents/MacOS —
// on macOS 26 UNUserNotificationCenter aborts (bundleProxyForCurrentProcess
// is nil) for executables launched out of Contents/Resources (#7929).
extraFiles: [
{
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
}
],
target: [
{
target: 'dmg',
@@ -398,6 +438,37 @@ async function signMacComputerUseHelper(helperAppPath, packager) {
})
}
async function signMacNotificationStatusHelper(helperPath, packager) {
if (!existsSync(helperPath)) {
if (isMacRelease) {
throw new Error(`Missing orca-notification-status helper at ${helperPath}`)
}
return
}
const codeSigningInfo =
isMacRelease && process.env.CSC_LINK && packager?.codeSigningInfo?.value
? await packager.codeSigningInfo.value
: null
const identity =
process.env.CSC_NAME ??
findInstalledMacSigningIdentity(codeSigningInfo?.keychainFile) ??
(isMacRelease ? null : '-')
if (!identity) {
throw new Error('Missing signing identity for orca-notification-status helper')
}
// Why: macOS keys notification records to the code-signing identifier; the
// binary embeds the app's CFBundleIdentifier in __TEXT,__info_plist so this
// (and any later) `codesign --force` derives the correct identifier. Sign
// before the outer Orca.app is sealed, like the computer-use helper.
const args = ['--force', '--sign', identity]
if (isMacRelease) {
args.push('--options', 'runtime', '--timestamp')
}
args.push(helperPath)
execFileSync('codesign', args, { stdio: 'inherit' })
execFileSync('codesign', ['--verify', '--strict', helperPath], { stdio: 'inherit' })
}
function codesignArgs(identity, targetPath) {
const args = ['--force', '--deep', '--sign', identity]
if (isMacRelease) {
+23
View File
@@ -0,0 +1,23 @@
; Clean up the relocated terminal daemon on a REAL uninstall.
;
; Why: the daemon host is deliberately copied to a distinct image name
; (orca-terminal-daemon.exe) under %LOCALAPPDATA%\Orca\daemon-host so that app
; UPDATES cannot kill it — that relocation is what keeps terminals alive across
; updates. The same design means a normal uninstall's process sweep and file
; removal both miss it, leaving an orphaned daemon plus its runtime copy behind.
;
; The ${isUpdated} guard is essential: electron-builder runs this uninstaller as
; part of uninstallOldVersion on EVERY update, and killing the daemon there would
; defeat the whole feature. Only clean up on a genuine uninstall.
;
; The image name and the LOCALAPPDATA folder name must stay in sync with
; DAEMON_HOST_EXE_NAME and LOCAL_HOST_ROOT_NAME in
; src/main/daemon/daemon-host-relocation.ts.
!macro customUnInstall
${ifNot} ${isUpdated}
nsExec::Exec 'taskkill /F /IM orca-terminal-daemon.exe'
; Give the OS a moment to release the image lock before removing the tree.
Sleep 500
RMDir /r "$LOCALAPPDATA\Orca\daemon-host"
${endIf}
!macroend
+363 -16
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-07-03",
"updatedAt": "2026-07-08",
"policy": {
"maturityLevels": [
"experimental",
@@ -105,6 +105,163 @@
],
"demotionRule": "Demote or quarantine if the gate flakes once without a product bug or harness bug filed to the owner."
},
{
"id": "terminal-session.startup-cwd-missing-dir-recovery",
"title": "Fresh local terminal creation cannot be bricked by a deleted startup cwd",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "shared-main-renderer-contract",
"surfaces": [
"terminal lifecycle",
"tab creation",
"PTY spawn",
"startup cwd persistence"
],
"platforms": [
"macos",
"linux",
"windows",
"mobile"
],
"providers": [
"local",
"daemon",
"ssh",
"wsl",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local",
"ssh",
"remote-runtime"
],
"coverageNotes": "Local macOS evidence covers the shared missing-dir fallback policy, main pty:spawn recovery and metadata, no-flag and reattach strictness, renderer IPC flag routing, SSH-tagged and remote-runtime omission, and the visibility-gated terminal notice. Daemon shares the same pre-provider main cwd decision but lacks a live daemon-provider run; WSL UNC paths are exempt from the probe by design and lack a live run; Linux/Windows and mobile/API strictness are gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7239",
"https://github.com/stablyai/orca/pull/7750",
"https://github.com/stablyai/orca/pull/7678"
],
"invariant": "A fresh local renderer terminal spawn may recover from a saved startup cwd whose directory no longer exists only by spawning at the selected workspace root and printing a generic in-terminal notice; existing directories — including ones outside the worktree (#7685) — spawn as requested, and reattach, SSH, remote-runtime, runtime/API, and mobile callers keep exact cwd semantics.",
"oracle": "The shared resolver falls back to the workspace root only when the injected existence probe reports the resolved cwd missing and the workspace root present, and never probes floating terminals or a cwd equal to the root. The renderer sends cwdFallback only for fresh local IPC spawns, main honors it only when connectionId and sessionId are absent, WSL UNC paths never engage the probe-based fallback, main returns fallback metadata only after an actual fallback, the IPC transport preserves that metadata, and the connection layer writes a generic notice that omits the missing path.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"testFiles": [
"src/shared/terminal-startup-cwd.test.ts",
"src/main/ipc/pty.test.ts",
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"src/renderer/src/components/terminal-pane/pty-connection.test.ts"
],
"assertionRefs": [
{
"file": "src/shared/terminal-startup-cwd.test.ts",
"assertions": [
"a missing requested cwd falls back to the workspace root and reports the missing path to the callback",
"existing cwds — nested or outside the worktree (#7685) — are never remapped",
"no fallback happens when the workspace root is missing too",
"floating terminal cwds and root-equal requests are never probed",
"non-ASCII worktree roots and folder workspace roots are recovered verbatim"
]
},
{
"file": "src/main/ipc/pty.test.ts",
"assertions": [
"local pty:spawn with cwdFallback worktree spawns at the worktree root when the saved cwd is missing and returns fallback metadata",
"a missing cwd without the flag still surfaces the provider's missing-directory error",
"an existing outside-worktree cwd spawns as requested without fallback metadata",
"session reattach spawns ignore the fallback flag and keep exact cwd semantics"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"assertions": [
"IPC transport sends cwdFallback only for local fresh spawns",
"SSH-tagged and session reattach spawns omit cwdFallback",
"IPC transport returns startup cwd fallback metadata to the connection layer"
]
},
{
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"assertions": [
"fresh local IPC worktree spawns are marked with cwdFallback worktree",
"startup cwd fallback metadata prints a generic in-terminal notice",
"remote-runtime worktree spawns are not marked with cwdFallback"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/terminal-startup-cwd.test.ts",
"result": "passed",
"durationSeconds": 0.2,
"summary": "1 test file passed, 21 tests passed; covers the missing-dir fallback policy, #7685 outside-worktree preservation, and root-missing/floating exemptions."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"result": "passed",
"durationSeconds": 0.9,
"summary": "1 test file passed, 225 tests passed; covers main pty:spawn recovery, fallback metadata, and no-flag/reattach provider-error strictness."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts",
"result": "passed",
"durationSeconds": 0.4,
"summary": "1 test file passed, 58 tests passed; covers cwdFallback forwarding only for local fresh spawns and metadata handoff."
},
{
"date": "2026-07-08",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts",
"result": "passed",
"durationSeconds": 6.5,
"summary": "1 test file passed, 341 tests passed; covers local IPC marking, the generic terminal fallback notice, and remote-runtime omission."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "focused unit and IPC contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New experimental gate added with local deterministic evidence only; needs CI soak before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The main IPC missing-cwd tests fail with the provider's 'Working directory ... does not exist.' error when the fallback is removed and pass with it. Full live Electron reproduction from a production persisted session is not captured."
},
"performanceBudget": {
"required": true,
"evidence": "The runtime change adds at most two statSync probes on the fresh-local spawn path (the provider already stats the same paths during validation) and one bounded terminal write only when fallback actually occurs; no polling, provider listing, hidden-pane work, startup awaits, subprocesses, or render-loop work was added."
},
"promotionCriteria": [
"Attach CI evidence for all declared test files.",
"Add a live Electron regression that opens a local terminal whose persisted startupCwd was deleted and proves visible shell input/output at the workspace root.",
"Add WSL/mobile/API provider-contract coverage or explicitly narrow their risk scope."
],
"knownGaps": [
"No live Electron fixture seeds a persisted tab whose startupCwd directory was deleted.",
"Daemon coverage is via the shared pre-provider main cwd decision, not a live daemon provider spawn.",
"WSL UNC paths bypass the probe by design and have no live existence-recovery run; Linux, Windows, and mobile/API strictness are not directly exercised."
],
"demotionRule": "Demote or quarantine if the gate flakes without a product bug, if an existing directory is ever remapped away from the requested cwd, or if a reattach/remote/API caller can engage the fallback."
},
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
@@ -1273,7 +1430,7 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence on main@1282f5c2d; identical files to the pending stack. Real OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"coverageNotes": "Local macOS evidence on main@1282f5c2d, deterministic renderer-unit coverage for the Linux/Sogou candidate-key policy, and a local Electron/CDP live-PTY Sogou-style repro. Real Linux/Sogou OS IME automation, Windows ConPTY post-agent reset, and the CJK/Vietnamese/Arabic matrix remain registered gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6699",
"https://github.com/stablyai/orca/pull/6682",
@@ -1281,14 +1438,20 @@
"https://github.com/stablyai/orca/pull/6999"
],
"invariant": "Composition, native text forwarding, synthetic input, paste, and platform keyboard bypass paths must not send preedit/control bytes before commit and must commit text exactly once to the intended PTY.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, and paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"oracle": "The current renderer-unit slice asserts native text commits route to the intended PTY once, composition/preedit bookkeeping does not leak premature text, input-source classification handles synthetic/native paths, paste/runtime forwarding avoids duplicate terminal payloads for covered fixtures, and Linux/Sogou candidate Space/digit selectors do not leak keydown/keypress/keyup while ordinary post-IME typing remains available. The Electron/CDP live-PTY repro verifies Sogou-style Space and digit candidate selectors submit only the committed Chinese text. Real IME paths and the full CJK/Vietnamese/Arabic/JIS-yen matrix run in follow-up platform soak where automation is possible.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"testFiles": [
"src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts",
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts"
"src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"tests/e2e/chinese-ime-chat-input-repro.spec.ts"
],
"assertionRefs": [
{
@@ -1309,34 +1472,80 @@
"assertions": [
"paste/runtime forwarding avoids duplicate terminal payloads"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts",
"assertions": [
"empty Sogou-style compositionupdate keeps composition active",
"stale composition state expires so editing keys cannot stay suppressed indefinitely",
"post-composition candidate guard is armed only by Sogou-style empty updates and clears after real typing"
]
},
{
"file": "src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts",
"assertions": [
"suppressed candidate keydown arms only the matching keypress/keyup release guard",
"held-key repeat keydowns stay guarded until their keyup, even past expiry",
"fresh keydowns, other keys, modified keys, and expired keypresses are not guarded, and a fresh press drops a stale pending release"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts",
"assertions": [
"standalone Linux 229 keydowns reach xterm while Windows 229 keydowns stay suppressed",
"candidate Space/digit selectors are suppressed only while candidate guards are active"
]
},
{
"file": "src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"assertions": [
"macOS standalone Process key behavior and composition-owned key suppression stay intact"
]
},
{
"file": "tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"assertions": [
"Sogou-style Space candidate selection submits only the committed Chinese character",
"Sogou-style digit candidate selection submits only the committed Chinese phrase",
"Post-composition Sogou-style digit selection stays out of the PTY after compositionend"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-03",
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts src/renderer/src/components/terminal-pane/terminal-ime-composition-tracker.test.ts src/renderer/src/components/terminal-pane/terminal-ime-candidate-key-release-guard.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy-non-mac.test.ts src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts",
"result": "passed",
"durationSeconds": 0.9,
"summary": "3 test file(s) passed, 63 tests passed on main@1282f5c2d in a clean checkout."
"durationSeconds": 0.7,
"summary": "7 test file(s) passed, 144 tests passed on the Linux/Sogou candidate-key branch (includes held-key repeat guard coverage)."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm run test:e2e -- tests/e2e/chinese-ime-chat-input-repro.spec.ts",
"result": "passed",
"durationSeconds": 72.0,
"summary": "Electron/CDP IME repro passed: Sogou candidate selection, post-composition candidate selection, and existing Chinese IME harness passed; real Codex IME test was skipped behind ORCA_E2E_REAL_CODEX_IME."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "renderer unit plus platform soak"
"p95Seconds": 90,
"scope": "renderer unit plus focused Electron/CDP IME repro"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Focused renderer input tests are now registered; needs soak history and platform IME evidence before promotion."
"evidence": "Focused renderer input tests and the Electron/CDP Sogou-style repro are now registered; needs soak history and true platform IME evidence before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Focused tests cover existing native-text, input-source, and paste/runtime forwarding contracts. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
"evidence": "Focused tests cover existing native-text, input-source, paste/runtime forwarding, Linux/Sogou candidate selector, post-composition guard contracts, and live-PTY Sogou-style candidate commits. Needs intentional-break proof for duplicate native text forwarding and composition preedit leakage, plus the broader language/platform matrix."
},
"performanceBudget": {
"required": false,
"evidence": "Required if input forwarding adds polling, repeated event listeners, or renderer hot-path work."
"required": true,
"evidence": "Terminal IME hot-path audit clean on 2026-07-07: candidate guard adds O(1) boolean checks plus a bounded Space/digit release map; composition listeners are disposed with pane lifecycle; no polling, IPC fan-out, subprocess work, or SSH/remote transport cost added."
},
"promotionCriteria": [
"Cover deterministic byte/cell oracles first.",
@@ -1344,7 +1553,7 @@
"Pair with Windows ConPTY keyboard reset for Windows standard-key behavior."
],
"knownGaps": [
"Current command is a renderer-unit slice; real OS IME automation may need manual or soak evidence.",
"Current commands include renderer-unit coverage and a CDP-driven Electron repro; real OS IME automation may need manual or soak evidence.",
"Backspace/Enter during composition, JIS yen, Arabic/RTL, paste edge cases, and Windows ConPTY post-agent key reset still need representative gate coverage."
],
"demotionRule": "Cannot promote if success is based only on DOM text without PTY byte/cell evidence."
@@ -1411,6 +1620,144 @@
],
"demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence."
},
{
"id": "terminal-input.agent-prompt-injection",
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-input",
"layer": "runtime-contract-and-cli-repro",
"surfaces": [
"terminal input",
"agent prompt injection",
"orchestration dispatch",
"PTY writes",
"bracketed paste"
],
"platforms": [
"macos",
"linux",
"windows"
],
"providers": [
"local",
"daemon",
"ssh",
"remote-runtime"
],
"coveredPlatforms": [
"macos"
],
"coveredProviders": [
"local"
],
"coverageNotes": "Local macOS evidence covers the runtime PTY write contract and a live dev-runtime CLI repro. SSH, daemon, remote-runtime, Linux, and Windows remain provider/platform gaps; the product path stays provider-owned and does not add local filesystem or process assumptions.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/7226"
],
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and send Enter only after the paste frame completes.",
"oracle": "Runtime tests assert the exact PTY write sequence and failure cleanup; orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness dispatches a 32KB task to a fake Codex-like TUI and requires marker present, bracketed paste present, zero unframed line breaks, and submit observed.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
],
"testFiles": [
"src/shared/agent-prompt-injection.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/rpc/methods/orchestration.test.ts",
"src/main/runtime/orchestration/coordinator.test.ts",
"tools/repro-orchestration-long-prompt.mjs"
],
"assertionRefs": [
{
"file": "src/shared/agent-prompt-injection.test.ts",
"assertions": [
"agent prompts are always framed as bracketed paste",
"submit stays separate from the paste frame",
"embedded ESC bytes are made inert before framing",
"chunk reconstruction preserves the paste frame"
]
},
{
"file": "src/main/runtime/orca-runtime.test.ts",
"assertions": [
"runtime writes bracketed paste before a delayed submit",
"large prompt frames are chunked and reconstructed before submit",
"partial prompt write failure closes the paste frame and does not submit"
]
},
{
"file": "src/main/runtime/rpc/methods/orchestration.test.ts",
"assertions": [
"orchestration.dispatch uses the agent prompt path for injected preambles",
"raw terminal.send is not called for injected task prompts",
"failed prompt injection rolls back the active dispatch"
]
},
{
"file": "src/main/runtime/orchestration/coordinator.test.ts",
"assertions": [
"coordinator dispatch failures from prompt injection circuit-break through the DB",
"silent-skip paths do not attempt prompt injection"
]
},
{
"file": "tools/repro-orchestration-long-prompt.mjs",
"assertions": [
"fake Codex-like worker observes submit after long orchestration dispatch",
"32KB task marker survives before submit",
"prompt bytes include a bracketed-paste frame with zero unframed line breaks"
]
}
],
"evidenceRuns": [
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/runtime/orchestration/coordinator.test.ts",
"result": "passed",
"durationSeconds": 7.4,
"summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior."
},
{
"date": "2026-07-07",
"runner": "local",
"platform": "macos",
"command": "node tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000",
"result": "passed",
"durationSeconds": 2.2,
"summary": "Live dev-runtime repro passed: expectedSpecBytes=32830, hasSubmit=true, rawContainsMarker=true, hasBracketedPasteFrame=true, unframedLineBreaks=0, contractOk=true."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "runtime contract tests plus optional local dev-runtime CLI repro"
},
"flakeHistory": {
"status": "unknown",
"evidence": "First local macOS evidence only; needs repeated dev-runtime harness runs and provider matrix evidence before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "The live harness reproduced the unsafe raw multiline contract before the fix and passes after the fix; intentional-break evidence is local only and not yet in CI."
},
"performanceBudget": {
"required": true,
"evidence": "Agent prompt dispatch remains O(prompt bytes), uses the existing 16KB terminal input chunking and one existing 500ms submit delay, and adds no polling, provider listing, subprocess churn, hidden-pane wakeups, or renderer work."
},
"promotionCriteria": [
"Run the live harness in soak with a self-starting dev runtime or provider-contract fixture.",
"Add daemon, SSH, remote-runtime, Linux, and Windows evidence or mark narrower provider scope.",
"Capture stable red/green intentional-break evidence in CI before blocking promotion."
],
"knownGaps": [
"Live harness command currently expects an already-running dev runtime and generated out/bin/orca-dev wrapper.",
"No Windows ConPTY, Linux PTY, SSH, daemon, or remote-runtime live evidence yet.",
"Push-on-idle orchestration message banners remain outside this dispatch-prompt gate."
],
"demotionRule": "Demote or quarantine if the live harness flakes without a product bug or harness bug filed to the terminal-input owner."
},
{
"id": "terminal-render.windows-cjk-repaint",
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
+5 -1
View File
@@ -8,6 +8,8 @@ if (process.platform !== 'darwin') {
}
runPnpmScript('build:computer-macos')
runPnpmScript('build:notification-status-macos')
process.exit(0)
function runPnpmScript(scriptName) {
const npmExecPath = process.env.npm_execpath
@@ -22,5 +24,7 @@ function runPnpmScript(scriptName) {
if (result.signal) {
process.kill(process.pid, result.signal)
}
process.exit(result.status ?? (result.error ? 1 : 0))
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -0,0 +1,97 @@
#!/usr/bin/env node
// Builds the orca-notification-status helper binary.
//
// The helper reads UNUserNotificationCenter settings for the app it ships
// inside (see native/notification-status-macos/main.swift). The target
// CFBundleIdentifier is embedded as a __TEXT,__info_plist section so every
// later `codesign --force` pass (electron-builder's signing, the dev runner's
// ad-hoc deep sign) derives the correct code identifier automatically —
// macOS keys notification records to that identifier.
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const repoRoot = path.resolve(import.meta.dirname, '../..')
const sourcePath = path.join(repoRoot, 'native', 'notification-status-macos', 'main.swift')
const defaultOutputPath = path.join(
repoRoot,
'native',
'notification-status-macos',
'.build',
'release',
'orca-notification-status'
)
if (process.platform !== 'darwin') {
process.exit(0)
}
const args = process.argv.slice(2)
const bundleId = readArg('--bundle-id') ?? 'com.stablyai.orca'
const outputPath = readArg('--output') ?? defaultOutputPath
// Why: dev launches only need the host architecture; release builds ship a
// universal binary matching the app's x64 + arm64 targets.
const singleArch = args.includes('--single-arch')
const workDir = path.join(tmpdir(), `orca-notification-status-${process.pid}`)
mkdirSync(workDir, { recursive: true })
try {
const plistPath = path.join(workDir, 'Info.plist')
writeFileSync(plistPath, embeddedInfoPlist(bundleId), 'utf8')
const triples = singleArch
? [process.arch === 'arm64' ? 'arm64-apple-macosx' : 'x86_64-apple-macosx']
: ['arm64-apple-macosx', 'x86_64-apple-macosx']
const builtBinaries = triples.map((triple) => {
const output = path.join(workDir, `orca-notification-status-${triple}`)
execFileSync(
'swiftc',
[
'-O',
sourcePath,
'-target',
triple.replace('-apple-macosx', '-apple-macosx11.0'),
'-o',
output,
'-Xlinker',
'-sectcreate',
'-Xlinker',
'__TEXT',
'-Xlinker',
'__info_plist',
'-Xlinker',
plistPath
],
{ stdio: 'inherit' }
)
return output
})
mkdirSync(path.dirname(outputPath), { recursive: true })
if (builtBinaries.length === 1) {
execFileSync('cp', [builtBinaries[0], outputPath])
} else {
execFileSync('lipo', ['-create', ...builtBinaries, '-output', outputPath])
}
execFileSync('chmod', ['755', outputPath])
} finally {
rmSync(workDir, { recursive: true, force: true })
}
function readArg(name) {
const index = args.indexOf(name)
return index >= 0 ? args[index + 1] : undefined
}
function embeddedInfoPlist(identifier) {
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>${identifier}</string>
<key>CFBundleName</key>
<string>orca-notification-status</string>
</dict>
</plist>
`
}
@@ -110,6 +110,39 @@ describe('computer-use e2e workflow', () => {
}
})
it('boots the built daemon under plain Node in the PR native-smoke job after the main build', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const steps = workflow.jobs['native-smoke'].steps
const runs = steps.map((step) => step.run).filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
const daemonSmokeIndex = runs.indexOf('node config/scripts/daemon-boot-smoke.mjs')
expect(daemonSmokeIndex, 'native-smoke must boot the built daemon').toBeGreaterThanOrEqual(0)
expect(
buildIndex,
'daemon boot smoke must run after the main bundle is built'
).toBeGreaterThanOrEqual(0)
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
})
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
const triggerPaths = workflow.on.pull_request.paths
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/daemon-boot-smoke.mjs',
'electron.vite.config.ts',
'build-plugins/**',
'src/main/daemon/**'
])
)
})
it('runs Linux computer-use e2e in the PR native-smoke job under Xvfb', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
+217
View File
@@ -0,0 +1,217 @@
/**
* Boots the BUILT terminal daemon (out/main/daemon-entry.js) under plain Node —
* the exact way production forks it (ELECTRON_RUN_AS_NODE = a plain-Node
* process) — and asserts it starts, serves a real PTY, and stops.
*
* Why this exists: native-smoke CI (and packaging) went green while
* v1.4.129-rc.1 shipped a daemon that exited code 1 at module load because an
* electron `require` leaked into its bundle graph. Nothing executed the built
* entry under plain Node, so the outage was invisible until an adopted old
* daemon died in the field. This runs on every PR that touches the daemon.
*
* Hard assertions (fail the job):
* - the daemon signals `{ type: 'ready' }` over IPC within the timeout, and
* - it terminates when asked (no hang / zombie).
* Best-effort (logged skip, never fails): an end-to-end `ptySpawnHealth` RPC,
* because node-pty spawn can be flaky on constrained CI runners.
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const projectDir = resolve(import.meta.dirname, '../..')
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 30_000
const PTY_HEALTH_TIMEOUT_MS = 10_000
const SHUTDOWN_TIMEOUT_MS = 10_000
function log(message) {
process.stdout.write(`[daemon-boot-smoke] ${message}\n`)
}
// Why: the daemon rejects a hello whose protocol version differs, so read the
// current version from source rather than hardcoding a number that can drift.
function readProtocolVersion() {
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
if (!match) {
throw new Error('could not read PROTOCOL_VERSION from src/main/daemon/types.ts')
}
return Number(match[1])
}
function makeSocketPath(userDataDir) {
// Why: Windows AF_UNIX-style IPC uses named pipes; POSIX uses a filesystem
// socket kept under the scratch userData dir so cleanup removes it.
if (process.platform === 'win32') {
return `\\\\.\\pipe\\orca-daemon-smoke-${process.pid}-${randomUUID()}`
}
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
socket.on('error', (err) => finish(false, err.message))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
`${JSON.stringify({
type: 'hello',
version: protocolVersion,
token,
clientId: randomUUID(),
role: 'control'
})}\n`
)
})
socket.on('data', (chunk) => {
buffer += chunk.toString('utf8')
let newlineIdx = buffer.indexOf('\n')
while (newlineIdx !== -1) {
const line = buffer.slice(0, newlineIdx)
buffer = buffer.slice(newlineIdx + 1)
let msg
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
)
return
}
newlineIdx = buffer.indexOf('\n')
}
})
})
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
try {
child.kill('SIGKILL')
} catch {
// already gone
}
}
rmSync(userDataDir, { recursive: true, force: true })
}
try {
await new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(() => {
rejectReady(
new Error(
`daemon did not signal 'ready' within ${READY_TIMEOUT_MS}ms.\nstderr:\n${stderr}`
)
)
}, READY_TIMEOUT_MS)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady()
}
})
child.on('error', (err) => {
clearTimeout(timer)
rejectReady(new Error(`daemon fork errored: ${err.message}\nstderr:\n${stderr}`))
})
child.on('exit', (code, signal) => {
clearTimeout(timer)
rejectReady(
new Error(
`daemon exited before 'ready' (code=${code}, signal=${signal}).\nstderr:\n${stderr}`
)
)
})
})
log('daemon signaled ready')
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
log('ptySpawnHealth OK — daemon spawned a real PTY end-to-end')
}
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
})
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
cleanup()
}
}
main().catch((error) => {
process.stderr.write(`[daemon-boot-smoke] FAIL: ${error.message}\n`)
process.exitCode = 1
})
@@ -63,12 +63,36 @@ describe('electron-builder config', () => {
)
})
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
expect(electronBuilderConfig.mac.extraFiles).toEqual(
expect.arrayContaining([
expect.objectContaining({
from: 'native/notification-status-macos/.build/release/orca-notification-status',
to: 'MacOS/orca-notification-status'
})
])
)
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
)
})
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
)
})
// Why: without the unpacked entry the watcher client silently falls back to
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
it('unpacks the forked parcel-watcher process entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -329,6 +353,15 @@ describe('electron-builder config', () => {
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(
join(unpackedMainDir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
@@ -221,7 +221,7 @@ describe('Electron runtime package contract', () => {
expect(installRun).not.toMatch(/throw\s+\$_/)
})
it('temporarily allows publishing Windows after verifying the signed installer only', () => {
it('verifies Windows inner binary signatures fail-open before publishing', () => {
const releaseWorkflow = readFileSync(
join(projectDir, '.github/workflows/release-cut.yml'),
'utf8'
@@ -230,12 +230,38 @@ describe('Electron runtime package contract', () => {
const steps = parsedWorkflow.jobs.build.steps
const stepNames = steps.map((step) => step.name)
const outerVerifyIndex = stepNames.indexOf('Verify signed Windows installer')
const innerVerifyIndex = stepNames.indexOf('Verify signed Windows inner executable')
const innerVerifyIndex = stepNames.indexOf('Verify Windows inner binary signatures')
const evidenceIndex = stepNames.indexOf('Upload Windows inner signing evidence')
const publishIndex = stepNames.indexOf('Publish signed Windows release artifacts')
expect(outerVerifyIndex).toBeGreaterThan(-1)
expect(innerVerifyIndex).toBe(-1)
expect(publishIndex).toBe(outerVerifyIndex + 1)
expect(innerVerifyIndex).toBe(outerVerifyIndex + 1)
expect(evidenceIndex).toBe(innerVerifyIndex + 1)
expect(publishIndex).toBe(evidenceIndex + 1)
// Why fail-open: unsigned inner binaries must warn, not block, until the
// flow is proven on a real release (issue #7785). Flip this to 'true'
// together with the workflow env to make the gate required.
expect(steps[innerVerifyIndex].env.ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED).toBe('false')
// Why: every step in the inner-signing chain must be unable to fail the
// release — a SignPath outage or timeout falls through to today's
// unsigned-inner flow instead of blocking the cut.
const innerChainStepNames = [
'Stage unsigned inner PE files for signing',
'Upload unsigned inner binaries for SignPath',
'Submit inner binaries signing request',
'Notify Slack that inner-binary signing is waiting for approval',
'Download signed inner binaries from SignPath',
'Restore signed inner binaries into unpacked app',
'Replace cached elevate.exe with the signed copy',
'Rebuild NSIS installer from signed unpacked app'
]
for (const stepName of innerChainStepNames) {
const step = steps[stepNames.indexOf(stepName)]
expect(step, stepName).toBeDefined()
expect(step['continue-on-error'], stepName).toBe(true)
}
})
it('publishes both Linux release matrix entries', () => {
+54 -15
View File
@@ -97,16 +97,6 @@ function setPlistValue(plistPath, key, value) {
execFileSync('/usr/bin/plutil', ['-replace', key, '-string', value, plistPath])
}
function sanitizeBundleIdPart(value) {
return (
value
.toLowerCase()
.replace(/[^a-z0-9.-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || 'dev'
)
}
function sanitizeMacAppBundleName(value) {
return (
Array.from(value, (char) => {
@@ -138,7 +128,10 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v4'
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -151,7 +144,17 @@ function prepareMacDevElectronApp() {
const appBundleName = `${sanitizeMacAppBundleName(title)}.app`
const appPath = path.join(distDir, appBundleName)
const markerPath = path.join(distDir, 'orca-dev-electron-app.json')
const bundleId = `com.stablyai.orca.dev.${sanitizeBundleIdPart(hash)}`
// Why: one stable id for every dev instance. Per-instance ids registered a
// new macOS Notification Settings entry for each branch × Electron version,
// piling up "Orca: <branch>" rows forever and breaking the notification
// settings deep-link (System Settings can't resolve an id it has no entry
// for and falls back to the root list). macOS keys notification permission
// by bundle id, so a single id also means granting notifications to one dev
// instance covers all of them. Trade-off: when two dev instances run at
// once, macOS may route a notification click to the other instance —
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -206,9 +209,45 @@ function prepareMacDevElectronApp() {
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
// Why no re-sign: dev launches execute the copied Electron binary directly,
// and Electron's framework bundle is ambiguous to codesign when deep-signing
// an already-built distribution. Avoid blocking `pn dev` on local signing.
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
// API). It must live inside the bundle and carry the dev bundle id as its
// embedded/code-sign identifier — macOS keys notification records to the
// signing identifier. Non-fatal: without swiftc the permission card falls
// back to delivery-probe heuristics.
try {
execFileSync(
process.execPath,
[
path.join(repoRoot, 'config', 'scripts', 'build-notification-status-macos.mjs'),
'--bundle-id',
bundleId,
'--single-arch',
'--output',
path.join(appPath, 'Contents', 'MacOS', 'orca-notification-status')
],
{ stdio: 'inherit' }
)
} catch (error) {
console.warn(
`[orca-dev] notification-status helper build failed (permission card falls back to probes): ${error?.message ?? error}`
)
}
// Why: the plist edits above (and the copy itself) break the bundle's
// ad-hoc seal, and macOS refuses Notification Center registration for
// invalidly-signed apps — every dev notification fails with UNErrorDomain
// error 1 and the app never appears in System Settings > Notifications.
// An ad-hoc re-sign restores delivery, the permission prompt, and the
// notification-settings deep link for dev builds. Non-fatal: a signing
// failure should not block `pnpm dev`.
try {
execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath])
} catch (error) {
console.warn(
`[orca-dev] ad-hoc codesign failed (dev notifications will not deliver): ${error?.message ?? error}`
)
}
writeFileSync(markerPath, expectedMarker, 'utf8')
process.env.ELECTRON_EXEC_PATH = executablePath
}
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env node
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'
import { hostname, tmpdir } from 'node:os'
import path from 'node:path'
import { createInterface } from 'node:readline'
const scriptDir = import.meta.dirname
const repoRoot = path.resolve(scriptDir, '..', '..')
const orcaDevScript = path.join(scriptDir, 'orca-dev.mjs')
const ensureNativeRuntimeScript = path.join(scriptDir, 'ensure-native-runtime.mjs')
const fixedProfileDir = process.env.ORCA_HEADLESS_PAIRING_PROFILE_DIR
const parsed = parseArgs(process.argv.slice(2))
if (parsed.help) {
printHelp()
process.exit(0)
}
if (hasForwardedServeFlag(parsed.serveArgs, 'no-pairing')) {
console.error('serve-headless-fresh-profile-pairing: --no-pairing cannot print a pairing code.')
process.exit(2)
}
if (hasForwardedServeFlag(parsed.serveArgs, 'recipe-json')) {
console.error(
'serve-headless-fresh-profile-pairing: --recipe-json detaches the server and cannot use an ephemeral profile safely.'
)
process.exit(2)
}
const serveArgs = withDefaultPairingAddress(parsed.serveArgs)
ensureElectronRuntime()
const profileDir =
fixedProfileDir ?? mkdtempSync(path.join(tmpdir(), 'orca-headless-pairing-profile-'))
const ownsProfileDir = !fixedProfileDir
mkdirSync(profileDir, { recursive: true })
let cleanedUp = false
let child = null
let sawPairingUrl = false
let stopAttempts = 0
// Why: temp dev worktrees do not have a root-owned chrome-sandbox; this script
// is only for local headless testing, not packaged production.
const childEnv = {
...process.env,
ORCA_DEV_USER_DATA_PATH: profileDir,
...(process.platform === 'linux'
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
: {})
}
console.error(`[headless-pairing] userData=${profileDir}`)
console.error(`[headless-pairing] starting: orca-dev serve --json${formatForwardedArgs(serveArgs)}`)
child = spawn(process.execPath, [orcaDevScript, 'serve', '--json', ...serveArgs], {
cwd: repoRoot,
detached: process.platform !== 'win32',
env: childEnv,
stdio: ['inherit', 'pipe', 'inherit']
})
const stdoutLines = createInterface({ input: child.stdout })
stdoutLines.on('line', (line) => {
if (!printReadyLine(line)) {
console.log(line)
}
})
child.once('error', (error) => {
console.error(`[headless-pairing] failed to start server: ${error.message}`)
cleanupProfile()
process.exitCode = 1
})
child.once('exit', (code, signal) => {
stdoutLines.close()
if (!sawPairingUrl && code !== 0) {
console.error('[headless-pairing] server exited before printing a pairing URL.')
}
cleanupProfile()
if (typeof code === 'number') {
process.exitCode = code
return
}
process.exitCode = signal ? 1 : 0
})
process.on('SIGINT', () => stopChild('SIGINT'))
process.on('SIGTERM', () => stopChild('SIGTERM'))
/**
* Parses wrapper flags and forwards everything else to `orca serve`.
*/
function parseArgs(args) {
const serveArgs = []
let keepProfile = false
let help = false
for (const arg of args) {
if (arg === '--keep') {
keepProfile = true
continue
}
if (arg === '-h' || arg === '--help') {
help = true
continue
}
serveArgs.push(arg)
}
return { help, keepProfile, serveArgs }
}
/**
* Prints script usage without touching the dev profile or starting the server.
*/
function printHelp() {
console.log(`Usage: node config/scripts/serve-headless-fresh-profile-pairing.mjs [--keep] [orca serve flags]
Starts orca-dev serve --json with a fresh isolated userData profile, ensures Electron's dev runtime is usable, and prints the pairing URL.
Wrapper flags:
--keep Keep the fresh profile after the server exits.
-h, --help Show this help.
Forwarded examples:
node config/scripts/serve-headless-fresh-profile-pairing.mjs --port 6768
node config/scripts/serve-headless-fresh-profile-pairing.mjs --pairing-address 100.64.1.20
node config/scripts/serve-headless-fresh-profile-pairing.mjs --mobile-pairing
Environment:
ORCA_HEADLESS_PAIRING_ADDRESS=<host|host:port|ws://...> Override the auto pairing address.
ORCA_HEADLESS_PAIRING_PROFILE_DIR=/path/to/profile Use a fixed profile directory.
`)
}
/**
* Adds a reachable pairing address unless the caller provided one explicitly.
*/
function withDefaultPairingAddress(args) {
if (hasPairingAddress(args)) {
return args
}
const address = resolveDefaultPairingAddress()
if (!address) {
return args
}
console.error(`[headless-pairing] pairingAddress=${address} (auto)`)
return [...args, '--pairing-address', address]
}
/**
* Checks both supported CLI forms for an explicit pairing address.
*/
function hasPairingAddress(args) {
return hasForwardedServeFlag(args, 'pairing-address')
}
/**
* Checks both exact and `--flag=value` forms for a forwarded serve flag.
*/
function hasForwardedServeFlag(args, name) {
const flag = `--${name}`
return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
}
/**
* Prefers an override, then Tailscale, then the OS hostname over loopback.
*/
function resolveDefaultPairingAddress() {
const configured = process.env.ORCA_HEADLESS_PAIRING_ADDRESS?.trim()
if (configured) {
return configured
}
const tailscaleAddress = readTailscaleAddress()
if (tailscaleAddress) {
return tailscaleAddress
}
const host = hostname().trim()
return host && host !== 'localhost' ? host : null
}
/**
* Reads the first Tailscale IPv4 address when Tailscale is installed.
*/
function readTailscaleAddress() {
const result = spawnSync('tailscale', ['ip', '-4'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
})
if (result.error || result.status !== 0) {
return null
}
return (
result.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean) ?? null
)
}
/**
* Formats forwarded arguments for the startup log.
*/
function formatForwardedArgs(args) {
if (args.length === 0) {
return ''
}
return ` ${args.map(formatShellArg).join(' ')}`
}
/**
* Quotes a shell-ish argument for display only.
*/
function formatShellArg(value) {
if (/^[\w./:=@+-]+$/.test(value)) {
return value
}
return JSON.stringify(value)
}
/**
* Runs the same Electron runtime preflight used by dev/start package scripts.
*/
function ensureElectronRuntime() {
console.error('[headless-pairing] ensuring Electron runtime...')
const result = spawnSync(process.execPath, [ensureNativeRuntimeScript, '--runtime=electron'], {
cwd: repoRoot,
stdio: 'inherit'
})
if (result.error) {
console.error(`[headless-pairing] Electron runtime preflight failed: ${result.error.message}`)
process.exit(1)
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
}
/**
* Rewrites the CLI JSON readiness line into the pairing URL testers need.
*/
function printReadyLine(line) {
let payload
try {
payload = JSON.parse(line)
} catch {
return false
}
if (!payload || payload.type !== 'orca_server_ready') {
return false
}
console.log(`Orca server ready: ${payload.endpoint ?? 'websocket unavailable'}`)
if (payload.pairing?.endpoint) {
console.log(`Pairing endpoint: ${payload.pairing.endpoint}`)
}
if (payload.pairing?.webClientUrl) {
console.log(`Web client URL: ${payload.pairing.webClientUrl}`)
}
if (payload.pairing?.url) {
sawPairingUrl = true
console.log(`Pairing URL: ${payload.pairing.url}`)
console.error('[headless-pairing] server is running; press Ctrl+C to stop.')
return true
}
console.log('Pairing URL: unavailable')
return true
}
/**
* Stops the foreground server; repeated signals force its process tree down.
*/
function stopChild(signal) {
if (!child || child.killed) {
return
}
stopAttempts += 1
const targetSignal = stopAttempts > 1 ? 'SIGKILL' : signal
if (process.platform === 'win32' && child.pid) {
// Why: child.kill() only targets orca-dev on Windows; taskkill walks the
// CLI/Electron descendants so the fresh profile is not left locked.
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
})
killer.on('error', () => child?.kill(targetSignal))
return
}
if (process.platform !== 'win32' && child.pid) {
// Why: orca-dev synchronously owns the CLI child, which owns Electron; kill
// the spawned process group so programmatic shutdown does not orphan serve.
try {
process.kill(-child.pid, targetSignal)
return
} catch {
// Fall back to the direct child below if the process group already exited.
}
}
child.kill(targetSignal)
}
/**
* Removes only the ephemeral profile directory this script created.
*/
function cleanupProfile() {
if (cleanedUp) {
return
}
cleanedUp = true
if (parsed.keepProfile || !ownsProfileDir) {
console.error(`[headless-pairing] kept ${profileDir}`)
return
}
if (!existsSync(profileDir) || !profileDir.includes('orca-headless-pairing-profile-')) {
console.error(`[headless-pairing] skipped cleanup for unexpected profile path: ${profileDir}`)
return
}
try {
rmSync(profileDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
console.error(`[headless-pairing] removed ${profileDir}`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`[headless-pairing] skipped cleanup for ${profileDir}: ${message}`)
}
}
@@ -0,0 +1,58 @@
const { existsSync } = require('node:fs')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
// Why: `asarUnpack` in config/electron-builder.config.cjs lists
// out/main/daemon-entry.js on every platform, and the packaged daemon fork
// (src/main/daemon/daemon-init.ts) resolves exactly this unpacked path. A
// missing entry means the package layout regressed, so the check throws
// instead of skipping — a silent skip false-passed exactly the layout bug
// this gate exists to catch.
function assertPackagedDaemonEntryExists(resourcesDir) {
const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
if (!existsSync(entryPath)) {
throw new Error(
`[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` +
`asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` +
`daemon cannot be forked from this layout`
)
}
return entryPath
}
// Why: v1.4.129-rc.1 shipped a terminal daemon that could not load (an electron
// `require` leaked into its bundle) while every build check passed. This boots
// the PACKAGED daemon-entry under plain Node against the asar-unpacked layout,
// so a bundling / asar-unpack regression fails packaging instead of reaching
// users. Module-load proof only: with no args the entry must reach argv parsing
// and print its "Usage: daemon-entry" error — a MODULE_NOT_FOUND or a missing
// usage line means the packaged graph does not load and the build must fail.
//
// resourcesDir is the packaged Resources dir (Contents/Resources on macOS,
// <appOutDir>/resources elsewhere). execPath defaults to the packaging Node.
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
const execPath = options.execPath || process.execPath
const entryPath = assertPackagedDaemonEntryExists(resourcesDir)
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
if (result.error) {
throw new Error(
`[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}`
)
}
const stderr = result.stderr || ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
}
module.exports = { assertPackagedDaemonEntryExists, verifyPackagedDaemonEntryBoots }
@@ -0,0 +1,57 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const {
assertPackagedDaemonEntryExists,
verifyPackagedDaemonEntryBoots
} = require('./verify-packaged-daemon-entry.cjs')
describe('verify-packaged-daemon-entry', () => {
let resourcesDir
beforeEach(() => {
resourcesDir = mkdtempSync(join(tmpdir(), 'orca-daemon-entry-verify-'))
})
afterEach(() => {
rmSync(resourcesDir, { recursive: true, force: true })
})
function writePackagedEntry(source) {
const entryDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
mkdirSync(entryDir, { recursive: true })
writeFileSync(join(entryDir, 'daemon-entry.js'), source)
}
// Why: a silent skip on a missing entry false-passed exactly the packaged
// layout regression this gate exists to catch (rc.1 daemon-load incident).
it('throws when the unpacked daemon entry is missing', () => {
expect(() => assertPackagedDaemonEntryExists(resourcesDir)).toThrow(
/missing unpacked daemon entry/
)
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
/missing unpacked daemon entry/
)
})
it('passes when the packaged entry loads and reaches argv parsing', () => {
writePackagedEntry('console.error("Usage: daemon-entry <socket>"); process.exit(1)\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).not.toThrow()
})
it('fails when the packaged entry cannot resolve its module graph', () => {
writePackagedEntry('require("orca-module-that-does-not-exist")\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(
/failed to load under plain Node/
)
})
it('fails when the packaged entry never reaches argv parsing', () => {
writePackagedEntry('process.exit(0)\n')
expect(() => verifyPackagedDaemonEntryBoots(resourcesDir)).toThrow(/did not reach argv parsing/)
})
})
+4
View File
@@ -13,9 +13,13 @@
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
"../src/main/codex/codex-home-paths.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
"../src/main/codex/config-settings-promotion.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
"../src/main/codex/hook-trust-promotion.ts",
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
+1
View File
@@ -2,6 +2,7 @@
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"../electron.vite.config.*",
"../build-plugins/**/*",
"../src/main/**/*",
"../src/preload/**/*",
"../src/shared/**/*",
+1 -1
View File
@@ -225,7 +225,7 @@ Three scrollbar classes are defined globally in `main.css`:
- **`.scrollbar-sleek`** — the default thin, neutral scrollbar for sidebars, lists, popovers. Pair with `.scrollbar-sleek-parent` on a hover-target ancestor if you want the thumb to fade in only on parent hover.
- **`.scrollbar-editor`** — slightly heavier, used inside Monaco-adjacent surfaces.
- **`.worktree-sidebar-scrollbar`** — reserves the gutter but keeps the thumb invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar so the chrome stays still.
- **`.worktree-sidebar-scrollbar`** — no reserved gutter: paired with `overflow-y-auto`, the scrollbar (and its width) exists only while content actually overflows, so a short list stays flush with the fixed header controls and classic-scrollbar Windows shows no arrow buttons on empty lists. The thumb stays invisible until the parent (`.scrollbar-sleek-parent`) is hovered. Used only in the worktree sidebar.
Apply one of these to overflow containers; don't write a fourth style.
+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: 3.7m">
<title>downloads: 3.7m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.2m">
<title>downloads: 4.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">3.7m</text>
<text x="93.5" y="14">3.7m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.2m</text>
<text x="93.5" y="14">4.2m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

+95
View File
@@ -0,0 +1,95 @@
# VS Code WSL Workspace Launch
## Problem
On Windows, choosing **Open in VS Code** for a workspace stored under a WSL UNC path opens the folder in a Windows VS Code environment instead of a Remote - WSL window ([issue #7649](https://github.com/stablyai/orca/issues/7649)). The renderer passes the workspace path and configured editor command unchanged (`src/renderer/src/components/sidebar/WorktreeOpenInMenu.tsx:103-121`), and the main process delegates launch argument construction to `resolveExternalEditorLaunchSpec` (`src/main/ipc/shell.ts:101-110`). The builder currently gives every non-Cursor executable only the original path (`src/main/external-editor-launch.ts:109-116`).
The deterministic reproduction for `\\wsl.localhost\Ubuntu\home\aliuq\project` produces `code <UNC path>` with no remote authority. VS Code's supported Windows CLI form is `code --remote wsl+<distro> <Linux path>`.
## Root cause
`resolveExternalEditorLaunchSpec` does not distinguish a VS Code launch targeting a WSL UNC workspace. Orca already has a shared parser for both modern `\\wsl.localhost\...` and legacy `\\wsl$\...` paths (`src/shared/wsl-paths.ts:1-20`), but the editor launcher never uses it. VS Code therefore receives the Windows-visible UNC folder and correctly opens it as a local Windows workspace.
## Non-goals
- Change the Open in menu, settings UI, IPC payload, file-manager behavior, or SSH/remote-runtime behavior.
- Infer WSL identity for ordinary drive-letter paths from project runtime settings.
- Rewrite user-defined compound shell commands or add remote flags to Cursor, VSCodium, or arbitrary editors.
- Install or configure the VS Code WSL extension.
## Design
1. In the external-editor launch-spec builder, parse the target path with the shared WSL UNC parser when the host platform is Windows.
2. For direct/executable VS Code Stable or Insiders launchers only, reuse the existing normalized launcher-basename check and translate a recognized WSL target into `['--remote', 'wsl+<distro>', '<linuxPath>']`. The exact allowlist recognizes Stable's `code`, Insiders' `code-insiders`, and the direct `Code - Insiders.exe` basename without matching unrelated `code-*` editors. Matching is case-insensitive and strips every Windows launcher suffix already supported by Orca (`.cmd`, `.exe`, and `.bat`).
3. Keep local paths, non-Windows hosts, non-VS-Code applications, and compound commands on their existing argument paths. The existing main-process spawn and Windows shim handling remain unchanged.
4. Replace the temporary reproduction harness with focused regression cases in `src/main/external-editor-launch.test.ts` covering modern and legacy WSL UNC forms plus unaffected local/custom-editor behavior.
## Data flow
- Worktree menu selects VS Code and sends `(workspacePath, 'code')` over existing IPC.
- Main process validates the absolute existing host path.
- Launch-spec builder resolves the VS Code executable.
- On Windows + WSL UNC + VS Code, the builder emits the Remote - WSL authority and Linux-native folder path.
- Existing Windows spawn wrapping launches VS Code with those arguments.
## Edge cases
- Modern `\\wsl.localhost\<distro>\...` and legacy `\\wsl$\<distro>\...` paths both preserve the distro spelling and convert separators to a POSIX path.
- Distro names and Linux folder paths containing spaces remain single arguments because launch-spec construction and Windows shim wrapping preserve argument-array boundaries.
- A WSL distro root maps to `/`.
- Windows drive-letter and ordinary UNC paths stay local.
- macOS/Linux behavior stays unchanged even for strings that resemble WSL UNC paths.
- Cursor retains `--new-window`; other custom editors retain their existing single path argument.
- Compound commands remain user-owned and are not rewritten because inserting flags safely would require parsing arbitrary shell syntax.
- SSH and remote-runtime workspaces remain blocked from local path opening by the existing renderer guard; this change does not alter that boundary.
- If the VS Code WSL extension is unavailable, launch behavior is left to VS Code and Orca retains its existing spawn-success contract.
## Test plan
- Unit: demonstrate the pre-fix launch spec lacks `--remote` for a modern WSL UNC path.
- Unit: assert the fixed modern UNC launch is `--remote`, `wsl+Ubuntu`, `/home/...`.
- Unit: assert legacy `\\wsl$` and distro-root paths produce the same Remote - WSL form.
- Unit: assert direct and resolved Windows VS Code Stable and Insiders launchers are matched case-insensitively across `.exe`, `.cmd`, and `.bat` suffixes.
- Unit: assert distro names and Linux folder paths containing spaces remain intact arguments through launch-spec construction and Windows shim forwarding.
- Unit: assert a Windows local path remains unchanged and explicit `darwin` and `linux` hosts do not acquire WSL remote arguments.
- Unit: assert Cursor and another custom editor are not given VS Code remote arguments.
- Integration/Electron: create a throwaway repo in the installed Ubuntu WSL distro, add/open it in Orca, choose Open in VS Code, and verify the VS Code remote indicator and an integrated-terminal Linux probe.
- Adjacent smoke: open a local Windows workspace in VS Code and verify it remains a local Windows window.
- Repository gates: focused Vitest files, `pnpm typecheck`, `pnpm lint`, and `pnpm check:max-lines-ratchet`.
## UI quality bar
Not UI-visible in Orca. The existing menu, labels, loading behavior, and errors do not change. The user-visible acceptance criterion is external: the launched VS Code window must identify the selected WSL distro and its terminal must run Linux.
## Review screenshots
1. Golden path: VS Code opened from a throwaway WSL workspace, showing the Remote - WSL indicator and a terminal Linux probe.
2. Adjacent local path: VS Code opened from a local Windows workspace, showing a local Windows terminal/environment.
## Rollout
1. Add launch-spec regression tests and observe the WSL case fail against the desired arguments.
2. Add the scoped VS Code + Windows + WSL argument translation.
3. Run focused and repository-wide static/test gates.
4. Validate WSL and local launches end to end, retaining local screenshots outside the PR.
## Lightweight Eng Review
- Scope: Kept to the launch-spec seam; no renderer, IPC, persistence, runtime-routing, or settings changes are needed because WSL filesystem identity is encoded in the UNC path.
- Architecture/data flow: Reuse `parseWslUncPath` in the main-process pure argument builder, then leave executable resolution, Windows shim wrapping, spawn lifecycle, and renderer guards unchanged.
- Failure modes covered:
- Modern and legacy WSL UNC aliases route to the correct distro and Linux path.
- Local/ordinary UNC paths and non-Windows platforms do not acquire remote flags.
- Case variants and supported Windows VS Code shim suffixes are recognized without classifying custom editors or compound shell commands as VS Code.
- Distro and folder names containing spaces survive the Windows shim as distinct arguments.
- Missing VS Code WSL support still follows the existing launch contract rather than adding a new partial-failure protocol.
- Test coverage required:
- Pure launch-spec regression cases in `src/main/external-editor-launch.test.ts` for WSL aliases/root, launcher case/suffix variants, and unaffected branches.
- `src/main/ipc/shell.test.ts` plus the existing Windows shim contract to prove remote arguments, including spaces, are forwarded as distinct values.
- Live Windows + Ubuntu WSL + VS Code smoke for the actual environment boundary.
- Performance/blast radius: One regex parse per external-editor click only; no startup, polling, watcher, terminal, or renderer cost. Blast radius is limited to direct VS Code launches of WSL UNC paths on Windows.
- UI quality bar: Not UI-visible in Orca; VS Code must visibly attach to the requested WSL distro and run a Linux terminal.
- Required review screenshots:
1. WSL VS Code window with distro indicator and Linux terminal probe.
2. Local Windows VS Code window demonstrating unchanged local launch behavior.
- Residual risks: VS Code without the WSL extension may reject or prompt on the valid remote launch; this is external dependency behavior and should not cause Orca to fall back silently to the wrong Windows environment.
-79
View File
@@ -1,79 +0,0 @@
# New Worktree Sidebar Reveal
## Problem
Issue https://github.com/stablyai/orca-internal/issues/350 asks that newly created worktrees jump into view in the left sidebar list with no scroll animation.
Current behavior:
- `activateAndRevealWorktree(...)` always calls `state.revealWorktreeInSidebar(worktreeId)` with no options.
- `revealWorktreeInSidebar` defaults `behavior` to `'smooth'` (`ui.ts`).
- `WorktreeList` forwards that behavior to `virtualizer.scrollToIndex(..., { behavior })`.
Result: off-screen targets animate by default, including freshly created worktrees.
## Root Cause
`activateAndRevealWorktree` conflates two intents:
1. activate existing worktree navigation (smooth reveal is fine);
2. activate a just-created worktree (must jump immediately).
Created-worktree callers cannot currently express reveal intent, so they inherit `'smooth'`.
## Scope and Non-goals
- Add an opt-in reveal behavior at activation call sites.
- Apply `'auto'` only where the worktree is newly created/added in that flow.
- Preserve existing behavior for normal worktree navigation (clicks, keyboard, history nav, palette selection of existing worktrees, port/status-driven activation) unless a caller opts in.
- Do not change direct raw reveal paths in IPC handlers that intentionally call `store.revealWorktreeInSidebar(...)` outside `activateAndRevealWorktree` (terminal/editor/mobile focus paths remain smooth).
- Do not change sorting/grouping/filter UI, list virtualization strategy, or sidebar styling.
## Design
1. Extend `activateAndRevealWorktree` options:
- `sidebarRevealBehavior?: PendingSidebarWorktreeReveal['behavior']`.
2. In `activateAndRevealWorktree`, call:
- `state.revealWorktreeInSidebar(worktreeId, { behavior: opts.sidebarRevealBehavior })` when provided;
- otherwise keep `state.revealWorktreeInSidebar(worktreeId)` so default behavior stays unchanged.
3. Pass `sidebarRevealBehavior: 'auto'` only from created/added-worktree flows:
- `useComposerState` full-create path;
- `useComposerState` quick-create path;
- `useIpcEvents` `onActivateWorktree` only when the event corresponds to a newly created worktree;
- `launch-work-item-direct`;
- folder add/create flows that activate a newly-added synthetic folder worktree (`AddRepoCreateStep` folder branch, `NonGitFolderDialog`, and `repos` slice `addNonGitFolder` path).
4. Keep existing navigation activations smooth, including:
- `AddRepoDialog` / `ProjectAddedDialog` “open primary worktree” actions (these can target pre-existing worktrees, not guaranteed newly created);
- all existing `activateAndRevealWorktree(...)` callers that do not opt in.
5. Keep `WorktreeList` reveal effect unchanged; it already honors `pendingRevealWorktree.behavior`.
## Correctness Notes and Edge Cases
- Repo-filter clearing remains unchanged: `activateAndRevealWorktree` only clears `filterRepoIds` when target repo is excluded.
- Other visibility constraints are not auto-cleared. If the target exists but is hidden by other sidebar state, `resolvePendingSidebarReveal(...)` keeps the reveal pending.
- The reveal effect uncollapses lineage/group containers before scroll; behavior changes only animation mode, not visibility resolution.
- Pending reveal is a single store slot (`pendingRevealWorktree`). Concurrent reveal requests are last-writer-wins; this change should not alter that behavior.
- If activation cannot resolve the worktree (`getKnownWorktreeById` miss), behavior remains unchanged (`false`, no reveal queued).
- `ui:activateWorktree` is an overloaded IPC used by both creation and non-creation activation paths. The renderer must choose `'auto'` only for create cases (for example, worktree absent before fetch and present after fetch), and keep default smooth reveal for existing-worktree activations.
- Multi-window consistency remains per renderer window store; each window applies its own reveal behavior locally.
- This change is renderer-only; it does not add main-process coordination and does not make reveal ordering transactional across concurrent async creators.
## Tests
Add/adjust focused tests in `worktree-activation` coverage:
- explicit `sidebarRevealBehavior: 'auto'` is forwarded to `revealWorktreeInSidebar(worktreeId, { behavior: 'auto' })`;
- no option still calls `revealWorktreeInSidebar(worktreeId)` (store default remains smooth).
Add call-site regression tests (recommended, small):
- one composer create path passes `'auto'`;
- one non-created navigation path stays default (no behavior option).
## Rollout
1. Add `sidebarRevealBehavior` option in `activateAndRevealWorktree`.
2. Update created-worktree callers to pass `'auto'`.
3. Add tests above.
4. Run targeted Vitest tests, then `pnpm typecheck` and `pnpm lint`.
5. Validate in Electron: with sidebar overflow, create a worktree and verify the list jumps to it without smooth animation.
+2 -2
View File
@@ -36,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.22/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.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,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.22/app-release.apk)
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,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.22/app-release.apk)
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -2
View File
@@ -36,7 +36,7 @@
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,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.22/app-release.apk)
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
+2 -6
View File
@@ -36,7 +36,7 @@
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
- **Android:** [Baixar APK 0.0.22](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/app-release.apk)
- **Android:** [Baixar APK 0.0.25](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -238,10 +238,6 @@ Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
- **Discord:** Entre na comunidade no **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Siga **[@orca_build](https://x.com/orca_build)** para atualizações e anúncios.
- **WeChat:** Escaneie o QR code para entrar na comunidade.
<img src="../assets/wechat-qr.png" alt="QR code do WeChat para a comunidade Orca" width="160" />
- **Feedback e ideias:** Lançamos rápido. Sentiu falta de algo? [Peça um novo recurso](https://github.com/stablyai/orca/issues).
- **Privacidade:** Veja a [documentação de privacidade e telemetria](https://www.onorca.dev/docs/telemetry) para saber quais dados anônimos de uso o Orca coleta e como desativar.
- **Mostre apoio:** Dê uma [estrela](https://github.com/stablyai/orca) neste repositório para acompanhar nossos lançamentos diários.
+7 -2
View File
@@ -36,7 +36,7 @@
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.22/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.25/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
</td>
<td width="50%">
@@ -227,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.22/app-release.apk)
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.25/app-release.apk)
---
@@ -235,6 +235,11 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
<img src="../assets/wechat-qr.png" alt="Orca 社区微信群二维码" width="160" />
<img src="../assets/wechat-qr-backup.jpg" alt="Orca 社区备用微信群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
- **支持我们:** 给这个仓库点 [Star](https://github.com/stablyai/orca),关注我们的日常发布。
@@ -0,0 +1,368 @@
# Source Control Push Failure AI Recovery
## Problem
Push, force-push, publish, and sync push-stage failures flow through the shared
remote operation formatter in `src/renderer/src/lib/source-control-remote-error.ts:94`
and the editor-store toast paths in `src/renderer/src/store/slices/editor.ts:3716`
and `src/renderer/src/store/slices/editor.ts:3821`. A local pre-push hook or
lint hook is not an auth, protected-branch, or transport problem, so generic
remote guidance sends the user in the wrong direction.
PR #7787 proves the behavior is useful, but the prototype needs tightening before
it is maintainable:
- `src/renderer/src/components/right-sidebar/SourceControl.tsx:1938` and
`src/renderer/src/components/right-sidebar/SourceControl.tsx:5799` repeat
push-failure predicates and feed live branch/file state into a prompt that says
"at failure time".
- `src/renderer/src/components/right-sidebar/use-source-control-recovery-ai.ts:63`
imports push detection again instead of consuming one derived recovery model.
- `src/renderer/src/components/right-sidebar/source-control-ai-commit-failure-launch.ts:24`
and `src/renderer/src/components/right-sidebar/source-control-ai-push-failure-launch.ts:24`
are copied launch flows with action-specific copy.
- `src/shared/source-control-commit-failure-agent-command.ts:6` and
`src/shared/source-control-push-failure-agent-command.ts:6` are identical
command-template builders.
- `src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx:21`
already contains a reusable split button, while `SourceControl.tsx:6415`
carries a second inline version.
- `src/renderer/src/lib/source-control-remote-error.ts:148` currently treats
any `isSync` error as push-like for hook detection. `syncBranch` calls the
formatter for fetch, upstream-status, pull, and push failures, so the renderer
cannot prove a sync error came from the push stage without an explicit marker.
- `src/renderer/src/components/right-sidebar/SourceControl.push-failure-recovery.test.ts:2`
imports prompt helpers through the large React module instead of shared prompt
modules.
## Goal
Add first-class recovery for pre-push hook failures without growing a parallel
push-only subsystem:
1. Toasts for push, force-push, publish, and sync push-stage failures say
"blocked" for detected pre-push or lint-hook output.
2. Source Control shows a concise Push blocked panel with Details and AI Fix
only for a failure snapshot that is both push-like and hook-like.
3. AI Fix launches the configured `fixPushFailure` Source Control action with a
safe, provider-neutral prompt.
4. Commit-failure recovery and push-failure recovery share launch,
command-template, split-button, and dense error-panel primitives.
## Non-goals
- Do not fix server-side pre-receive hooks, hosted CI failures, or provider-side
protected-branch failures from this entry point.
- Do not bypass hooks, add `--no-verify`, push from the launched agent, create a
PR, or assume GitHub-specific terminology.
- Do not add app-wide persistence for this transient failure panel.
- Do not redesign the Source Control panel or the Source Control AI settings
model.
- Do not fold unrelated launch surfaces such as checks recovery into this change
unless a tiny shared helper is already needed for commit/push recovery.
## Design
1. Keep push-hook detection and prompt building in shared code.
- `src/shared/source-control-push-failure.ts` owns normalization, bounded
scanning, summary text, Details eligibility, prompt truncation, prompt
rules, and changed-file list bounding.
- Detection must stay conservative: explicit `pre-push`/`prepush`,
`hook declined to push`, hook-runner output in push context, or lint output
in push context. Auth, non-fast-forward, protected branch, pre-receive,
submodule, and generic transport failures stay on the existing remote-error
path.
- Keep the output scan bounded at 64 KiB and the prompt failure-output
section bounded. Also cap prompt file lines to a small constant with an
omitted-count line; do not rely on the git-status cap, which is far too high
for prompt context.
2. Make remote-error formatting sync-stage aware.
- Extend `RemoteOperationErrorOptions` with an explicit push-stage flag for
sync, for example `isSyncPushStage`.
- Gate push-hook classification on `isPush`, `isForcePush`, `publish`, or
`isSyncPushStage`; do not use bare `isSync` as evidence of a push failure.
- In `syncBranch`, pass the push-stage flag only inside the two inner
`pushRuntimeGit` catch blocks. The outer fetch, upstream-status, and pull
catches still pass `isSync` for Sync-shaped generic copy but must not render
blocked hook copy.
- Carry the same push-stage fact back to the renderer, either by tagging the
thrown `Error` with a narrow exported marker or by wrapping it in a typed
error that preserves the original message/cause. `SourceControl` must set
`syncPushStage` only from that marker, not from `kind === 'sync'` plus
hook-like stderr.
- Keep submodule push messages before hook detection so submodule guidance
remains more specific than AI recovery.
3. Replace one-off command builders with one shared recovery command builder.
- Add a shared builder that accepts `{ actionId, promptOverride,
commandInputTemplate, basePrompt }` and renders the existing Source Control
AI command template for launch actions.
- Keep compatibility exports for commit and push helpers if nearby tests or
call sites still use those names, but make them wrappers over the generic
builder.
- Move prompt-builder tests to import from shared modules, not
`SourceControl.tsx`.
4. Capture and derive push recovery state once.
- Extend `SourceControlActionError` with the raw error, a push-stage marker
for sync failures, the branch name at failure time, a bounded status-entry
snapshot at failure time, and a per-worktree sequence token.
- `runRemoteAction` clears the worktree's previous error and increments its
sequence before starting. A catch may write an error only if its sequence
still owns that worktree, so a slow failure cannot overwrite a newer retry
or success.
- Add a focused renderer helper that accepts the captured
`SourceControlActionError` and the current branch name, then returns either
`null` or a model with raw/sanitized detail text, summary, details flag,
kind label, and AI prompt.
- The helper returns `null` unless the operation is `push`, `force_push`,
`publish`, or `sync` with the sync push-stage marker and
`isPushHookFailure(rawError)` passes.
- If the current branch is known and differs from the captured branch, hide or
clear the model instead of launching an agent with wrong branch context.
- Pass that single model to `useSourceControlAi` and `CommitArea`; neither
prop construction nor `CommitArea` should repeat the push-hook predicate.
5. Use one generic recovery launcher for commit and push failures.
- Replace the separate commit and push launch files with
`source-control-ai-recovery-launch.ts`, or keep the old files as one-line
wrappers if import stability is cheaper.
- The generic launcher owns connection resolution, SSH/local agent discovery,
saved-agent validation, agent-args validation, agent selection, terminal
launch, focus, and success/failure toasts.
- Resolve connection without collapsing local and unresolved states: read
`const worktreeConnectionId = getConnectionId(worktreeId)`, use it when it
is a string or `null`, and only fall back to `sourceRepoConnectionId` when
the worktree lookup returns `undefined`. If the fallback is also
`undefined`, show the workspace-connection error instead of launching
locally. `null` means proven local; a string means SSH/remote.
- Validate saved CLI args before agent detection or terminal creation. On
Windows, keep using PowerShell-safe planning through
`planAgentCliArgsSuffix(..., 'powershell')`.
- Action-specific data is limited to action id, base prompt, empty-prompt
copy, unavailable-agent copy, and success copy.
6. Keep the recovery hook thin.
- `use-source-control-recovery-ai.ts` builds the commit prompt, accepts the
already-derived push recovery prompt/model, keeps independent loading
flags, and calls the generic launcher.
- It should not import push-hook detection, resolve agents, or duplicate
launch plumbing.
7. Extract and reuse the recovery UI.
- Reuse or replace the existing
`src/renderer/src/components/right-sidebar/source-control-fix-split-button.tsx`
rather than adding another split-button component.
- Move the dense recovery notice and Details dialog into focused renderer
modules named for source-control recovery. Avoid `helpers`, `utils`, and new
max-lines suppressions.
- Reuse the same notice/dialog component for commit and push recovery with
action-specific labels, summary, details, prompt, saved recipe, and launch
callback.
- Keep normal remote errors filtered out when a push recovery model is
rendered, so the user does not see both Push blocked and generic remote
error copy for the same failure.
8. Retain `fixPushFailure` in the existing Source Control AI action model.
- The prototype already adds `fixPushFailure` to
`src/shared/source-control-ai-actions.ts`; keep it as a launch action like
`fixCommitFailure`.
- Verify default settings, normalizers, global settings rows, repository
override rows, labels, descriptions, and variable chips all include Push
failure fixes.
- Do not create a push-specific settings store or migration. Existing
`normalizeSourceControlAiSettings` hydrates missing action defaults.
## Data Flow
- User clicks Push, Force Push, Publish Branch, or Sync.
- `runRemoteAction` clears that worktree's previous remote error, records a new
sequence token, and starts the editor-store remote action.
- Git push fails and throws.
- `pushBranch` or sync's inner push-stage catch calls
`resolveRemoteOperationErrorMessage` with push-like options. Sync fetch, pull,
and upstream-status failures do not pass the push-stage flag.
- Sync's inner push-stage catch marks the rethrown error as push-stage so
`runRemoteAction` can preserve `syncPushStage` in the captured snapshot.
- The formatter classifies hook output before auth/transport fallbacks only for
push-like options and shows blocked toast copy.
- `SourceControl` catches the same error, and if the sequence still owns the
worktree, stores `{ kind, rawError, message, syncPushStage, branchName,
entriesSnapshot }`.
- The push recovery helper derives one `pushRecovery` model from that snapshot.
- `CommitArea` renders normal remote errors or the Push blocked recovery notice.
- AI Fix builds the `fixPushFailure` command input and launches the selected
agent in the owning local or SSH runtime.
## Edge Cases
- Auth, missing repo, protected-branch, pre-receive, non-fast-forward, and
transport failures must not show the Push blocked panel.
- Submodule push errors keep their existing specialized messages before
push-hook detection.
- Sync failures from fetch, upstream-status, or pull stages must not show Push
blocked, even if stderr contains words like "lint" or "hook".
- Create PR intent remote failures use the same `runRemoteAction` plumbing; if
CommitArea is not visible, blocked toast copy is still required but no hidden
panel work is needed.
- A newer remote operation for the same worktree must clear stale recovery state
immediately and prevent an older in-flight failure from writing after it.
- Switching worktrees or branches must not show another worktree or branch's
push failure.
- External edits or another Orca window may make the failure stale. Do not add
cross-window persistence for this feature; rely on local retry/remount/branch
mismatch clearing and list this as residual risk.
- Source Control AI hidden for a repo still shows the blocked notice and Details;
it hides AI Fix.
- SSH worktrees must detect and launch agents on the owning connection, not a
local fallback.
- Windows launch planning must continue to use PowerShell-safe agent args.
- Very large hook output and very large changed-file sets must be bounded before
prompt generation.
- ANSI/control output must not leak into summaries or Details comparison logic.
Details may show sanitized text while preserving enough line breaks for
debugging.
- Empty custom command templates must stay empty so the launcher rejects them
with a clear settings error.
- Prompt text must treat file paths, branch names, and hook output as data, not
instructions.
## Test Plan
- Shared unit tests:
- `src/shared/source-control-push-failure.test.ts` covers detection positives,
auth/protected/pre-receive/non-fast-forward negatives, ANSI/control
stripping, bounded scanning, details comparison, prompt output truncation,
prompt file-list capping, and provider-neutral prompt rules.
- Shared command-template tests cover the generic recovery builder plus
commit/push wrappers, including empty templates and prompt overrides.
- `src/shared/source-control-ai-actions.test.ts` covers `fixPushFailure`
label, default template, variables, normalization, and action-list inclusion.
- Remote formatter and store tests:
- `src/renderer/src/lib/source-control-remote-error.test.ts` covers push,
force-push, publish, and sync push-stage blocked copy plus sync non-push
stage negatives, auth/non-fast-forward/protected/pre-receive/submodule
regressions.
- `src/renderer/src/store/slices/editor.test.ts` covers the `pushBranch` toast
path and both sync push-stage and sync non-push-stage toasts.
- Renderer model and UI tests:
- A focused push recovery derivation test covers operation kind, sync
push-stage marker, branch mismatch, stale sequence ownership, snapshot file
entries, summary, details flag, prompt contents, and ordinary remote errors.
- `CommitArea` tests cover the Push blocked notice, Details dialog, AI Fix
visible/hidden, ordinary remote errors, no duplicate remote error when a push
model exists, and unchanged commit-failure recovery rendering.
- Settings tests cover global and repository Source Control AI action rows
showing Push failure fixes.
- Launcher tests:
- Generic recovery launcher tests cover commit and push action ids, invalid CLI
args before detection, empty template rejection, unavailable saved agent,
local versus SSH agent detection, successful launch/focus, and success copy.
- Checks:
- Run targeted vitest files above, `pnpm typecheck`, `pnpm lint`, and
`pnpm check:max-lines-ratchet`.
## UI Quality Bar
The Source Control recovery UI must match `docs/STYLEGUIDE.md`: monochrome,
quiet, dense, token-based, and consistent with adjacent commit-failure recovery.
Use existing shadcn `Button`, `DropdownMenu`, and `Dialog` primitives, lucide
icons, `card`/`border`/`destructive`/`muted` tokens, and the existing action
recipe row patterns. The Push blocked notice should keep the summary readable at
narrow right-sidebar widths, avoid nested cards, avoid new color values, show
stable split-button geometry while launching, and keep Details as progressive
disclosure for long hook output. Dialogs and dropdowns must remain usable in
light/dark mode and under SSH latency.
## Review Screenshots
1. Source Control after a simulated pre-push hook failure: Push blocked notice
with summary, AI Fix, and Details.
2. Push failure Details dialog showing hook output and Fix with AI.
3. AI Fix customize-launch dialog for `fixPushFailure`.
4. Ordinary push auth/protected/remote failure state: regular remote error, no
Push blocked notice.
5. Sync fetch/pull-stage failure state: Sync-shaped remote error, no Push
blocked notice.
6. Source Control AI hidden for the repo: Push blocked notice and Details, no AI
Fix.
7. Source Control AI settings showing the Push failure fixes launch recipe.
8. Adjacent commit-failure recovery notice still rendering correctly after the
shared UI extraction.
## Rollout
1. Tighten shared push-failure classifier/prompt bounds and add the generic
recovery command builder.
2. Make remote formatter/store sync-stage aware and update toast tests.
3. Add the `SourceControlActionError` snapshot/sequence data and one push
recovery derivation helper.
4. Replace duplicated commit/push launchers with the generic recovery launcher
and thin hook wiring.
5. Extract/reuse source-control recovery UI components, including the existing
split-button module.
6. Verify `fixPushFailure` settings/default/normalizer coverage and i18n labels.
7. Update targeted tests and run typecheck, lint, and max-lines ratchet.
8. Validate rendered Electron states with screenshots before opening the PR.
## Lightweight Eng Review
- Scope: Reduced from PR #7787's push-specific parallel path to a shared
recovery layer. Keep the feature limited to local pre-push/lint-hook detection,
blocked copy, an inline recovery notice, Details, and AI launch. No persistence
or provider-specific recovery.
- Architecture/data flow: Shared code owns classification, prompt text, and
command-template rendering; the editor store owns stage-aware toast formatting;
`SourceControl` owns transient worktree-scoped failure snapshots and sequence
invalidation; the generic launcher owns local/SSH agent discovery and terminal
launch.
- Failure modes covered:
- Sync stage ambiguity: only the inner push stage may classify as hook-blocked.
- Auth/protected/pre-receive/non-fast-forward/submodule/transport errors stay
out of push recovery.
- Branch or worktree switches suppress stale recovery models.
- Per-worktree sequence tokens prevent older in-flight failures from
overwriting newer retries or successes.
- Empty custom templates, invalid CLI args, unavailable saved agents, missing
worktree context, SSH host detection, and Windows shell planning fail before
terminal launch.
- Large or hostile hook output and file lists are bounded and treated as data
in prompts.
- Test coverage required:
- Shared classifier/prompt/command/action-model tests in `src/shared`.
- Remote formatter and editor-store tests for push, force-push, publish, sync
push-stage, and sync non-push-stage paths.
- Renderer derivation and `CommitArea` tests for Push blocked, Details, AI Fix,
hidden AI actions, stale branch/sequence, ordinary remote errors, and
existing commit failure recovery.
- Generic launcher tests for local/SSH, invalid args, saved-agent unavailable,
empty template, and success launch.
- Settings tests for global and repo Push failure fixes rows.
- Electron validation for all required screenshots; no separate E2E is
required if renderer tests cover the model and Electron validates the real UI.
- Performance/blast radius: No polling, watchers, migrations, or startup work.
Runtime cost is one bounded classifier pass over at most 64 KiB, bounded prompt
output, and a bounded file snapshot only when a remote action fails. Largest
blast radius is the shared recovery UI and launcher used by existing commit
failures, so commit recovery needs tests and a screenshot.
- UI quality bar: Match `docs/STYLEGUIDE.md` and adjacent Source Control density:
token-based error state, stable split-button geometry, progressive Details
disclosure, no nested cards, concise copy, light/dark compatibility, and
visible disabled/loading state under SSH latency.
- Required review screenshots:
1. Push blocked notice after pre-push hook failure.
2. Push failure Details dialog with Fix with AI.
3. `fixPushFailure` customize-launch dialog.
4. Ordinary push auth/protected/remote failure with no Push blocked panel.
5. Sync non-push-stage failure with no Push blocked panel.
6. Source Control AI hidden: Push blocked without AI Fix.
7. Source Control AI settings showing Push failure fixes.
8. Existing commit-failure recovery notice after shared UI extraction.
- Residual risks: Classifier false positives remain possible for unusual local
hook-runner output that mentions push context; keep tests biased toward
preserving auth, protected-branch, and non-fast-forward behavior. Failure
panels are renderer-local, so another Orca window or an external terminal can
fix the problem while the old panel remains visible until local retry, remount,
or branch/worktree change. Electron validation may need a mocked or temporary
repo scenario to trigger a real pre-push hook without mutating user data.
+5 -1
View File
@@ -2,6 +2,7 @@ import { resolve } from 'node:path'
import { defineConfig } from 'electron-vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard'
// Why: the telemetry transport is gated by two compile-time constants that
// only the official CI release workflow sets. Contributor / `pnpm dev` /
@@ -182,13 +183,16 @@ export default defineConfig({
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
// Why: electron-vite cleans out/main in dev. The dev CLI imports
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
},
plugins: [createStartupDiagnosticsBootstrapPlugin()]
plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()]
}
},
// Why: compile-time substitution for the telemetry gate. See the block
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.24",
"version": "0.0.25",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 5
"versionCode": 6
},
"plugins": [
"expo-router",
+9
View File
@@ -102,6 +102,14 @@ export default function RootLayout() {
return
}
handledNotificationIdsRef.current.add(notificationId)
// Why: RootLayout never unmounts, so cap this tap-dedup set (FIFO) rather
// than letting it grow one id per notification tapped for the app's life.
if (handledNotificationIdsRef.current.size > 256) {
const oldest = handledNotificationIdsRef.current.values().next().value
if (oldest !== undefined) {
handledNotificationIdsRef.current.delete(oldest)
}
}
const path = await getNavigationPath(response.notification.request.content.data)
clearLastNotificationResponse()
@@ -169,6 +177,7 @@ export default function RootLayout() {
<Stack.Screen name="voice-settings" options={{ headerShown: false }} />
<Stack.Screen name="notifications" options={{ headerShown: false }} />
<Stack.Screen name="troubleshoot" options={{ headerShown: false }} />
<Stack.Screen name="connection-log" options={{ headerShown: false }} />
<Stack.Screen name="about" options={{ headerShown: false }} />
<Stack.Screen name="h" options={{ headerShown: false }} />
</Stack>
+221
View File
@@ -0,0 +1,221 @@
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
import { View, Text, StyleSheet, Pressable, Platform } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import * as Clipboard from 'expo-clipboard'
import Constants from 'expo-constants'
import { ChevronLeft, Copy, Check } from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { loadHosts } from '../src/transport/host-store'
import { connectionLogStore } from '../src/transport/connection-log-buffer'
import {
useHostClient,
useLastConnectedAt,
useReconnectAttempt
} from '../src/transport/client-context'
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
// Why: getSnapshot must be referentially stable when there's no data —
// a fresh [] per call would make useSyncExternalStore re-render forever.
const EMPTY_ENTRIES: readonly ConnectionLogEntry[] = []
// Why: reading the log is most needed while a host is failing, so this
// screen also *acquires* the host client — opening it kicks a dial and the
// log fills live instead of showing a stale tail.
export default function ConnectionLogScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [selectedId, setSelectedId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
useEffect(() => {
let stale = false
void loadHosts().then((loaded) => {
if (stale) {
return
}
setHosts(loaded)
setSelectedId((prev) => prev ?? loaded[0]?.id ?? null)
})
return () => {
stale = true
}
}, [])
const selected = hosts.find((h) => h.id === selectedId) ?? null
const { state } = useHostClient(selected?.id)
const reconnectAttempts = useReconnectAttempt(selected?.id)
const lastConnectedAt = useLastConnectedAt(selected?.id)
const subscribe = useCallback(
(listener: () => void) =>
selectedId ? connectionLogStore.subscribe(selectedId, listener) : () => {},
[selectedId]
)
const getSnapshot = useCallback(
() => (selectedId ? connectionLogStore.get(selectedId) : EMPTY_ENTRIES),
[selectedId]
)
const entries = useSyncExternalStore(subscribe, getSnapshot)
const copyDiagnostics = useCallback(async () => {
if (!selected) {
return
}
const report = buildConnectionDiagnosticsReport({
hostName: selected.name,
endpoint: selected.endpoint,
state,
reconnectAttempts,
lastConnectedAt,
platform: `${Platform.OS} ${Platform.Version ?? ''}`.trim(),
appVersion: Constants.expoConfig?.version ?? 'unknown',
entries
})
await Clipboard.setStringAsync(report)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [selected, state, reconnectAttempts, lastConnectedAt, entries])
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Connection log</Text>
</View>
{hosts.length > 1 && (
<View style={styles.hostPicker}>
{hosts.map((host) => (
<Pressable
key={host.id}
style={[styles.hostChip, host.id === selectedId && styles.hostChipActive]}
onPress={() => setSelectedId(host.id)}
>
<Text
style={[styles.hostChipText, host.id === selectedId && styles.hostChipTextActive]}
numberOfLines={1}
>
{host.name}
</Text>
</Pressable>
))}
</View>
)}
{selected ? (
<>
<View style={styles.statusRow}>
<Text style={styles.statusText}>
{state}
{reconnectAttempts > 0 ? ` · attempt ${reconnectAttempts}` : ''}
</Text>
<Pressable style={styles.copyButton} onPress={() => void copyDiagnostics()}>
{copied ? (
<Check size={14} color={colors.statusGreen} />
) : (
<Copy size={14} color={colors.textSecondary} />
)}
<Text style={styles.copyButtonText}>{copied ? 'Copied' : 'Copy diagnostics'}</Text>
</Pressable>
</View>
{entries.length > 0 ? (
<ConnectionLog entries={[...entries]} title={selected.name} />
) : (
<Text style={styles.emptyText}>
No connection events yet this session. Events appear as the app dials this host.
</Text>
)}
</>
) : (
<Text style={styles.emptyText}>No paired hosts.</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.lg
},
backButton: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.sm
},
heading: {
fontSize: 20,
fontWeight: '700',
color: colors.textPrimary
},
hostPicker: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md
},
hostChip: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderRadius: 16,
backgroundColor: colors.bgRaised
},
hostChipActive: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
hostChipText: {
fontSize: typography.metaSize,
color: colors.textSecondary,
maxWidth: 160
},
hostChipTextActive: {
color: colors.textPrimary,
fontWeight: '600'
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm
},
statusText: {
fontSize: typography.metaSize,
color: colors.textSecondary
},
copyButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs + 2,
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
borderRadius: 8,
backgroundColor: colors.bgRaised
},
copyButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
emptyText: {
fontSize: typography.metaSize,
color: colors.textMuted,
lineHeight: 18
}
})
+20 -199
View File
@@ -1,207 +1,28 @@
import { useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react-native'
import { useHostClient } from '../../../../src/transport/client-context'
import type { RpcSuccess } from '../../../../src/transport/types'
import { colors, spacing, typography } from '../../../../src/theme/mobile-theme'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from '../../../../src/source-control/mobile-git-history'
import type { GitBranchChangeEntry } from '../../../../../src/shared/types'
import { Redirect, useLocalSearchParams } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
function firstParam(value: string | string[] | undefined): string {
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '')
}
export default function HistoryScreen() {
// History is now a segment of the Source Control hub. This route stays as a thin
// redirect so existing deep links (and any cached navigation) land on the hub with
// the History segment selected.
export default function HistoryRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
}>()
const hostId = firstParam(params.hostId)
const worktreeId = firstParam(params.worktreeId)
const router = useRouter()
const insets = useSafeAreaInsets()
const { client, state: connState } = useHostClient(hostId)
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
useEffect(() => {
let active = true
if (!client || connState !== 'connected' || !worktreeId) {
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
setError(null)
setRows(null)
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
if (active) {
setRows(mapMobileCommitRows(result, Date.now()))
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : 'Failed to load history')
}
}
})()
return () => {
active = false
}
}, [client, connState, worktreeId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && client && !filesById[row.id]) {
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => ({ ...prev, [row.id]: entries }))
})
.catch(() => setFilesById((prev) => ({ ...prev, [row.id]: [] })))
}
},
[client, expanded, filesById, worktreeId]
)
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
const files = filesById[item.id]
const isOpen = expanded === item.id
return (
<View style={styles.commit}>
<Pressable
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
onPress={() => toggleCommit(item)}
>
{isOpen ? (
<ChevronDown size={14} color={colors.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
)}
<View style={styles.commitMain}>
<Text style={styles.commitSubject} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.commitMeta} numberOfLines={1}>
{item.shortId} · {item.author} · {item.relativeTime}
</Text>
</View>
</Pressable>
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
files.map((file) => (
<View key={file.path} style={styles.fileRow}>
<Text style={styles.filePath} numberOfLines={1}>
{file.path}
</Text>
<Text style={styles.fileStat}>
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
</Text>
</View>
))
)}
</View>
) : null}
</View>
)
},
[expanded, filesById, toggleCommit]
)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} accessibilityLabel="Back">
<ChevronLeft size={22} color={colors.textPrimary} />
</Pressable>
<Text style={styles.title}>Commit History</Text>
</View>
{error ? (
<View style={styles.state}>
<Text style={styles.stateText}>{error}</Text>
</View>
) : rows === null ? (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : rows.length === 0 ? (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
) : (
<FlatList
data={rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + insets.bottom }}
/>
)}
</SafeAreaView>
<Redirect
href={{
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId: firstParam(params.hostId),
worktreeId: firstParam(params.worktreeId),
name: firstParam(params.name),
origin: firstParam(params.origin),
tab: 'history'
}
}}
/>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: colors.bgBase },
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm
},
back: { padding: spacing.xs },
title: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
commitHeaderPressed: { backgroundColor: colors.bgRaised },
commitMain: { flex: 1, minWidth: 0 },
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
commitMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
marginTop: 2
},
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
filePath: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
add: { color: colors.gitDecorationAdded },
del: { color: colors.gitDecorationDeleted },
empty: { color: colors.textMuted, fontSize: typography.metaSize }
})
+11
View File
@@ -552,6 +552,17 @@ export function HostScreen({
}
}, [connState, client])
useFocusEffect(
useCallback(() => {
// Why: opening the host is a strong user signal — reset a backed-off or
// trickling reconnect loop (and probe a possibly half-open socket)
// immediately instead of waiting out its timer. Deps stay empty so this
// fires per focus transition, not per connection-state change; nudging
// on every reconnecting↔connecting flip would defeat the backoff.
clientRef.current?.notifyForeground()
}, [])
)
useFocusEffect(
useCallback(() => {
// The embedded sidebar drives its own polling below; focus never fires
+24 -25
View File
@@ -1,30 +1,29 @@
import { useLocalSearchParams } from 'expo-router'
import { useHostClient } from '../../../../src/transport/client-context'
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { MobilePrViewPanel } from '../../../../src/components/pr-sidebar/MobilePrViewPanel'
// Narrow-layout full-screen PR route. The standalone panel can't ride on the review
// screen's diff state, so branch/head SHA are resolved here from git.status + branchCompare.
export default function MobilePrViewScreen() {
const { hostId, worktreeId } = useLocalSearchParams<{ hostId: string; worktreeId: string }>()
const { client, state: connState } = useHostClient(hostId)
const { branch, headSha, status, isGithubRepo, repoLoaded, loaded } = useMobilePrBranchContext({
client,
connState,
worktreeId
})
import { Redirect, useLocalSearchParams } from 'expo-router'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
// The Pull Request view is now a segment of the Source Control hub. This route
// stays as a thin redirect so existing deep links land on the hub with the Pull
// Request segment selected. The wide-layout dock opens the same hub with
// initialTab="pr" (SessionDockColumn), not this route.
export default function PrRedirect() {
const params = useLocalSearchParams<{
hostId?: string | string[]
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
}>()
return (
<MobilePrViewPanel
client={client}
connState={connState}
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={status}
isGithubRepo={isGithubRepo}
branchContextLoaded={loaded && repoLoaded}
embedded={false}
<Redirect
href={{
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId: firstParam(params.hostId),
worktreeId: firstParam(params.worktreeId),
name: firstParam(params.name),
origin: firstParam(params.origin),
tab: 'pr'
}
}}
/>
)
}
+86 -35
View File
@@ -24,6 +24,7 @@ import {
AlertTriangle,
ArrowUp,
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsRight,
@@ -61,7 +62,10 @@ import {
useReconnectAttempt,
useLastConnectedAt
} from '../../../../src/transport/client-context'
import { classifyConnection } from '../../../../src/transport/connection-health'
import {
classifyConnection,
verdictDisplayLabel
} from '../../../../src/transport/connection-health'
import { useResponsiveLayout } from '../../../../src/layout/responsive-layout'
import {
type ActivePanel,
@@ -72,6 +76,7 @@ import {
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -100,6 +105,7 @@ import {
isTerminalLiveInputWithinByteLimit,
scheduleTerminalLiveInputFocus
} from '../../../../src/terminal/terminal-live-input'
import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss'
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response'
import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit'
@@ -851,20 +857,16 @@ export default function SessionScreen() {
setActivePanel(null)
}
}, [canDockPanel, activePanel])
// Session-level PR context feeds the docked PR panel and gates the GitHub-only
// PR entry so GitLab/other providers do not open a GitHub RPC surface.
const {
branch: prBranch,
headSha: prHeadSha,
status: prStatus,
isGithubRepo: prIsGithubRepo,
repoLoaded: prRepoContextLoaded,
loaded: prContextLoaded
} = useMobilePrBranchContext({
client,
connState,
worktreeId
})
// Session-level GitHub remote probe gates the PR dock icon so non-GitHub
// providers do not open the hosted-review surface. Branch/head/status for the
// hub are loaded inside MobileSourceControlPanel — skip the unused identity RPCs.
const { isGithubRepo: prIsGithubRepo, repoLoaded: prRepoContextLoaded } =
useMobilePrBranchContext({
client,
connState,
worktreeId,
includeBranchIdentity: false
})
useEffect(() => {
if (prRepoContextLoaded && !prIsGithubRepo && activePanel === 'pr') {
setActivePanel(null)
@@ -998,6 +1000,9 @@ export default function SessionScreen() {
const terminalCwdRef = useRef<Map<string, string>>(new Map())
const initialModesSeenRef = useRef<Set<string>>(new Set())
const deviceTokenRef = useRef<string | null>(null)
// Why: state (not a ref) — the connection verdict needs a re-render once
// the endpoint loads so the Tailscale hint can appear.
const [hostEndpoint, setHostEndpoint] = useState<string | null>(null)
const clientRef = useRef<RpcClient | null>(null)
const connStateRef = useRef<ConnectionState>(connState)
// Why: measured once from TerminalWebView on mount, then passed with every
@@ -1006,6 +1011,7 @@ export default function SessionScreen() {
const viewportMeasuredRef = useRef(false)
const terminalRefs = useRef<Map<string, TerminalWebViewHandle>>(new Map())
const liveInputRef = useRef<TextInput>(null)
const commandInputRef = useRef<TextInput>(null)
const liveInputFocusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sendLiveTerminalInputRef = useRef<TerminalLiveInputSender>(async () => false)
const sessionTabActionSheetKeyboardHideSubRef = useRef<ReturnType<
@@ -2450,6 +2456,7 @@ export default function SessionScreen() {
const host = hosts.find((h) => h.id === hostId)
if (host) {
deviceTokenRef.current = host.deviceToken
setHostEndpoint(host.endpoint)
}
})
return () => {
@@ -2684,6 +2691,11 @@ export default function SessionScreen() {
timers.push(setTimeout(fn, ms))
}
void (async () => {
const reportActivationOutcome = (response: RpcSuccess | null): void => {
if (!disposed && response && headlessActivationNeedsHostRenderer(response.result)) {
showToast('Open Orca on the host to wake sleeping agents.', 3000)
}
}
if (client && created !== '1') {
// Why: mobile needs host-owned tabs hydrated for this route, but should
// not pull other paired clients, especially desktop, into this worktree.
@@ -2692,6 +2704,7 @@ export default function SessionScreen() {
worktree: `id:${worktreeId}`,
notifyClients: false
})
.then((response) => reportActivationOutcome(response.ok ? response : null))
.catch(() => null)
}
if (disposed) {
@@ -2713,12 +2726,13 @@ export default function SessionScreen() {
return
}
void (async () => {
await client
const activationResponse = await client
.sendRequest('worktree.activate', {
worktree: `id:${worktreeId}`,
notifyClients: false
})
.catch(() => null)
reportActivationOutcome(activationResponse?.ok ? activationResponse : null)
if (disposed) {
return
}
@@ -2734,7 +2748,7 @@ export default function SessionScreen() {
clearTimeout(t)
}
}
}, [client, connState, created, fetchSessionTabs, fetchTerminals, worktreeId])
}, [client, connState, created, fetchSessionTabs, fetchTerminals, showToast, worktreeId])
useEffect(() => {
if (!client || connState !== 'connected') {
@@ -3208,6 +3222,15 @@ export default function SessionScreen() {
]
)
const dismissSoftwareKeyboard = useCallback(() => {
dismissTerminalKeyboard({
clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef),
commandInput: commandInputRef.current,
dismissKeyboard: () => Keyboard.dismiss(),
liveInput: liveInputRef.current
})
}, [])
const handleTerminalTap = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
@@ -4253,13 +4276,14 @@ export default function SessionScreen() {
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
// Why: the reconnect loop parks at its give-up cap; without an in-session
// affordance the only recovery is leaving the screen or restarting the
// app (issue #5049). Surface tap-to-retry once the verdict escalates.
// Why: the reconnect loop slows to a 90s trickle at its give-up cap;
// surface tap-to-retry once the verdict escalates so recovery doesn't
// wait out the trickle timer (issue #5049).
const connectionVerdict = classifyConnection({
state: connState,
reconnectAttempts,
lastConnectedAt
lastConnectedAt,
endpoint: hostEndpoint
})
const showConnectionRetry =
connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable'
@@ -4272,7 +4296,7 @@ export default function SessionScreen() {
? '1 tab'
: `${visibleTabs.length} tabs`
: showConnectionRetry
? `${connectionVerdict.label} — tap to retry`
? `${verdictDisplayLabel(connectionVerdict)} — tap to retry`
: MOBILE_SESSION_STATUS_LABELS[connState]
// Why: keep safe-area padding in layout at all times, then visually translate
@@ -4411,14 +4435,18 @@ export default function SessionScreen() {
setActivePanel(action.next)
return
}
const descriptor = panelRouteDescriptor(action.panel)
router.push({
pathname: panelRouteDescriptor(action.panel).pathname,
pathname: descriptor.pathname,
params: {
hostId,
worktreeId,
name: worktreeName || '',
// Source control's post-diff-open dismissal keys off origin: 'session' (U2).
...(action.panel === 'sourceControl' ? { origin: 'session' } : {})
// SC + PR both land on the source-control hub; post-diff-open dismissal
// keys off origin: 'session' (U2). Files keeps its own route without origin.
...(action.panel === 'sourceControl' || action.panel === 'pr' ? { origin: 'session' } : {}),
// The PR panel routes into the hub's Pull Request segment via descriptor params.
...descriptor.params
}
})
}
@@ -4780,10 +4808,38 @@ export default function SessionScreen() {
>
{/* Accessory keys */}
<View style={styles.accessoryBar}>
{/* Why: a fixed, always-visible escape hatch from the open
keyboard. Kept outside the horizontal ScrollView so it does
not scroll away, and out of the terminal-byte shortcut path so
it cannot be hidden by user shortcut customization (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: with default tap handling the first tap on any accessory
key dismisses the open keyboard and is swallowed, so live
input lost its keyboard on every Esc/Tab press (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
@@ -5009,6 +5065,7 @@ export default function SessionScreen() {
) : (
<View style={styles.inputBar}>
<TextInput
ref={commandInputRef}
// Why: Android caches the IME inputType at mount, so toggling
// autocomplete must remount there; iOS can update without a focus-costly remount.
key={
@@ -5020,9 +5077,10 @@ export default function SessionScreen() {
}
style={styles.textInput}
value={input}
onChangeText={(text) =>
setInput((previousText) => normalizeTerminalTextInput(text, previousText))
}
// Why: iOS kills an active dictation/IME session when JS
// writes a value that differs from the native field text;
// store the raw field text and normalize at send time.
onChangeText={setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
@@ -5074,13 +5132,6 @@ export default function SessionScreen() {
hostId={hostId}
worktreeId={worktreeId}
name={worktreeName || ''}
client={client}
connState={connState}
branch={prBranch}
headSha={prHeadSha}
gitStatus={prStatus}
isGithubRepo={prIsGithubRepo}
branchContextLoaded={prContextLoaded && prRepoContextLoaded}
availableWidth={sessionContentRowWidth}
onRequestClose={() => setActivePanel(null)}
onFileOpenStart={handleFileOpenStart}
@@ -68,10 +68,16 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
zIndex: 20
},
accessoryBar: {
flexDirection: 'row',
alignItems: 'center',
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
accessoryScroll: {
flex: 1,
minWidth: 0
},
accessoryContent: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
@@ -110,6 +116,29 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
accessoryKeyTextDisabled: {
color: colors.textMuted
},
keyboardDismissKey: {
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.sm,
marginVertical: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.sm + 2,
paddingVertical: 0,
borderRadius: radii.button,
minWidth: 36,
height: 28
},
keyboardDismissGlyph: {
alignItems: 'center',
height: 18,
justifyContent: 'flex-start',
position: 'relative',
width: 18
},
keyboardDismissChevron: {
bottom: -2,
position: 'absolute'
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',
@@ -1,6 +1,7 @@
import { useLocalSearchParams } from 'expo-router'
import { MobileSourceControlPanel } from '../../../../src/source-control/MobileSourceControlPanel'
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
import { parseSourceControlHubTab } from '../../../../src/source-control/mobile-source-control-hub-tab'
export default function MobileSourceControlScreen() {
const params = useLocalSearchParams<{
@@ -8,6 +9,7 @@ export default function MobileSourceControlScreen() {
worktreeId?: string | string[]
name?: string | string[]
origin?: string | string[]
tab?: string | string[]
}>()
return (
<MobileSourceControlPanel
@@ -15,6 +17,7 @@ export default function MobileSourceControlScreen() {
worktreeId={firstParam(params.worktreeId)}
name={firstParam(params.name)}
origin={firstParam(params.origin)}
initialTab={parseSourceControlHubTab(params.tab)}
embedded={false}
/>
)
+4 -3
View File
@@ -34,7 +34,7 @@ import {
useForceReconnect,
usePrimeHosts
} from '../src/transport/client-context'
import { classifyConnection } from '../src/transport/connection-health'
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
@@ -827,7 +827,8 @@ export default function HomeScreen() {
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
lastConnectedAt
lastConnectedAt,
endpoint: item.endpoint
})
const isError =
verdict.kind === 'warning' ||
@@ -859,7 +860,7 @@ export default function HomeScreen() {
<View style={styles.hostMeta}>
<StatusDot state={state} verdict={verdict} />
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
{verdict.label}
{verdictDisplayLabel(verdict)}
{connected && info
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
: ''}
+20 -68
View File
@@ -14,13 +14,9 @@ import {
ChevronLeft,
ChevronDown,
ChevronUp,
WifiOff,
Shield,
Monitor,
Clock,
Globe,
Activity,
CheckCircle2,
ScrollText,
XCircle,
AlertTriangle
} from 'lucide-react-native'
@@ -30,7 +26,12 @@ import {
startDiagnosticFetchTimeout,
type DiagnosticFetchTimeout
} from '../src/diagnostics/diagnostic-fetch-timeout'
import { formatEndpoint, testHostReachability } from '../src/diagnostics/host-reachability'
import {
formatEndpoint,
testHostReachability,
unreachableHostDetail
} from '../src/diagnostics/host-reachability'
import { troubleshootCommonIssues } from '../src/diagnostics/troubleshoot-common-issues'
type DiagnosticStatus = 'idle' | 'running' | 'done'
@@ -40,66 +41,6 @@ type CheckResult = {
detail: string
}
type TroubleshootSection = {
id: string
icon: React.ReactNode
title: string
steps: string[]
}
const sections: TroubleshootSection[] = [
{
id: 'wifi',
icon: <WifiOff size={16} color={colors.textSecondary} />,
title: 'Different WiFi Networks',
steps: [
'Both devices must be on the same local network.',
'Ethernet and WiFi must share the same subnet.',
'Try reconnecting WiFi on both devices.'
]
},
{
id: 'firewall',
icon: <Shield size={16} color={colors.textSecondary} />,
title: 'Firewall Blocking Port 6768',
steps: [
'macOS: System Settings → Network → Firewall — allow Orca.',
'Windows: Defender Firewall → Allow app — enable Orca for Private networks.',
'Linux: sudo ufw allow 6768',
'Corporate/school networks may block P2P — try a personal hotspot.'
]
},
{
id: 'desktop',
icon: <Monitor size={16} color={colors.textSecondary} />,
title: 'Desktop App Not Running',
steps: [
'Orca must be open on your desktop to accept connections.',
'Try restarting Orca — the companion server starts on launch.',
'After an update, you may need to re-pair via QR code.'
]
},
{
id: 'timeout',
icon: <Clock size={16} color={colors.textSecondary} />,
title: 'Connection Timeout',
steps: [
'Check WiFi signal strength on your phone.',
'Go back to the host list and tap your host to retry.',
'Restart both apps if timeouts persist.'
]
},
{
id: 'vpn',
icon: <Globe size={16} color={colors.textSecondary} />,
title: 'VPN Interference',
steps: [
'VPNs can route local traffic through a remote server.',
'Disable the VPN or enable split tunneling / "Allow LAN".'
]
}
]
function StatusIcon({ status }: { status: CheckResult['status'] }) {
switch (status) {
case 'pass':
@@ -211,7 +152,7 @@ export default function TroubleshootScreen() {
status: reachable ? 'pass' : 'fail',
detail: reachable
? `Reachable at ${formatEndpoint(host.endpoint)}`
: `Cannot reach ${formatEndpoint(host.endpoint)}`
: unreachableHostDetail(host.endpoint)
})
setChecks([...results])
}
@@ -273,6 +214,17 @@ export default function TroubleshootScreen() {
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.diagnosticButton,
pressed && styles.diagnosticButtonPressed
]}
onPress={() => router.push('/connection-log')}
>
<ScrollText size={16} color={colors.textPrimary} />
<Text style={styles.diagnosticButtonLabel}>View connection log</Text>
</Pressable>
{checks.length > 0 && (
<View style={styles.section}>
{checks.map((check, i) => (
@@ -295,7 +247,7 @@ export default function TroubleshootScreen() {
<Text style={styles.sectionHeading}>Common issues</Text>
<View style={styles.section}>
{sections.map((section, i) => (
{troubleshootCommonIssues.map((section, i) => (
<View key={section.id}>
{i > 0 && <View style={styles.separator} />}
<Pressable
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -15,7 +15,7 @@ const MAX_TABLE_COLUMNS = 8
function openMarkdownUrl(url: string): void {
const trimmed = url.trim()
if (/^(https?:|mailto:)/i.test(trimmed)) {
void Linking.openURL(trimmed)
void Linking.openURL(trimmed).catch(() => {})
}
}
+39 -19
View File
@@ -39,6 +39,8 @@ type Props = {
gitStatus: MobileGitStatusResult | null
headSha: string | null
bottomInset?: number
// Hub chrome already shows open-on-web; hide the in-body icon there.
showOpenOnWeb?: boolean
}
// Mutation hooks run unconditionally here and gate internally until a PR is ready.
@@ -52,17 +54,15 @@ export function MobilePRSidebar({
gitBranch,
gitStatus,
headSha,
bottomInset = 0
bottomInset = 0,
showOpenOnWeb = true
}: Props) {
const branch = prSidebarRenderBranch(state)
// prNumber is 0 until ready; the hook gates on `ready` so it never fires early.
const prNumber = state.kind === 'ready' ? state.data.pr.number : 0
const prRepo =
state.kind === 'ready'
? state.data.pr.prRepo
? { owner: state.data.pr.prRepo.owner, repo: state.data.pr.prRepo.repo }
: null
: null
// Prefer the stable PRInfo.prRepo reference — cloning owner/repo each render
// reallocates and thrash-updates the mutation/comment/title hooks.
const prRepo = state.kind === 'ready' ? (state.data.pr.prRepo ?? null) : null
const actions = useMobilePrActions({
client,
connState,
@@ -95,6 +95,9 @@ export function MobilePRSidebar({
style={{ flex: 1 }}
contentContainerStyle={[styles.scrollContent, { paddingBottom: bottomInset }]}
keyboardShouldPersistTaps="handled"
// Why: root-comment / reply composers sit at the bottom of this scroll
// area; without keyboard insets the focused field stays under the keyboard.
automaticallyAdjustKeyboardInsets
showsVerticalScrollIndicator={false}
>
<PrSidebarContent
@@ -111,6 +114,7 @@ export function MobilePRSidebar({
commentActions={commentActions}
titleAction={titleAction}
triage={triage}
showOpenOnWeb={showOpenOnWeb}
/>
</ScrollView>
)
@@ -129,7 +133,8 @@ function PrSidebarContent({
actions,
commentActions,
titleAction,
triage
triage,
showOpenOnWeb
}: {
branch: ReturnType<typeof prSidebarRenderBranch>
state: PrSidebarState
@@ -144,6 +149,7 @@ function PrSidebarContent({
commentActions: MobilePrCommentActions
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
showOpenOnWeb: boolean
}) {
if (branch === 'loading') {
return (
@@ -209,6 +215,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
showOpenOnWeb={showOpenOnWeb}
/>
)
}
@@ -223,7 +230,8 @@ function PrSidebarSections({
commentActions,
titleAction,
triage,
refetch
refetch,
showOpenOnWeb
}: {
data: Extract<PrSidebarState, { kind: 'ready' }>['data']
client: RpcClient | null
@@ -233,6 +241,7 @@ function PrSidebarSections({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
refetch: () => void
showOpenOnWeb: boolean
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -262,19 +271,30 @@ function PrSidebarSections({
isBusy: triage.isBusy('resolve-conflicts'),
error: triage.error
}
// One card for identity + actions so the ready PR isn't a stack of thin
// duplicate blocks (badge row, title, branches, then another action band).
return (
<>
<PRSidebarHeader pr={data.pr} details={data.details} titleAction={titleAction} />
{/* Conflicting-files section mirrors desktop order: directly below the header,
before actions/checks. Renders only when the PR has merge conflicts. */}
<View style={styles.section}>
<View style={styles.sectionBody}>
<PRSidebarHeader
pr={data.pr}
details={data.details}
titleAction={titleAction}
showOpenOnWeb={showOpenOnWeb}
bare
/>
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
</View>
</View>
{/* Own titled section when present; null otherwise (no empty chrome). */}
<PRConflictingFilesSection pr={data.pr} triage={conflictsTriage} />
<PRActionsSection
pr={data.pr}
actions={actions}
client={client}
worktreeId={worktreeId}
onUnlinked={refetch}
/>
<PRReviewersSection
details={data.details}
actions={actions}
@@ -45,5 +45,8 @@ export function MobilePrComposeSheet({
}
export function openMobilePrUrl(url: string): void {
void Linking.openURL(url)
// Why: Linking.openURL rejects when iOS/Android can't open the URL (no app,
// bad scheme, etc.). Without a catch that surfaces as LogBox "Uncaught
// (in promise) Error: Unable to open URL…" over the PR screen.
void Linking.openURL(url).catch(() => {})
}
@@ -1,13 +1,10 @@
import { useEffect } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { ChevronLeft, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { StyleSheet, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { colors } from '../../theme/mobile-theme'
import type { ConnectionState } from '../../transport/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobileGitStatusResult } from '../../source-control/mobile-git-status'
import { useMobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import type { MobilePrSidebarController } from '../../session/use-mobile-pr-sidebar-controller'
import { MobilePRSidebar } from '../MobilePRSidebar'
type Props = {
@@ -19,13 +16,13 @@ type Props = {
gitStatus: MobileGitStatusResult | null
isGithubRepo?: boolean
branchContextLoaded?: boolean
// Embedded (docked) drops the full-screen SafeAreaView chrome and shows a close
// affordance; the dock column owns the safe-area insets. Full-screen otherwise.
embedded?: boolean
onRequestClose?: () => void
controller: MobilePrSidebarController
}
export function MobilePrViewPanel({
// Chromeless PR sidebar body for the source-control hub's Pull Request segment.
// The hub owns the header, segmented control, load triggers, and the shared
// controller (one fetch feeds both the branch-card chip and this body).
export function MobilePrViewPanelBody({
client,
connState,
worktreeId,
@@ -34,32 +31,10 @@ export function MobilePrViewPanel({
gitStatus,
isGithubRepo = true,
branchContextLoaded = true,
embedded = false,
onRequestClose
controller
}: Props) {
const router = useRouter()
const insets = useSafeAreaInsets()
const controller = useMobilePrSidebarController({
client,
connState,
worktreeId,
branch,
headSha
})
// A docked/full-screen PR panel is always visible — there is no drawer to open,
// so trigger the load directly once context is ready rather than gating on the
// showPRSidebar overlay flag (KTD4).
const prSidebarKind = controller.prSidebarState.kind
const refetch = controller.refetchPRSidebar
useEffect(() => {
if (branch && isGithubRepo && prSidebarKind === 'hidden') {
refetch()
}
}, [branch, isGithubRepo, prSidebarKind, refetch])
// Embedded: the dock column applies the bottom inset; full-screen relies on its own
// SafeAreaView (edges top only), so content must clear the home indicator itself.
const sidebarState = !branchContextLoaded
? ({ kind: 'loading' } as const)
: !isGithubRepo
@@ -73,63 +48,24 @@ export function MobilePrViewPanel({
message: 'Current branch unavailable.'
} as const)
: controller.prSidebarState
const sidebar = (
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
/>
)
if (embedded) {
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={onRequestClose}
hitSlop={8}
accessibilityLabel="Close pull request panel"
>
<X size={20} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
</View>
</View>
{sidebar}
</View>
)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<View style={styles.header}>
<View style={styles.topBar}>
<Pressable
style={({ pressed }) => [styles.iconButton, pressed && styles.iconButtonPressed]}
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Back to session"
>
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
<Text style={styles.title} numberOfLines={1}>
Pull Request
</Text>
</View>
</View>
{sidebar}
</SafeAreaView>
<View style={styles.container}>
<MobilePRSidebar
state={sidebarState}
onRetry={controller.retryPRSidebar}
refetch={controller.refetchPRSidebar}
client={client}
connState={connState}
worktreeId={worktreeId}
gitBranch={branch}
gitStatus={gitStatus}
headSha={headSha}
bottomInset={insets.bottom}
// Hub header already hosts open-on-web while this segment is active.
showOpenOnWeb={false}
/>
</View>
)
}
@@ -137,34 +73,5 @@ const styles = 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
},
iconButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
iconButtonPressed: {
backgroundColor: colors.bgRaised
},
title: {
flex: 1,
minWidth: 0,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '600'
}
})
@@ -7,7 +7,6 @@ import type { RpcClient } from '../../transport/rpc-client'
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 { resolveMobilePrMergeMethod, resolvePrActionAvailability } from './pr-actions-state'
import { prActionsStyles as styles } from './pr-actions-styles'
@@ -25,12 +24,13 @@ type Confirm =
| { kind: 'merge'; method: GitHubPRMergeMethod }
| { kind: 'state'; state: 'open' | 'closed' }
// Merge, auto-merge toggle, and close/reopen. Destructive actions route through
// ConfirmModal first (R5). The firing row shows a spinner in place of its icon
// and disables; other rows stay interactive (uniform visual).
// Merge primary; Close/Reopen + Unlink share one secondary row. No section title —
// button labels are self-explanatory and a header wasted a full row on mobile.
export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }: Props) {
const [confirm, setConfirm] = useState<Confirm | null>(null)
const [unlinking, setUnlinking] = useState(false)
// Local unlink errors — unlink is not routed through the actions engine.
const [unlinkError, setUnlinkError] = useState<string | null>(null)
// Mobile keeps merge one-tap: use the repo default instead of surfacing a
// desktop-style method picker in the narrow PR action stack.
@@ -41,23 +41,31 @@ 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 unlinkBusy = unlinking || mergeBusy || autoMergeBusy || stateBusy
const showAutoMerge =
avail.canAutoMerge &&
canShowMobilePRAutoMergeControl({
...pr,
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
})
const showSecondary = avail.canClose || avail.canReopen || avail.canUnlink
const actionError = unlinkError ?? actions.error
const unlink = useCallback(async (): Promise<void> => {
if (!client || unlinking) {
return
}
setUnlinking(true)
setUnlinkError(null)
try {
const outcome = await unlinkMobilePr(client, worktreeId)
if (outcome.ok) {
onUnlinked()
} else {
setUnlinkError(outcome.error)
}
} catch (err) {
setUnlinkError(err instanceof Error ? err.message : 'Failed to unlink pull request.')
} finally {
setUnlinking(false)
}
@@ -89,6 +97,8 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
if (!confirm) {
return
}
// Engine errors take over the shared error line after this; drop unlink text.
setUnlinkError(null)
if (confirm.kind === 'merge') {
actions.merge(confirm.method)
} else {
@@ -99,8 +109,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const copy = confirmCopy()
return (
<PRSection title="Actions">
{/* Merge controls only while the PR can still be merged (open/draft). */}
<View style={styles.actionsBlock}>
{avail.canMerge ? (
<Pressable
style={[
@@ -108,7 +117,10 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
styles.actionButtonMerge,
mergeBusy && styles.actionButtonDisabled
]}
onPress={() => setConfirm({ kind: 'merge', method: effectiveMethod })}
onPress={() => {
setUnlinkError(null)
setConfirm({ kind: 'merge', method: effectiveMethod })
}}
disabled={mergeBusy}
accessibilityRole="button"
accessibilityLabel="Merge pull request"
@@ -124,13 +136,15 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
</Pressable>
) : null}
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{showAutoMerge ? (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
<Pressable
style={[styles.togglePill, autoMerge && styles.togglePillOn]}
onPress={() => actions.setAutoMerge(!autoMerge, effectiveMethod)}
onPress={() => {
setUnlinkError(null)
actions.setAutoMerge(!autoMerge, effectiveMethod)
}}
disabled={autoMergeBusy}
accessibilityRole="switch"
accessibilityState={{ checked: autoMerge }}
@@ -147,49 +161,59 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
</View>
) : null}
{/* Close (open PRs) / Reopen (closed PRs) — confirmed before firing (R5). */}
{avail.canClose || avail.canReopen ? (
<Pressable
style={[styles.actionButton, stateBusy && styles.actionButtonDisabled]}
onPress={() => setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[styles.actionButtonText, avail.canClose && styles.actionButtonDestructiveText]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
{showSecondary ? (
<View style={styles.secondaryRow}>
{avail.canClose || avail.canReopen ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
stateBusy && styles.actionButtonDisabled
]}
onPress={() => {
setUnlinkError(null)
setConfirm({ kind: 'state', state: avail.canClose ? 'closed' : 'open' })
}}
disabled={stateBusy}
accessibilityRole="button"
accessibilityLabel={avail.canClose ? 'Close pull request' : 'Reopen pull request'}
>
{stateBusy ? <ActivityIndicator color={colors.textSecondary} /> : null}
<Text
style={[
styles.actionButtonText,
avail.canClose && styles.actionButtonDestructiveText
]}
>
{avail.canClose ? 'Close' : 'Reopen'}
</Text>
</Pressable>
) : null}
{avail.canUnlink ? (
<Pressable
style={[
styles.actionButton,
styles.secondaryButton,
unlinkBusy && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinkBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
</View>
) : null}
{/* Unlink the PR from this worktree. Disabled while another PR mutation is in
flight so clearing the link can't race a merge/close refetch. */}
{avail.canUnlink ? (
<Pressable
style={[
styles.actionButton,
(unlinking || mergeBusy || autoMergeBusy || stateBusy) && styles.actionButtonDisabled
]}
onPress={() => void unlink()}
disabled={unlinking || mergeBusy || autoMergeBusy || stateBusy}
accessibilityRole="button"
accessibilityLabel="Unlink pull request"
>
{unlinking ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<Link2Off size={16} color={colors.textSecondary} strokeWidth={2.2} />
)}
<Text style={styles.actionButtonText}>Unlink</Text>
</Pressable>
) : null}
{actionError ? <Text style={styles.actionError}>{actionError}</Text> : null}
{actions.error ? <Text style={styles.actionError}>{actions.error}</Text> : null}
{/* A Modal is taken out of the flex flow, so it adds no body gap here. */}
<ConfirmModal
visible={confirm !== null}
title={copy.title}
@@ -199,6 +223,6 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
onConfirm={runConfirmed}
onCancel={() => setConfirm(null)}
/>
</PRSection>
</View>
)
}
@@ -5,6 +5,7 @@ import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/type
import type { GitHubPrRepoSlug } from '../../session/github-pr-rpc'
import { colors } from '../../theme/mobile-theme'
import { canAddRootComment } from '../../session/pr-comment-actions'
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import type { MobilePrCommentActions } from '../../session/use-mobile-pr-comment-actions'
import { PRSection } from './PRSection'
import { CommentMarkdown } from './CommentMarkdown'
@@ -50,10 +51,15 @@ const COMMENT_PAGE = 12
// review comments, reactions, and collapsible resolved threads.
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const body = details?.body ?? ''
const comments = useMemo(() => details?.comments ?? [], [details])
const isPr = details?.item.type === 'pr'
const comments = useMemo(
() => (details && !isPrSidebarDetailsPlaceholder(details) ? details.comments : []),
[details]
)
const isPr = details != null && !detailsFailed && details.item.type === 'pr'
// Per-card action bundle (stable callbacks from the hook) — built once so the
// memo'd cards don't re-render on unrelated timeline changes.
@@ -94,6 +100,10 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
<PRSection title="Description">
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.noDescription}>
Could not load description. Tap refresh to try again.
</Text>
) : body.trim() ? (
<CommentMarkdown content={body} variant="document" />
) : (
@@ -113,6 +123,8 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
>
{loadingDetails ? (
<ActivityIndicator color={colors.textSecondary} />
) : detailsFailed ? (
<Text style={styles.empty}>Could not load comments. Tap refresh to try again.</Text>
) : (
<View style={styles.list}>
{comments.length === 0 ? (
@@ -5,6 +5,7 @@ import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails } from '../../../../src/shared/types'
import type { RpcClient } from '../../transport/rpc-client'
import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { isPrSidebarDetailsPlaceholder } from '../../session/mobile-pr-sidebar-state'
import { getPRReviewerRows } from './pr-checks-presentation'
import { ReviewerPickerDrawer } from './ReviewerPickerDrawer'
import { PRSection } from './PRSection'
@@ -20,8 +21,17 @@ type Props = {
// Requested reviewers + their latest review status, with a picker to request /
// remove (optimistic add/remove via the actions hook).
export function PRReviewersSection({ details, actions, client, worktreeId }: Props) {
// details === null means phase 2 (work-item payload) is still in flight — same
// signal Comments uses. Do not treat that as "no reviewers" or the section goes
// blank while checks (phase 1) already paint. A synthetic placeholder means
// phase 2 failed (no body/comments/reviews landed).
const loadingDetails = details === null
const detailsFailed = details != null && isPrSidebarDetailsPlaceholder(details)
const authoritativeRows = useMemo(
() => (details?.item ? getPRReviewerRows(details.item) : []),
() =>
details?.item && !isPrSidebarDetailsPlaceholder(details)
? getPRReviewerRows(details.item)
: [],
[details]
)
const [pickerOpen, setPickerOpen] = useState(false)
@@ -46,21 +56,27 @@ export function PRReviewersSection({ details, actions, client, worktreeId }: Pro
return author ? [author, ...logins] : logins
}, [authoritativeRows, details])
return (
<PRSection
title="Reviewers"
trailing={
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
}
const addButton = (
<Pressable
style={styles.iconButton}
onPress={() => setPickerOpen(true)}
accessibilityRole="button"
accessibilityLabel="Add or remove reviewers"
>
{rows.length === 0 ? (
<UserPlus size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
)
return (
<PRSection title="Reviewers" trailing={addButton}>
{loadingDetails ? (
<View style={styles.reviewersStatus}>
<ActivityIndicator color={colors.textSecondary} />
<Text style={styles.emptyText}>Loading reviewers…</Text>
</View>
) : detailsFailed ? (
<Text style={styles.emptyText}>Could not load reviewers. Tap refresh to try again.</Text>
) : rows.length === 0 ? (
<Text style={styles.emptyText}>No reviewers requested</Text>
) : (
rows.map((row) => {
+13 -7
View File
@@ -3,22 +3,28 @@ import { Text, View } from 'react-native'
import { mobilePrSidebarStyles as styles } from './mobile-pr-sidebar-styles'
type Props = {
title: string
// Optional: omit for self-explanatory sections (e.g. action buttons) so the
// header row doesn't waste vertical space on mobile.
title?: string
// Optional trailing control(s) in the header row (e.g. add-reviewer, checks
// summary + rerun). Rendered right-aligned opposite the title.
trailing?: ReactNode
children: ReactNode
}
// Shared card shell for the titled PR sections (Actions/Reviewers/Checks). Mirrors
// the desktop PR page's card-with-header-divider so the sections read consistently.
// Shared card shell for PR sections (Actions/Reviewers/Checks). Mirrors the
// desktop PR page's card-with-header-divider so the sections read consistently.
// Header is omitted when neither title nor trailing is provided.
export function PRSection({ title, trailing, children }: Props) {
const showHeader = Boolean(title) || trailing != null
return (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionLabel}>{title}</Text>
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
{showHeader ? (
<View style={styles.sectionHeader}>
{title ? <Text style={styles.sectionLabel}>{title}</Text> : null}
{trailing ? <View style={styles.sectionHeaderTrailing}>{trailing}</View> : null}
</View>
) : null}
<View style={styles.sectionBody}>{children}</View>
</View>
)
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { ArrowRight, Pencil } from 'lucide-react-native'
import { ArrowRight, ExternalLink, Pencil } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubWorkItemDetails, PRInfo } from '../../../../src/shared/types'
import type { MobilePrTitleAction } from '../../session/use-mobile-pr-title-action'
@@ -16,11 +16,21 @@ type Props = {
details: GitHubWorkItemDetails | null
// Inline title-edit action; the pencil affordance only shows when the PR is editable.
titleAction: MobilePrTitleAction
// Hub chrome already surfaces open-on-web; hide the duplicate icon in that case.
showOpenOnWeb?: boolean
// When true, render without section chrome so identity can share a card with actions.
bare?: boolean
}
// Header: state badge (incl. draft — display-only), title, author, head->base.
// The title is inline-editable on an open/draft PR (desktop parity).
export function PRSidebarHeader({ pr, details, titleAction }: Props) {
// Compact identity: state + # + author on one meta row, title, head→base.
// # lives only in the meta row (not also after the title) to avoid repetition.
export function PRSidebarHeader({
pr,
details,
titleAction,
showOpenOnWeb = true,
bare = false
}: Props) {
const item = details?.item
const badge = prStateBadge(pr.state)
const badgeColor = statusColor(badge.token)
@@ -29,58 +39,79 @@ export function PRSidebarHeader({ pr, details, titleAction }: Props) {
const baseRef = item?.baseRefName ?? null
const headRef = item?.branchName ?? null
const editable = canEditPRTitle(pr.state)
// Tapping the state badge or the #number opens the PR on its host (GitHub/etc.)
// in the phone browser — pr.url is the canonical web URL.
const openPr = pr.url ? () => openMobilePrUrl(pr.url) : undefined
return (
<View style={styles.section}>
<View style={styles.sectionBody}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
<PRTitle
title={title}
number={pr.number}
editable={editable}
openPr={openPr}
titleAction={titleAction}
/>
{author ? <Text style={styles.prMeta}>by {author}</Text> : null}
{baseRef && headRef ? (
// head -> base reads in merge direction (desktop ChecksPanel parity).
<View style={styles.branchRow}>
<Text style={styles.branchPill}>{headRef}</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill}>{baseRef}</Text>
</View>
const body = (
<>
<View style={styles.metaRow}>
<View style={styles.metaLeft}>
<Pressable
onPress={openPr}
disabled={!openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
style={({ pressed }) => [
styles.badge,
{ borderColor: badgeColor },
pressed && { opacity: 0.6 }
]}
>
<Text style={[styles.badgeText, { color: badgeColor }]}>{badge.label}</Text>
</Pressable>
<Text
style={styles.prMetaStrong}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} on the web`}
>
#{pr.number}
</Text>
{author ? <Text style={styles.prMeta}>· {author}</Text> : null}
</View>
{showOpenOnWeb && openPr ? (
<Pressable
onPress={openPr}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${pr.number} in browser`}
style={({ pressed }) => [styles.iconButton, pressed && { opacity: 0.6 }]}
>
<ExternalLink size={16} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
) : null}
</View>
<PRTitle title={title} editable={editable} titleAction={titleAction} />
{baseRef && headRef ? (
<View style={styles.branchRow}>
<Text style={styles.branchPill} numberOfLines={1}>
{headRef}
</Text>
<ArrowRight size={12} color={colors.textSecondary} strokeWidth={2.2} />
<Text style={styles.branchPill} numberOfLines={1}>
{baseRef}
</Text>
</View>
) : null}
</>
)
if (bare) {
return <View style={styles.identityBlock}>{body}</View>
}
return (
<View style={styles.section}>
<View style={styles.sectionBody}>{body}</View>
</View>
)
}
function PRTitle({
title,
number,
editable,
openPr,
titleAction
}: {
title: string
number: number
editable: boolean
openPr: (() => void) | undefined
titleAction: MobilePrTitleAction
}) {
const [editing, setEditing] = useState(false)
@@ -106,7 +137,7 @@ function PRTitle({
if (editing) {
return (
<View>
<View style={composerStyles.container}>
<TextInput
style={composerStyles.input}
value={draft}
@@ -152,17 +183,7 @@ function PRTitle({
accessibilityRole={editable ? 'button' : undefined}
accessibilityLabel={editable ? 'Edit pull request title' : undefined}
>
<Text style={styles.prTitle}>
{title}{' '}
<Text
style={styles.prMeta}
onPress={openPr}
accessibilityRole="link"
accessibilityLabel={`Open pull request #${number} on the web`}
>
#{number}
</Text>
</Text>
<Text style={styles.prTitle}>{title}</Text>
{editable ? (
<View style={styles.titleEditButton}>
<Pencil size={14} color={colors.textSecondary} strokeWidth={2} />
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, Text, TextInput, View } from 'react-native'
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors } from '../../theme/mobile-theme'
import type { GitHubAssignableUser } from '../../../../src/shared/types'
@@ -26,9 +26,10 @@ type LoadState =
| { status: 'error'; message: string }
| { status: 'loaded'; users: GitHubAssignableUser[] }
// A search + FlatList of github.listAssignableUsers in a BottomDrawer (not a new
// primitive). Seeded with already-requested/latest reviewers + author at the top.
// Optimistic add/remove via onToggle.
// Searchable assignable-user list in a BottomDrawer. Mapped rows (not FlatList):
// this drawer is opened from the PR ScrollView, and a VirtualizedList nested in
// that ScrollView throws and can leave the Reviewers section blank.
// Seeded reviewers + author sort first for quick un-request.
export function ReviewerPickerDrawer({
visible,
onClose,
@@ -47,16 +48,22 @@ export function ReviewerPickerDrawer({
}
let cancelled = false
setLoad({ status: 'loading' })
void fetchAssignableUsers(client, worktreeId).then((outcome) => {
if (cancelled) {
return
}
setLoad(
outcome.ok
? { status: 'loaded', users: outcome.result }
: { status: 'error', message: outcome.error }
)
})
void fetchAssignableUsers(client, worktreeId)
.then((outcome) => {
if (cancelled) {
return
}
setLoad(
outcome.ok
? { status: 'loaded', users: outcome.result }
: { status: 'error', message: outcome.error }
)
})
.catch(() => {
if (!cancelled) {
setLoad({ status: 'error', message: 'Failed to load people' })
}
})
return () => {
cancelled = true
}
@@ -107,15 +114,12 @@ export function ReviewerPickerDrawer({
<Text style={styles.emptyText}>No matching people</Text>
</View>
) : (
<FlatList
style={styles.pickerList}
data={ordered}
keyExtractor={(u) => u.login}
keyboardShouldPersistTaps="handled"
renderItem={({ item }) => {
<View style={styles.pickerList}>
{ordered.map((item) => {
const requested = isRequested(item.login)
return (
<Pressable
key={item.login}
style={styles.pickerRow}
onPress={() => onToggle(item.login)}
accessibilityRole="button"
@@ -134,8 +138,8 @@ export function ReviewerPickerDrawer({
</View>
</Pressable>
)
}}
/>
})}
</View>
)}
</BottomDrawer>
)
@@ -25,8 +25,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
section: {
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
overflow: 'hidden'
borderBottomColor: colors.borderSubtle
},
// Section header row: title + optional trailing control, divided from the body
// by a hairline border (desktop `h-10 border-b px-3`).
@@ -55,7 +54,25 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '600'
},
// Header section: state badge, title, author, base<-head branches.
// Identity + actions share one section card (hub redesign): no per-block chrome.
identityBlock: {
gap: spacing.sm
},
// State badge + #number + author on one row; open-on-web flush right when shown.
metaRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
metaLeft: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: spacing.xs
},
badge: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.sm,
@@ -69,6 +86,7 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontWeight: '700'
},
prTitle: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.titleSize,
fontWeight: '700',
@@ -78,6 +96,12 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
// #number in the meta row — stronger than author so the identity scans first.
prMetaStrong: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
// Title row: tappable area pairing the title with a trailing edit affordance.
titleRow: {
flexDirection: 'row',
@@ -97,10 +121,11 @@ export const mobilePrSidebarStyles = StyleSheet.create({
gap: spacing.xs
},
branchPill: {
flexShrink: 1,
color: colors.textPrimary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
backgroundColor: colors.bgPanel,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: radii.button
@@ -139,6 +164,13 @@ export const mobilePrSidebarStyles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
// Loading / empty status row inside Reviewers (and similar) section bodies.
reviewersStatus: {
minHeight: 44,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
summaryLabel: {
fontSize: typography.bodySize,
fontWeight: '700'
@@ -267,8 +299,10 @@ export const mobilePrSidebarStyles = StyleSheet.create({
fontSize: typography.bodySize,
marginBottom: spacing.sm
},
// No maxHeight / FlatList: the parent BottomDrawer scrolls this block so we
// never nest a VirtualizedList inside the PR page ScrollView.
pickerList: {
maxHeight: 320
gap: 0
},
pickerRow: {
minHeight: 44,
@@ -5,6 +5,19 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// line). Split out of mobile-pr-sidebar-styles to keep that file under the
// 300-line cap.
export const prActionsStyles = StyleSheet.create({
// Bare block when identity + actions share one section card.
actionsBlock: {
gap: spacing.sm
},
// Close/Reopen + Unlink share a row so secondary actions don't stack full-width.
secondaryRow: {
flexDirection: 'row',
alignItems: 'stretch',
gap: spacing.sm
},
secondaryButton: {
flex: 1
},
// Primary CTA (merge) and secondary action buttons (close/reopen/rerun/add).
actionButton: {
minHeight: 44,
@@ -5,7 +5,8 @@ import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
// match the PR comment timeline; split out to keep PRCommentComposer focused.
export const prCommentComposerStyles = StyleSheet.create({
container: {
gap: spacing.sm
// Input → Cancel/Save needs clear separation (title edit was flush without this).
gap: spacing.md
},
input: {
minHeight: 64,
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { buildConnectionDiagnosticsReport } from './connection-diagnostics-report'
const NOW = Date.UTC(2026, 6, 9, 22, 0, 0)
describe('buildConnectionDiagnosticsReport', () => {
it('summarizes a failing Tailscale host with its log', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 1',
endpoint: 'ws://100.65.9.106:6768',
state: 'reconnecting',
reconnectAttempts: 12,
lastConnectedAt: NOW - 5 * 60_000,
platform: 'ios 26.5.1',
appVersion: '0.0.29',
entries: [
{
id: 'log-1',
ts: NOW - 60_000,
level: 'error',
message: 'WebSocket connect timeout',
detail: 'No TCP/WS handshake within 12s — endpoint unreachable?'
}
],
nowMs: NOW
})
expect(report).toContain('App: Orca Mobile 0.0.29 · ios 26.5.1')
expect(report).toContain('Endpoint: 100.65.9.106:6768 (Tailscale)')
expect(report).toContain('State: reconnecting (reconnect attempts: 12)')
expect(report).toContain('(5m 0s ago)')
expect(report).toContain(
'[error] WebSocket connect timeout — No TCP/WS handshake within 12s — endpoint unreachable?'
)
})
it('marks never-connected sessions and empty logs', () => {
const report = buildConnectionDiagnosticsReport({
hostName: 'Host 2',
endpoint: 'ws://192.168.1.50:6768',
state: 'connecting',
reconnectAttempts: 0,
lastConnectedAt: null,
platform: 'android 15',
appVersion: '0.0.29',
entries: [],
nowMs: NOW
})
expect(report).toContain('Endpoint: 192.168.1.50:6768')
expect(report).not.toContain('(Tailscale)')
expect(report).toContain('Last connected: never this session')
expect(report).toContain('No connection events recorded this session.')
})
})
@@ -0,0 +1,58 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
import type { ConnectionLogEntry, ConnectionState } from '../transport/types'
import { formatEndpoint } from './host-reachability'
// Why: one shareable text blob answering everything we historically had to
// ask reporters one message at a time (endpoint type, state, attempt count,
// last-connected, versions, and the reconnect lifecycle log).
export function buildConnectionDiagnosticsReport(args: {
hostName: string
endpoint: string
state: ConnectionState
reconnectAttempts: number
lastConnectedAt: number | null
platform: string
appVersion: string
entries: readonly ConnectionLogEntry[]
nowMs?: number
}): string {
const now = args.nowMs ?? Date.now()
const lines: string[] = []
lines.push('Orca Mobile connection diagnostics')
lines.push(`Generated: ${new Date(now).toISOString()}`)
lines.push(`App: Orca Mobile ${args.appVersion} · ${args.platform}`)
lines.push(`Host: ${args.hostName}`)
lines.push(
`Endpoint: ${formatEndpoint(args.endpoint)}${isTailscaleEndpoint(args.endpoint) ? ' (Tailscale)' : ''}`
)
lines.push(`State: ${args.state} (reconnect attempts: ${args.reconnectAttempts})`)
lines.push(
args.lastConnectedAt == null
? 'Last connected: never this session'
: `Last connected: ${new Date(args.lastConnectedAt).toISOString()} (${formatAgo(now - args.lastConnectedAt)} ago)`
)
lines.push('')
if (args.entries.length === 0) {
lines.push('No connection events recorded this session.')
} else {
lines.push(`Connection log (${args.entries.length} events, oldest first):`)
for (const entry of args.entries) {
const detail = entry.detail ? ` — ${entry.detail}` : ''
lines.push(`${new Date(entry.ts).toISOString()} [${entry.level}] ${entry.message}${detail}`)
}
}
return lines.join('\n')
}
function formatAgo(ms: number): string {
const seconds = Math.max(0, Math.round(ms / 1000))
if (seconds < 60) {
return `${seconds}s`
}
const minutes = Math.floor(seconds / 60)
if (minutes < 60) {
return `${minutes}m ${seconds % 60}s`
}
const hours = Math.floor(minutes / 60)
return `${hours}h ${minutes % 60}m`
}
@@ -1,5 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { testHostReachability } from './host-reachability'
import { testHostReachability, unreachableHostDetail } from './host-reachability'
describe('unreachableHostDetail', () => {
it('points at Tailscale for tailnet CGNAT endpoints', () => {
expect(unreachableHostDetail('ws://100.65.9.106:6768')).toBe(
'Cannot reach 100.65.9.106:6768 — check Tailscale'
)
})
it('points at Tailscale for MagicDNS endpoints', () => {
expect(unreachableHostDetail('ws://my-desktop.tailnet-1234.ts.net:6768')).toBe(
'Cannot reach my-desktop.tailnet-1234.ts.net:6768 — check Tailscale'
)
})
it('stays generic for LAN endpoints', () => {
expect(unreachableHostDetail('ws://192.168.1.50:6768')).toBe('Cannot reach 192.168.1.50:6768')
})
it('does not treat non-CGNAT 100.x addresses as Tailscale', () => {
expect(unreachableHostDetail('ws://100.20.1.5:6768')).toBe('Cannot reach 100.20.1.5:6768')
})
})
describe('testHostReachability', () => {
it('returns false without leaving timers when WebSocket rejects a malformed endpoint', async () => {
@@ -1,3 +1,5 @@
import { isTailscaleEndpoint } from '../../../src/shared/remote-runtime-tailscale-hint'
const HOST_REACHABILITY_TIMEOUT_MS = 4000
// Why: troubleshooting needs a cheap endpoint probe without completing the
@@ -56,3 +58,13 @@ export function formatEndpoint(endpoint: string): string {
return endpoint
}
}
// Why: an unreachable 100.x/*.ts.net host almost always means the phone's
// Tailscale tunnel is down or wedged (known iOS failure mode, fixed by
// toggling the VPN) — point at that instead of a bare "Cannot reach".
export function unreachableHostDetail(endpoint: string): string {
if (isTailscaleEndpoint(endpoint)) {
return `Cannot reach ${formatEndpoint(endpoint)} — check Tailscale`
}
return `Cannot reach ${formatEndpoint(endpoint)}`
}
@@ -0,0 +1,73 @@
import { WifiOff, Shield, Monitor, Clock, Globe } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
export type TroubleshootSection = {
id: string
icon: React.ReactNode
title: string
steps: string[]
}
export const troubleshootCommonIssues: TroubleshootSection[] = [
{
id: 'wifi',
icon: <WifiOff size={16} color={colors.textSecondary} />,
title: 'Different WiFi Networks',
steps: [
'Both devices must be on the same local network (unless connected through Tailscale).',
'Ethernet and WiFi must share the same subnet.',
'Try reconnecting WiFi on both devices.'
]
},
{
id: 'firewall',
icon: <Shield size={16} color={colors.textSecondary} />,
title: 'Firewall Blocking Port 6768',
steps: [
'macOS: System Settings → Network → Firewall — allow Orca.',
'Windows: Defender Firewall → Allow app — enable Orca for Private networks.',
'Linux: sudo ufw allow 6768',
'Corporate/school networks may block P2P — try a personal hotspot.'
]
},
{
id: 'desktop',
icon: <Monitor size={16} color={colors.textSecondary} />,
title: 'Desktop App Not Running',
steps: [
'Orca must be open on your desktop to accept connections.',
'Try restarting Orca — the companion server starts on launch.',
'After an update, you may need to re-pair via QR code.'
]
},
{
id: 'timeout',
icon: <Clock size={16} color={colors.textSecondary} />,
title: 'Connection Timeout',
steps: [
'Check WiFi signal strength on your phone.',
'Go back to the host list and tap your host to retry.',
'Restart both apps if timeouts persist.'
]
},
{
id: 'tailscale',
icon: <Globe size={16} color={colors.textSecondary} />,
title: 'Tailscale Host Unreachable',
steps: [
'Host addresses like 100.x.x.x or *.ts.net connect through Tailscale — keep it ON.',
'iOS/Android can silently wedge the tunnel: toggle Tailscale off and back on in the Tailscale app.',
'Check the desktop is awake and shows as connected in your tailnet.',
'Update the Tailscale app — recent releases fix reconnect bugs.'
]
},
{
id: 'vpn',
icon: <Shield size={16} color={colors.textSecondary} />,
title: 'Other VPN Interference',
steps: [
'Non-Tailscale VPNs can route local traffic through a remote server.',
'Disable that VPN or enable split tunneling / "Allow LAN".'
]
}
]
@@ -1,6 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import {
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -268,4 +271,48 @@ describe('subscribeToDesktopNotifications', () => {
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalled()
})
// Why: notificationId is unique per completion, so the map grew unbounded when
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
setScheduledNotificationsMaxForTests(1)
try {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync)
.mockResolvedValueOnce('scheduled-old')
.mockResolvedValueOnce('scheduled-new')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
let onEvent: ((data: unknown) => void) | null = null
const client = {
subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
onEvent = callback
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn()
} as unknown as RpcClient
subscribeToDesktopNotifications(client, 'host-1')
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:old' })
await flushAsync()
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
await flushAsync()
// The older entry was evicted by the cap: dismissing it is a no-op...
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
// ...while the most-recent entry is retained and still dismissable.
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
await flushAsync()
expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-new')
} finally {
setScheduledNotificationsMaxForTests()
}
})
})
@@ -31,10 +31,42 @@ type ScheduledNotificationState = {
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
// Why: notificationId embeds a per-completion timestamp (buildAgentNotificationId),
// so every agent-task-complete inserts a new, never-reused key. Entries are only
// removed when the desktop sends a matching dismiss — which a remote mobile user
// (not at the desktop) frequently never gets — so the map grew for the app's whole
// life. Bound it; a settled entry only retains a small identifier used for later
// programmatic dismissal, unnecessary for long-past completions.
const MAX_SCHEDULED_NOTIFICATIONS = 256
let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
function getStoredNotificationKey(hostId: string, notificationId: string): string {
return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
}
// Evict the oldest SETTLED entries (never one mid-schedule) until within the cap.
// Map iteration is insertion order, so the first match is the oldest.
function boundScheduledNotifications(): void {
while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
let evicted = false
for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
if (!state.pending) {
scheduledNotificationsByHostAndNotificationId.delete(key)
evicted = true
break
}
}
if (!evicted) {
break
}
}
}
/** Test-only: override the cap (pass no arg to restore the default). */
export function setScheduledNotificationsMaxForTests(max?: number): void {
maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
}
export type NotificationPermissionState = {
granted: boolean
status: string
@@ -155,6 +187,7 @@ async function showLocalNotification(event: NotificationEvent, hostId: string):
return
}
notificationState.identifier = scheduledIdentifier
boundScheduledNotifications()
} finally {
if (notificationState.pending === pending) {
notificationState.pending = undefined
+8 -52
View File
@@ -1,27 +1,16 @@
import { memo } from 'react'
import { View, StyleSheet } from 'react-native'
import type { ConnectionState } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { MobileSourceControlPanel } from '../source-control/MobileSourceControlPanel'
import { MobileFileExplorerPanel } from '../files/MobileFileExplorerPanel'
import { MobilePrViewPanel } from '../components/pr-sidebar/MobilePrViewPanel'
import { mobilePrSidebarStyles } from '../components/pr-sidebar/mobile-pr-sidebar-styles'
import { useMobileDockResize } from './use-mobile-dock-resize'
import type { ActivePanel } from './session-panel-host'
import type { MobileGitStatusResult } from '../source-control/mobile-git-status'
type Props = {
activePanel: Exclude<ActivePanel, null>
hostId: string
worktreeId: string
name: string
client: RpcClient | null
connState: ConnectionState
branch: string | null
headSha: string | null
gitStatus: MobileGitStatusResult | null
isGithubRepo: boolean
branchContextLoaded: boolean
availableWidth: number
onRequestClose: () => void
onFileOpenStart?: () => void
@@ -40,13 +29,6 @@ export function SessionDockColumn({
hostId,
worktreeId,
name,
client,
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
availableWidth,
onRequestClose,
onFileOpenStart,
@@ -63,13 +45,6 @@ export function SessionDockColumn({
hostId={hostId}
worktreeId={worktreeId}
name={name}
client={client}
connState={connState}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
@@ -85,18 +60,14 @@ const DockPanelContent = memo(function DockPanelContent({
hostId,
worktreeId,
name,
client,
connState,
branch,
headSha,
gitStatus,
isGithubRepo,
branchContextLoaded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: DockPanelContentProps) {
if (activePanel === 'sourceControl') {
// Source Control and Pull Request share one hub instance so swapping dock icons
// keeps commit draft / visited-tab / scroll state and lands on the right segment
// (design: PR dock maps to hub + tab=pr).
if (activePanel === 'sourceControl' || activePanel === 'pr') {
return (
<MobileSourceControlPanel
hostId={hostId}
@@ -104,33 +75,18 @@ const DockPanelContent = memo(function DockPanelContent({
name={name}
origin="session"
embedded
initialTab={activePanel === 'pr' ? 'pr' : 'changes'}
onRequestClose={onRequestClose}
onFileOpenStart={onFileOpenStart}
onOpenedFileDiff={onOpenedFileDiff}
/>
)
}
if (activePanel === 'files') {
return (
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
name={name}
embedded
onRequestClose={onRequestClose}
/>
)
}
return (
<MobilePrViewPanel
client={client}
connState={connState}
<MobileFileExplorerPanel
hostId={hostId}
worktreeId={worktreeId}
branch={branch}
headSha={headSha}
gitStatus={gitStatus}
isGithubRepo={isGithubRepo}
branchContextLoaded={branchContextLoaded}
name={name}
embedded
onRequestClose={onRequestClose}
/>
+17
View File
@@ -172,6 +172,23 @@ describe('readWorkItemDetails', () => {
expect(parsed?.item.latestReviews).toEqual([{ login: 'ok', state: null, avatarUrl: null }])
})
it('accepts raw gh latestReviews author.login nesting', () => {
const parsed = readWorkItemDetails({
item: {
id: 'n',
type: 'pr',
number: 1,
state: 'open',
latestReviews: [
{ author: { login: 'coderabbitai', avatarUrl: 'https://a' }, state: 'COMMENTED' }
]
}
})
expect(parsed?.item.latestReviews).toEqual([
{ login: 'coderabbitai', state: 'COMMENTED', avatarUrl: 'https://a' }
])
})
it('returns null when item is unparseable', () => {
expect(readWorkItemDetails({ item: { number: 1 } })).toBeNull()
expect(readWorkItemDetails(null)).toBeNull()
+11 -2
View File
@@ -123,14 +123,23 @@ export function readReviewSummary(value: unknown): GitHubPRReviewSummary | null
if (!isRecord(value)) {
return null
}
const login = readString(value.login)
// Desktop maps latestReviews to top-level `login`. Raw `gh pr view --json`
// keeps nested `author.login` — accept both so mobile never drops reviewers.
const nestedAuthor = isRecord(value.author) ? value.author : null
const login =
readString(value.login) ?? (nestedAuthor ? readString(nestedAuthor.login) : undefined)
if (login === undefined) {
return null
}
const avatarUrl =
readString(value.avatarUrl) ??
(nestedAuthor
? (readString(nestedAuthor.avatarUrl) ?? readString(nestedAuthor.avatar_url) ?? null)
: null)
return {
login,
state: readString(value.state) ?? null,
avatarUrl: readString(value.avatarUrl) ?? null
avatarUrl
}
}
@@ -140,6 +140,62 @@ export async function loadPrSidebarDetails(
}
}
// UI treats details===null as "still loading". After phase 2 finishes (success or
// non-fatal failure), never leave null — use prior comments if any, else empty body.
export function resolvePrSidebarDetailsAfterPhase2(args: {
fetched: GitHubWorkItemDetails | null
prior: GitHubWorkItemDetails | null
pr: PRInfo
}): GitHubWorkItemDetails {
if (args.fetched != null) {
return args.fetched
}
if (args.prior != null) {
return args.prior
}
return emptyPrSidebarDetails(args.pr)
}
// Placeholder details so Description/Comments leave the spinner after a failed phase 2.
// The synthetic id is the only id shape we mint — real GitHub node ids never match.
export function emptyPrSidebarDetails(pr: PRInfo): GitHubWorkItemDetails {
return {
item: {
id: `pr-${pr.number}`,
type: 'pr',
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.url,
labels: [],
updatedAt: pr.updatedAt,
author: null
},
body: '',
comments: []
}
}
// True when phase 2 failed and we synthesized a stand-in (no body/comments/reviews).
export function isPrSidebarDetailsPlaceholder(details: GitHubWorkItemDetails): boolean {
return details.item.id === `pr-${details.item.number}`
}
// Phase-2 still needs a real payload: null (in flight / not started) or a
// synthetic placeholder from a failed fetch. Real details (including empty body)
// do not qualify — ensure/retry must not re-pull those.
export function prSidebarDetailsNeedFetch(
details: GitHubWorkItemDetails | null | undefined
): boolean {
return details == null || isPrSidebarDetailsPlaceholder(details)
}
// Soft head refresh may restart an in-flight phase-1 load; only skip when there is
// no visible/in-flight sidebar work (hidden or terminal error states).
export function shouldSoftRefreshPrSidebarOnHeadChange(kind: PrSidebarState['kind']): boolean {
return kind === 'ready' || kind === 'none' || kind === 'loading'
}
// Stale-response guard (KTD6): a load tagged with an older sequence must not
// overwrite a newer one. The hook bumps a monotonic counter per load.
export function shouldApplyResult(resultSeq: number, latestSeq: number): boolean {
@@ -41,6 +41,8 @@ describe('mobile session startup', () => {
expect(startupEffect.indexOf("sendRequest('worktree.activate'")).toBeLessThan(
startupEffect.indexOf('await fetchSessionTabs()')
)
expect(startupEffect).toContain('headlessActivationNeedsHostRenderer(response.result)')
expect(startupEffect).toContain("showToast('Open Orca on the host to wake sleeping agents.'")
})
it('activates an already-selected pending terminal tab after hydration', () => {
@@ -80,6 +80,51 @@ describe('PrActionsEngine — transport-rejection-normalized outcomes settle cle
expect(engine.error).toBe('socket hung up')
expect(engine.busy).toBeNull()
})
it('surfaces a refetch throw as error without rejecting the action promise', async () => {
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: async () => {
throw new Error('refresh failed')
},
onChange: () => {}
})
await expect(engine.merge()).resolves.toBeUndefined()
expect(engine.error).toBe('refresh failed')
expect(engine.busy).toBeNull()
})
it('does not notify on no-op setError(null) at action start', async () => {
const onChange = vi.fn()
const engine = new PrActionsEngine({
mutations: {
mergePR: async () => ({ ok: true }),
setPRAutoMerge: async () => ({ ok: true }),
updatePRState: async () => ({ ok: true }),
requestReviewers: async () => ({ ok: true }),
removeReviewers: async () => ({ ok: true }),
rerunChecks: async () => ({ ok: true })
},
prNumber: 1,
refetch: () => {},
onChange
})
// Idle: error is already null. Action start must only notify for busy, not a
// redundant clearError path.
onChange.mockClear()
const p = engine.merge()
// First notify is setBusy only (setError(null) no-ops).
expect(onChange).toHaveBeenCalledTimes(1)
await p
})
})
describe('PrActionsEngine — PR identity changes', () => {
+20 -1
View File
@@ -129,7 +129,12 @@ export class PrActionsEngine {
return f
}
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
// so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
if (key === null ? this.busy === null : busyKeyEquals(this.busy, key)) {
return
}
this.busy = key
this.cfg.onChange()
}
@@ -143,6 +148,9 @@ export class PrActionsEngine {
}
private setError(message: string | null): void {
if (this.error === message) {
return
}
this.error = message
this.cfg.onChange()
}
@@ -180,7 +188,14 @@ export class PrActionsEngine {
}
if (outcome.ok) {
handlers.onSuccess()
await this.cfg.refetch()
// Why: void engine.merge() callers are fire-and-forget; refetch must not LogBox.
try {
await this.cfg.refetch()
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to refresh pull request.')
}
}
return
}
// Both failure classes clear optimism to authoritative; only the message
@@ -205,6 +220,10 @@ export class PrActionsEngine {
prRepo: cfg.prRepo
})
await this.settle(identity, outcome, { onSuccess: () => {}, onRevert: () => {} })
} catch (err) {
if (this.identity === identity) {
this.setError(err instanceof Error ? err.message : 'Failed to merge pull request.')
}
} finally {
this.clearBusyIfOwned(identity, { kind: 'merge' })
}
@@ -86,7 +86,8 @@ describe('panelRouteDescriptor', () => {
pathname: '/h/[hostId]/files/[worktreeId]'
})
expect(panelRouteDescriptor('pr')).toEqual({
pathname: '/h/[hostId]/pr/[worktreeId]'
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: { tab: 'pr' }
})
})
})
+7 -2
View File
@@ -47,13 +47,18 @@ export function resolvePanelAction(args: {
// Single source of truth for each panel's expo-router pathname pattern so narrow-push
// and any deep-linking agree; the caller supplies the [hostId]/[worktreeId] params.
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): { pathname: string } {
// The Pull Request panel is a segment of the source-control hub, so its narrow-push
// targets that route with `tab: 'pr'` rather than the standalone (redirecting) route.
export function panelRouteDescriptor(panel: Exclude<ActivePanel, null>): {
pathname: string
params?: Record<string, string>
} {
switch (panel) {
case 'sourceControl':
return { pathname: '/h/[hostId]/source-control/[worktreeId]' }
case 'files':
return { pathname: '/h/[hostId]/files/[worktreeId]' }
case 'pr':
return { pathname: '/h/[hostId]/pr/[worktreeId]' }
return { pathname: '/h/[hostId]/source-control/[worktreeId]', params: { tab: 'pr' } }
}
}
@@ -34,12 +34,16 @@ export function deriveMobilePrBranchContext(
// Loads repo eligibility independently from branch/SHA. The header PR icon only
// needs the cheap GitHub probe; the panel can keep loading branch context after
// the entry point is already stable in the top bar.
//
// `includeBranchIdentity: false` skips git.status + branchCompare — use when the
// caller only gates a PR entry on hosted-repo eligibility (session dock icon).
export function useMobilePrBranchContext(input: {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
includeBranchIdentity?: boolean
}): MobilePrBranchContext {
const { client, connState, worktreeId } = input
const { client, connState, worktreeId, includeBranchIdentity = true } = input
const [context, setContext] = useState<MobilePrBranchContext>({
branch: null,
headSha: null,
@@ -60,7 +64,7 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
loaded: !includeBranchIdentity
})
return
}
@@ -70,7 +74,8 @@ export function useMobilePrBranchContext(input: {
status: null,
isGithubRepo: false,
repoLoaded: false,
loaded: false
// Repo-only mode is "loaded" for branch fields immediately (they stay null).
loaded: !includeBranchIdentity
})
void loadMobilePrRepoContext(client, worktreeId)
@@ -95,6 +100,12 @@ export function useMobilePrBranchContext(input: {
}
})
if (!includeBranchIdentity) {
return () => {
cancelled = true
}
}
void loadMobilePrBranchIdentity(client, worktreeId)
.then((next) => {
if (!cancelled) {
@@ -121,7 +132,7 @@ export function useMobilePrBranchContext(input: {
return () => {
cancelled = true
}
}, [ready, client, worktreeId])
}, [ready, client, worktreeId, includeBranchIdentity])
return context
}
@@ -0,0 +1,100 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
const fetchGithubRepoSlugMock = vi.fn()
// Only the repo probe matters here; the load path never runs without a branch.
vi.mock('./github-pr-rpc', () => ({
fetchGithubRepoSlug: (...args: unknown[]) => fetchGithubRepoSlugMock(...args),
fetchHostedReviewForBranch: vi.fn(),
fetchPRChecks: vi.fn(),
fetchPRForBranch: vi.fn(),
fetchWorkItemDetails: vi.fn()
}))
vi.mock('../source-control/mobile-pr-link', () => ({
fetchWorktreeLinkedPR: vi.fn(async () => null)
}))
import {
useMobilePrSidebarController,
type MobilePrSidebarController
} from './use-mobile-pr-sidebar-controller'
let captured: MobilePrSidebarController | null = null
function Harness(props: Parameters<typeof useMobilePrSidebarController>[0]) {
captured = useMobilePrSidebarController(props)
return null
}
async function flush(): Promise<void> {
// Two ticks: the probe's resolved promise (.then) plus its setState commit.
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('useMobilePrSidebarController repo probe', () => {
const client = { sendRequest: vi.fn() } as unknown as RpcClient
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
captured = null
fetchGithubRepoSlugMock.mockReset()
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
})
// Regression: repo eligibility must not be gated on a branch. A detached HEAD /
// mid-rebase worktree (branch === null) still has to resolve the probe, or the
// Pull Request segment gets stranded on a forever spinner instead of the
// "Current branch unavailable" state.
it('resolves the probe when connected without a branch (detached HEAD)', async () => {
fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } })
await act(async () => {
renderer = create(
createElement(Harness, {
client,
connState: 'connected',
worktreeId: 'w',
branch: null,
headSha: 'sha'
})
)
})
await flush()
expect(fetchGithubRepoSlugMock).toHaveBeenCalledWith(client, 'w')
expect(captured?.prSidebarRepoProbeLoaded).toBe(true)
expect(captured?.prSidebarIsGithubRepo).toBe(true)
// No branch means no PR load ran — state stays hidden, never a spinner.
expect(captured?.prSidebarState.kind).toBe('hidden')
})
it('does not probe until the client is connected', async () => {
fetchGithubRepoSlugMock.mockResolvedValue({ ok: true, result: { owner: 'o', repo: 'r' } })
await act(async () => {
renderer = create(
createElement(Harness, {
client,
connState: 'connecting',
worktreeId: 'w',
branch: null,
headSha: 'sha'
})
)
})
await flush()
expect(fetchGithubRepoSlugMock).not.toHaveBeenCalled()
expect(captured?.prSidebarRepoProbeLoaded).toBe(false)
})
})
@@ -4,11 +4,17 @@ import type { HostedReviewInfo } from '../../../src/shared/hosted-review'
import type { GitHubPrReadOutcome } from './github-pr-rpc'
import {
classifyPrSidebarFailure,
emptyPrSidebarDetails,
isPrSidebarDetailsPlaceholder,
prSidebarDetailsNeedFetch,
loadPrSidebarData,
loadPrSidebarDetails,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps
} from './mobile-pr-sidebar-state'
import { buildMobilePrSidebarIdentity } from './use-mobile-pr-sidebar-controller'
function ok<T>(result: T): GitHubPrReadOutcome<T> {
return { ok: true, result }
@@ -200,3 +206,84 @@ describe('shouldApplyResult', () => {
expect(shouldApplyResult(2, 3)).toBe(false)
})
})
describe('shouldSoftRefreshPrSidebarOnHeadChange', () => {
it('restarts ready/none/loading so mid-flight head advances are not stuck', () => {
expect(shouldSoftRefreshPrSidebarOnHeadChange('ready')).toBe(true)
expect(shouldSoftRefreshPrSidebarOnHeadChange('none')).toBe(true)
expect(shouldSoftRefreshPrSidebarOnHeadChange('loading')).toBe(true)
})
it('skips hidden and terminal failures (chip bootstrap / retry own those)', () => {
expect(shouldSoftRefreshPrSidebarOnHeadChange('hidden')).toBe(false)
expect(shouldSoftRefreshPrSidebarOnHeadChange('error')).toBe(false)
expect(shouldSoftRefreshPrSidebarOnHeadChange('blocked')).toBe(false)
})
})
describe('resolvePrSidebarDetailsAfterPhase2', () => {
it('prefers a successful fetch over prior details', () => {
const prior = emptyPrSidebarDetails(PR)
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: DETAILS, prior, pr: PR })).toBe(DETAILS)
})
it('keeps prior details when the fetch is non-fatal null', () => {
const prior = emptyPrSidebarDetails(PR)
expect(resolvePrSidebarDetailsAfterPhase2({ fetched: null, prior, pr: PR })).toBe(prior)
})
it('uses empty details when phase 2 fails with nothing prior (no forever spinner)', () => {
const empty = resolvePrSidebarDetailsAfterPhase2({
fetched: null,
prior: null,
pr: PR
})
expect(empty.body).toBe('')
expect(empty.comments).toEqual([])
expect(empty.item.number).toBe(7)
expect(empty.item.type).toBe('pr')
expect(isPrSidebarDetailsPlaceholder(empty)).toBe(true)
})
})
describe('isPrSidebarDetailsPlaceholder / prSidebarDetailsNeedFetch', () => {
it('detects the synthetic hyphen id, not the host pr:n shape', () => {
const placeholder = emptyPrSidebarDetails(PR)
expect(isPrSidebarDetailsPlaceholder(placeholder)).toBe(true)
expect(isPrSidebarDetailsPlaceholder(DETAILS)).toBe(false)
// Host work-item ids use a colon (`pr:7`); placeholders use a hyphen (`pr-7`).
expect(
isPrSidebarDetailsPlaceholder({
...DETAILS,
item: { ...DETAILS.item, id: `pr:${PR.number}` }
})
).toBe(false)
})
it('needs fetch for null and placeholders, not real details', () => {
expect(prSidebarDetailsNeedFetch(null)).toBe(true)
expect(prSidebarDetailsNeedFetch(undefined)).toBe(true)
expect(prSidebarDetailsNeedFetch(emptyPrSidebarDetails(PR))).toBe(true)
expect(prSidebarDetailsNeedFetch(DETAILS)).toBe(false)
})
})
describe('buildMobilePrSidebarIdentity', () => {
it('keys identity on worktree + branch, not head SHA', () => {
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe('w1\u0000feat')
// Head advances on commit must not form a new identity — soft refresh keeps ready UI.
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })).toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'feat' })
)
})
it('is null without a branch and changes when branch or worktree changes', () => {
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: null })).toBeNull()
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'b' })
)
expect(buildMobilePrSidebarIdentity({ worktreeId: 'w1', branch: 'a' })).not.toBe(
buildMobilePrSidebarIdentity({ worktreeId: 'w2', branch: 'a' })
)
})
})
@@ -11,7 +11,10 @@ import {
import {
loadPrSidebarData,
loadPrSidebarDetails,
prSidebarDetailsNeedFetch,
resolvePrSidebarDetailsAfterPhase2,
shouldApplyResult,
shouldSoftRefreshPrSidebarOnHeadChange,
type PrSidebarLoadDeps,
type PrSidebarState
} from './mobile-pr-sidebar-state'
@@ -27,12 +30,20 @@ type PrSidebarControllerInput = {
headSha: string | null
}
function buildPrSidebarIdentity(args: {
// Load options for the shared PR controller. The Source Control hub chip only needs
// phase 1 (PR + checks); phase 2 (comments/body) is heavy and should wait until the
// Pull Request segment is actually open.
export type PrSidebarLoadOptions = {
includeDetails?: boolean
}
// Identity is worktree + branch only. Head SHA advances on every commit and must not
// wipe a ready chip/sidebar to "loading" — soft-refresh uses the new head instead.
export function buildMobilePrSidebarIdentity(args: {
worktreeId: string
branch: string | null
headSha: string | null
}): string | null {
return args.branch ? `${args.worktreeId}\u0000${args.branch}\u0000${args.headSha ?? ''}` : null
return args.branch ? `${args.worktreeId}\u0000${args.branch}` : null
}
export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
@@ -41,13 +52,31 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
// independent of whether the branch has an open PR (a no-PR branch shows an
// empty state rather than hiding the icon).
const [isGithubRepo, setIsGithubRepo] = useState(false)
// False until the probe resolves for this worktree. Consumers gate "unavailable
// for this provider" copy on it — isGithubRepo=false is meaningless mid-probe.
const [repoProbeLoaded, setRepoProbeLoaded] = useState(false)
const [state, setState] = useState<PrSidebarState>({ kind: 'hidden' })
const [showPRSidebar, setShowPRSidebar] = useState(false)
const loadSeqRef = useRef(0)
// Phase-2-only fetches use a separate sequence so they cannot cancel a concurrent
// phase-1 soft refresh (and vice versa) when chip bootstrap left details null.
const detailsSeqRef = useRef(0)
// The (seq, prNumber) of the phase-2 fetch currently in flight. The hub's
// fill-in effect fires as soon as phase 1 renders ready with null details —
// exactly when load()'s own phase 2 just started. Without this claim, every
// cold PR-segment open fetched the heavy details payload twice.
const detailsInFlightRef = useRef<{ seq: number; prNumber: number } | null>(null)
const stateIdentityRef = useRef<string | null>(null)
const stateRef = useRef(state)
stateRef.current = state
const headShaRef = useRef(headSha)
const ready = client !== null && connState === 'connected' && !!branch
const identity = buildPrSidebarIdentity({ worktreeId, branch, headSha })
// Repo eligibility (a GitHub remote) is independent of the branch, so the probe
// must not require one: a detached HEAD / mid-rebase worktree (branch === null)
// would otherwise never set repoProbeLoaded, stranding the PR segment on a
// forever spinner instead of the "Current branch unavailable" state.
const probeReady = client !== null && connState === 'connected'
const identity = buildMobilePrSidebarIdentity({ worktreeId, branch })
const buildDeps = useCallback((): PrSidebarLoadDeps | null => {
if (!client) {
@@ -63,66 +92,221 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
}, [client])
// Probe whether this is a GitHub repo to decide icon availability (GitHub-only).
// Worktree change must reset eligibility; a brief disconnect must not — otherwise
// the hub shows "unavailable for this provider" and hides the chip mid-session.
useEffect(() => {
setIsGithubRepo(false)
setRepoProbeLoaded(false)
}, [worktreeId])
useEffect(() => {
let cancelled = false
if (!ready || !client) {
setIsGithubRepo(false)
if (!probeReady || !client) {
return
}
void fetchGithubRepoSlug(client, worktreeId).then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
}
})
void fetchGithubRepoSlug(client, worktreeId)
.then((outcome) => {
if (!cancelled) {
setIsGithubRepo(outcome.ok && outcome.result !== null)
setRepoProbeLoaded(true)
}
})
.catch(() => {
// Why: sendGithubPrRead already normalizes throws, but a cancelled
// unmount + any unexpected rejection must not surface as LogBox.
if (!cancelled) {
setIsGithubRepo(false)
setRepoProbeLoaded(true)
}
})
return () => {
cancelled = true
}
}, [ready, client, worktreeId])
}, [probeReady, client, worktreeId])
useEffect(() => {
if (!identity) {
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
return
}
if (stateIdentityRef.current !== null && stateIdentityRef.current !== identity) {
// Why: ready/loading data is scoped to branch+head. A branch switch must
// not let the open panel keep rendering the previous PR as "fresh."
// Why: ready/loading data is scoped to branch. A branch switch must not let
// the open panel keep rendering the previous PR as "fresh."
loadSeqRef.current += 1
detailsSeqRef.current += 1
stateIdentityRef.current = null
setState({ kind: 'hidden' })
}
}, [identity])
const load = useCallback(async () => {
const load = useCallback(
async (options?: PrSidebarLoadOptions) => {
const includeDetails = options?.includeDetails ?? true
const deps = buildDeps()
const loadIdentity = identity
if (!deps || !branch || !loadIdentity) {
return
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
// In-flight phase-2 work is NOT invalidated here: this load only takes phase-2
// ownership when its own phase 2 actually starts (the bump below), so a load
// superseded at the phase-1 guard can never orphan a detailsSeq and silently
// discard the only details fetch. A stale ensure applying mid-phase-1 is safe —
// its identity/number/kind guards only let matching details through.
const previousIdentity = stateIdentityRef.current
stateIdentityRef.current = loadIdentity
// Soft refresh: same branch already showing ready/none stays visible while
// checks re-fetch (head advanced after commit). Hard loading only on first load
// or after a real identity wipe.
const keepVisible =
previousIdentity === loadIdentity &&
(stateRef.current.kind === 'ready' ||
stateRef.current.kind === 'none' ||
stateRef.current.kind === 'loading')
if (!keepVisible) {
setState({ kind: 'loading' })
}
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (
!shouldApplyResult(seq, loadSeqRef.current) ||
stateIdentityRef.current !== loadIdentity
) {
return
}
stateIdentityRef.current = loadIdentity
// Keep prior comments/body visible across phase 1 when the same PR is still open.
// loadPrSidebarData always returns details:null; without this, soft refresh and
// PR-tab refresh blank the comment tree until phase 2 finishes.
const priorDetails =
next.kind === 'ready' &&
stateRef.current.kind === 'ready' &&
stateRef.current.data.details != null &&
stateRef.current.data.pr.number === next.data.pr.number
? stateRef.current.data.details
: null
if (next.kind === 'ready' && priorDetails != null) {
setState({ kind: 'ready', data: { ...next.data, details: priorDetails } })
if (!includeDetails) {
return
}
} else {
setState(next)
if (next.kind !== 'ready' || !includeDetails) {
return
}
}
// Phase 2: refresh (or first-load) the heavy comments/body payload.
const detailsSeq = detailsSeqRef.current + 1
detailsSeqRef.current = detailsSeq
detailsInFlightRef.current = { seq: detailsSeq, prNumber: next.data.pr.number }
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
// Release the claim unless a newer phase-2 superseded it (never clear theirs).
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
// Phase-2 ownership is encoded by detailsSeq + identity + PR number alone —
// deliberately NOT by loadSeq: a chip-only soft refresh bumps loadSeq without
// bumping detailsSeq, and must not discard the in-flight details it preserved
// (ensure dedupes against this claim, so nothing would re-fetch them).
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
stateRef.current.kind !== 'ready' ||
stateRef.current.data.pr.number !== next.data.pr.number
) {
return
}
// Non-fatal null must not leave details===null (UI treats that as forever-loading).
const details = resolvePrSidebarDetailsAfterPhase2({
fetched: fetchedDetails,
prior: stateRef.current.data.details,
pr: stateRef.current.data.pr
})
setState({ kind: 'ready', data: { ...stateRef.current.data, details } })
},
[buildDeps, branch, headSha, identity, worktreeId]
)
// Phase-2 only — used when the hub opens the PR segment after a chip-only load.
// Uses detailsSeqRef (not loadSeqRef) so it cannot cancel a concurrent soft phase-1.
// Retries synthetic placeholders too: a failed phase-2 installs non-null empty
// details so Description/Comments leave the spinner, and without this ensure
// would never re-fetch on tab re-open.
const ensurePrSidebarDetails = useCallback(async () => {
const current = stateRef.current
if (current.kind !== 'ready' || !prSidebarDetailsNeedFetch(current.data.details)) {
return
}
const deps = buildDeps()
const loadIdentity = identity
if (!deps || !branch || !loadIdentity) {
if (!deps || !loadIdentity || stateIdentityRef.current !== loadIdentity) {
return
}
const seq = loadSeqRef.current + 1
loadSeqRef.current = seq
stateIdentityRef.current = loadIdentity
setState({ kind: 'loading' })
// Phase 1: PR + checks (fast) — the worktree linkedPR read is parallelized with
// forBranch inside loadPrSidebarData so a closed/merged linked PR still resolves.
const next = await loadPrSidebarData(deps, { worktreeId, branch, headSha })
if (!shouldApplyResult(seq, loadSeqRef.current) || stateIdentityRef.current !== loadIdentity) {
const prNumber = current.data.pr.number
// A live phase-2 fetch for this PR is already in flight (its claim still owns
// the latest details seq) — do not start a duplicate.
const inFlight = detailsInFlightRef.current
if (inFlight && inFlight.prNumber === prNumber && inFlight.seq === detailsSeqRef.current) {
return
}
stateIdentityRef.current = loadIdentity
setState(next)
if (next.kind !== 'ready') {
const detailsSeq = detailsSeqRef.current + 1
detailsSeqRef.current = detailsSeq
detailsInFlightRef.current = { seq: detailsSeq, prNumber }
const fetchedDetails = await loadPrSidebarDetails(deps, worktreeId, prNumber)
if (detailsInFlightRef.current?.seq === detailsSeq) {
detailsInFlightRef.current = null
}
if (
detailsSeq !== detailsSeqRef.current ||
stateIdentityRef.current !== loadIdentity ||
stateRef.current.kind !== 'ready' ||
stateRef.current.data.pr.number !== prNumber
) {
return
}
// Phase 2: lazy-load the heavy comments/body payload and merge it in, so it never
// blocks the actionable PR UI. Re-check the seq so a newer load isn't clobbered.
const details = await loadPrSidebarDetails(deps, worktreeId, next.data.pr.number)
if (shouldApplyResult(seq, loadSeqRef.current) && stateIdentityRef.current === loadIdentity) {
setState({ kind: 'ready', data: { ...next.data, details } })
const details = resolvePrSidebarDetailsAfterPhase2({
fetched: fetchedDetails,
prior: stateRef.current.data.details,
pr: stateRef.current.data.pr
})
setState({
kind: 'ready',
data: { ...stateRef.current.data, details }
})
}, [buildDeps, identity, worktreeId])
// Soft-refresh checks when HEAD advances on the same branch (post-commit).
// Also restarts an in-flight phase-1 load so a mid-flight head advance is not
// applied with a stale SHA (headShaRef would otherwise advance with no reload).
useEffect(() => {
if (headShaRef.current === headSha) {
return
}
}, [buildDeps, branch, headSha, identity, worktreeId])
headShaRef.current = headSha
// Identity was just wiped (branch/worktree switch): stateRef still holds the
// pre-wipe state in this effect flush, which would start a load flavored for
// the OLD surface (e.g. heavy details on the Changes tab). Let the owning
// surface's hidden-state effects drive the first load for the new identity.
if (stateIdentityRef.current === null) {
return
}
const current = stateRef.current
if (!shouldSoftRefreshPrSidebarOnHeadChange(current.kind)) {
return
}
void load({
includeDetails: current.kind === 'ready' && current.data.details != null
})
}, [headSha, load])
const openPRSidebar = useCallback(() => {
setShowPRSidebar(true)
@@ -131,17 +315,28 @@ export function useMobilePrSidebarController(input: PrSidebarControllerInput) {
stateIdentityRef.current !== identity ||
(state.kind !== 'ready' && state.kind !== 'loading')
) {
void load()
void load({ includeDetails: true })
} else if (state.kind === 'ready' && prSidebarDetailsNeedFetch(state.data.details)) {
// Retries phase-2 placeholders too (failed details load, not only null).
void ensurePrSidebarDetails()
}
}, [identity, state.kind, load])
}, [identity, state, load, ensurePrSidebarDetails])
const retry = useCallback(() => {
void load({ includeDetails: true })
}, [load])
return {
prSidebarState: state,
prSidebarIsGithubRepo: isGithubRepo,
prSidebarRepoProbeLoaded: repoProbeLoaded,
showPRSidebar,
setShowPRSidebar,
openPRSidebar,
retryPRSidebar: load,
refetchPRSidebar: load
retryPRSidebar: retry,
refetchPRSidebar: load,
ensurePrSidebarDetails
}
}
export type MobilePrSidebarController = ReturnType<typeof useMobilePrSidebarController>
@@ -0,0 +1,271 @@
import { memo, useCallback, useEffect, useState } from 'react'
import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { ConnectionState, RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { useForceReconnect } from '../transport/client-context'
import {
fetchMobileGitHistory,
mapMobileCommitRows,
type MobileCommitRow
} from './mobile-git-history'
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
import type { GitBranchChangeEntry } from '../../../src/shared/types'
type Props = {
client: RpcClient | null
connState: ConnectionState
worktreeId: string
// Needed so Retry can revive a parked reconnect loop (STA-1511 / #5049).
hostId: string
bottomInset: number
// Bumped by the hub header refresh so History reloads without remounting.
refreshNonce?: number
}
// Headerless commit-history list. Extracted from the /history route so the hub's
// History segment and the standalone route render the same body over one code path.
// Memoized: it stays mounted (hidden) while the Changes segment is active, and must
// not re-reconcile its FlatList on every commit-message keystroke re-render.
export const MobileGitHistoryList = memo(function MobileGitHistoryList({
client,
connState,
worktreeId,
hostId,
bottomInset,
refreshNonce = 0
}: Props) {
const forceReconnect = useForceReconnect()
const [rows, setRows] = useState<MobileCommitRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [reloadNonce, setReloadNonce] = useState(0)
const [expanded, setExpanded] = useState<string | null>(null)
const [filesById, setFilesById] = useState<Record<string, GitBranchChangeEntry[] | 'loading'>>({})
// Worktree identity change must wipe history immediately — even while
// disconnected — so a kept-mounted hub segment never shows another tree's commits.
useEffect(() => {
setRows(null)
setError(null)
setExpanded(null)
setFilesById({})
}, [worktreeId])
useEffect(() => {
let active = true
if (!client || connState !== 'connected' || !worktreeId) {
// Why: leave already-loaded rows (and expand state) alone across a drop —
// resolveMobileHistoryScreenView keeps them visible (STA-1511).
return
}
// Reset prior error/rows so a successful retry doesn't stay stuck behind a
// stale error (error wins render precedence).
setError(null)
setRows(null)
setExpanded(null)
setFilesById({})
void (async () => {
try {
const result = await fetchMobileGitHistory(client, worktreeId)
if (active) {
setRows(mapMobileCommitRows(result, Date.now()))
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : 'Failed to load history')
}
}
})()
return () => {
active = false
}
}, [client, connState, reloadNonce, refreshNonce, worktreeId])
const retry = useCallback(() => {
setError(null)
// Why: retrying the fetch is useless while the transport's reconnect loop
// is parked at its backoff cap — revive the connection instead (mirrors
// MobileSourceControlPanel / issue #5049). The load effect re-runs via
// connState once the fresh client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
setReloadNonce((n) => n + 1)
}, [connState, forceReconnect, hostId])
const toggleCommit = useCallback(
(row: MobileCommitRow) => {
const next = expanded === row.id ? null : row.id
setExpanded(next)
if (next && !filesById[row.id]) {
// No client (disconnected while cached rows stay visible): resolve to an
// empty file list so the row shows "No file changes" instead of a spinner
// that never completes — no request can be made.
if (!client) {
setFilesById((prev) => ({ ...prev, [row.id]: [] }))
return
}
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', { worktree: `id:${worktreeId}`, commitId: row.id })
.then((response) => {
const entries = response.ok
? ((response as RpcSuccess).result as { entries: GitBranchChangeEntry[] }).entries
: []
setFilesById((prev) => {
// Drop stale responses if the row is no longer loading (collapsed + re-opened).
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: entries }
})
})
.catch(() =>
setFilesById((prev) => {
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: [] }
})
)
}
},
[client, expanded, filesById, worktreeId]
)
const renderCommit = useCallback(
({ item }: { item: MobileCommitRow }) => {
const files = filesById[item.id]
const isOpen = expanded === item.id
return (
<View style={styles.commit}>
<Pressable
style={({ pressed }) => [styles.commitHeader, pressed && styles.commitHeaderPressed]}
onPress={() => toggleCommit(item)}
>
{isOpen ? (
<ChevronDown size={14} color={colors.textMuted} />
) : (
<ChevronRight size={14} color={colors.textMuted} />
)}
<View style={styles.commitMain}>
<Text style={styles.commitSubject} numberOfLines={1}>
{item.subject}
</Text>
<Text style={styles.commitMeta} numberOfLines={1}>
{item.shortId} · {item.author} · {item.relativeTime}
</Text>
</View>
</Pressable>
{isOpen ? (
<View style={styles.files}>
{files === 'loading' || files === undefined ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : files.length === 0 ? (
<Text style={styles.empty}>No file changes</Text>
) : (
files.map((file) => (
<View key={file.path} style={styles.fileRow}>
<Text style={styles.filePath} numberOfLines={1}>
{file.path}
</Text>
<Text style={styles.fileStat}>
{file.added ? <Text style={styles.add}>+{file.added} </Text> : null}
{file.removed ? <Text style={styles.del}>-{file.removed}</Text> : null}
</Text>
</View>
))
)}
</View>
) : null}
</View>
)
},
[expanded, filesById, toggleCommit]
)
const view = resolveMobileHistoryScreenView({
connected: client !== null && connState === 'connected',
rows,
error
})
if (view.kind === 'error' || view.kind === 'waiting') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>
{view.kind === 'waiting' ? 'Waiting for desktop...' : view.message}
</Text>
<Pressable style={styles.retryButton} onPress={retry} accessibilityLabel="Retry">
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
)
}
if (view.kind === 'loading') {
return (
<View style={styles.state}>
<ActivityIndicator color={colors.textSecondary} />
</View>
)
}
if (view.kind === 'empty') {
return (
<View style={styles.state}>
<Text style={styles.stateText}>No commits.</Text>
</View>
)
}
return (
<FlatList
data={view.rows}
renderItem={renderCommit}
keyExtractor={(row) => row.id}
contentContainerStyle={{ paddingBottom: spacing.lg + bottomInset }}
/>
)
})
const styles = StyleSheet.create({
state: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: spacing.lg },
stateText: { color: colors.textMuted, fontSize: typography.bodySize },
retryButton: {
marginTop: spacing.md,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
retryText: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
commit: { borderBottomWidth: 1, borderBottomColor: colors.borderSubtle },
commitHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2
},
commitHeaderPressed: { backgroundColor: colors.bgRaised },
commitMain: { flex: 1, minWidth: 0 },
commitSubject: { color: colors.textPrimary, fontSize: typography.bodySize },
commitMeta: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily,
marginTop: 2
},
files: { paddingHorizontal: spacing.lg, paddingBottom: spacing.sm, gap: 4 },
fileRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm },
filePath: {
flex: 1,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: typography.monoFamily
},
fileStat: { fontSize: typography.metaSize, fontFamily: typography.monoFamily },
add: { color: colors.gitDecorationAdded },
del: { color: colors.gitDecorationDeleted },
empty: { color: colors.textMuted, fontSize: typography.metaSize }
})
@@ -0,0 +1,78 @@
import { Pressable, Text, View } from 'react-native'
import { GitBranch } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
import { MobileSourceControlPrChip } from './MobileSourceControlPrChip'
import type { MobilePrChipSummary } from './mobile-pr-chip-summary'
import { mobileConflictAbortLabel } from './mobile-source-control-conflict-abort'
type Props = {
branchLabel: string
syncLabel: string | null
unstagedCount: number
stagedCount: number
branchCount: number
conflictOperation: string | null
// True while any serial git IO is in flight — disables Abort so ops don't race.
conflictBusy: boolean
// True only while abort-merge / abort-rebase itself is running (label accuracy).
conflictAborting: boolean
onAbortConflict: (operation: string) => void
// The PR chip is shown only on repos with a hosted-review remote; null hides it.
prChip: MobilePrChipSummary | null
onOpenPr: () => void
}
// Persistent card at the top of every hub segment: branch identity, sync/counts,
// conflict state, and the PR chip. Shared so PR/History see the same status the
// Changes lens does without re-deriving it.
export function MobileSourceControlBranchCard({
branchLabel,
syncLabel,
unstagedCount,
stagedCount,
branchCount,
conflictOperation,
conflictBusy,
conflictAborting,
onAbortConflict,
prChip,
onOpenPr
}: Props) {
const showConflict = conflictOperation !== null && conflictOperation !== 'unknown'
return (
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.branchText} numberOfLines={1}>
{branchLabel}
</Text>
</View>
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
</View>
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
{showConflict ? (
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{conflictOperation}</Text>
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
<Pressable
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
disabled={conflictBusy}
onPress={() => onAbortConflict(conflictOperation)}
>
<Text style={styles.abortText}>
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
</Text>
</Pressable>
) : null}
</View>
) : null}
</View>
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
</View>
)
}
@@ -1,5 +1,5 @@
import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native'
import { GitBranch, Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
import { colors, spacing } from '../theme/mobile-theme'
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
import { MobileCommitFailurePanel } from './MobileCommitFailurePanel'
@@ -7,15 +7,17 @@ import { KEYBOARD_COMMIT_BAR_CLEARANCE } from './mobile-source-control-screen-st
import { makeRenderFileRow, BranchCompareFooter } from './MobileSourceControlFileRows'
import type { MobileSourceControlState } from './use-mobile-source-control-state'
import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
type Props = {
state: MobileSourceControlState
}
// The ready-state body: summary card, changed-files list, and commit bar.
// The ready-state Changes segment body: quick actions, changed-files list, and commit bar.
export function MobileSourceControlContent({ state }: Props) {
const {
insets,
connState,
busyAction,
commitMessage,
setCommitMessage,
@@ -28,23 +30,17 @@ export function MobileSourceControlContent({ state }: Props) {
keyboardLift,
openingPath,
openingBranchPath,
status,
sections,
branchEntries,
hasVisibleChanges,
stageablePaths,
unstageablePaths,
stagedCount,
unstagedCount,
branchLabel,
syncLabel,
primaryAction,
createPrAction,
stageAll,
unstageAll,
generateCommitMessage,
cancelGenerateCommitMessage,
abortConflictOperation,
openFile,
openBranchDiff,
runGitAction
@@ -56,41 +52,16 @@ export function MobileSourceControlContent({ state }: Props) {
return (
<>
<View style={styles.summaryCard}>
<View style={styles.summaryHeader}>
<View style={styles.branchLine}>
<GitBranch size={15} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={styles.branchText} numberOfLines={1}>
{branchLabel}
</Text>
</View>
{syncLabel ? <Text style={styles.syncText}>{syncLabel}</Text> : null}
</View>
<View style={styles.countRow}>
<Text style={styles.countText}>{unstagedCount} changed</Text>
<Text style={styles.countText}>{stagedCount} staged</Text>
{branchEntries.length > 0 ? (
<Text style={styles.countText}>{branchEntries.length} on branch</Text>
) : null}
{status && status.conflictOperation !== 'unknown' ? (
<View style={styles.conflictRow}>
<Text style={styles.conflictText}>{status.conflictOperation}</Text>
{(status.conflictOperation === 'merge' || status.conflictOperation === 'rebase') && (
<Pressable
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
disabled={busyAction !== null}
onPress={() => void abortConflictOperation(status.conflictOperation)}
>
<Text style={styles.abortText}>
{busyAction === `abort-${status.conflictOperation}`
? 'Aborting…'
: `Abort ${status.conflictOperation}`}
</Text>
</Pressable>
)}
</View>
) : null}
{connState !== 'connected' ? (
// Why: once data has loaded the screen looks alive even when the
// desktop link is down, so taps appear to do nothing (STA-1511).
// Surface the reconnect state where the user is looking.
<View style={styles.reconnectBanner}>
<ActivityIndicator size="small" color={colors.statusAmber} />
<Text style={styles.reconnectBannerText}>Reconnecting to desktop...</Text>
</View>
) : null}
<View style={hubStyles.changesControls}>
{commitFailureRecovery ? (
<MobileCommitFailurePanel
failure={commitFailureRecovery}
@@ -160,6 +131,7 @@ export function MobileSourceControlContent({ state }: Props) {
</View>
) : (
<SectionList
style={hubStyles.tabBody}
sections={sections}
renderItem={makeRenderFileRow({
busyAction,
@@ -1,5 +1,5 @@
import { Pressable, Text, View } from 'react-native'
import { ChevronLeft, RefreshCw, X } from 'lucide-react-native'
import { ChevronLeft, ExternalLink, RefreshCw, X } from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-source-control-styles'
@@ -9,6 +9,10 @@ type Props = {
ioBusy: boolean
onBack: () => void
onRefresh: () => void
// When set (PR segment ready with a host URL), show open-on-web flush-right of
// the title so the control stays visible while the PR body scrolls.
onOpenPrWeb?: () => void
prNumber?: number | null
}
export function MobileSourceControlHeader({
@@ -16,7 +20,9 @@ export function MobileSourceControlHeader({
worktreeLabel,
ioBusy,
onBack,
onRefresh
onRefresh,
onOpenPrWeb,
prNumber = null
}: Props) {
return (
<View style={styles.topBar}>
@@ -40,6 +46,21 @@ export function MobileSourceControlHeader({
{worktreeLabel}
</Text>
</View>
{onOpenPrWeb ? (
<Pressable
style={({ pressed }) => [styles.refreshButton, pressed && styles.refreshButtonPressed]}
onPress={onOpenPrWeb}
hitSlop={8}
accessibilityRole="link"
accessibilityLabel={
prNumber != null
? `Open pull request #${prNumber} on the web`
: 'Open pull request on the web'
}
>
<ExternalLink size={18} color={colors.textSecondary} strokeWidth={2.1} />
</Pressable>
) : null}
<Pressable
style={({ pressed }) => [
styles.refreshButton,
@@ -1,3 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { colors } from '../theme/mobile-theme'
@@ -6,7 +7,18 @@ import { useMobileSourceControlActionSheet } from './use-mobile-source-control-a
import { MobileSourceControlHeader } from './MobileSourceControlHeader'
import { MobileSourceControlContent } from './MobileSourceControlContent'
import { MobileSourceControlModals } from './MobileSourceControlModals'
import { MobileSourceControlSegments } from './MobileSourceControlSegments'
import { MobileSourceControlBranchCard } from './MobileSourceControlBranchCard'
import { MobileGitHistoryList } from './MobileGitHistoryList'
import { styles } from './mobile-source-control-styles'
import { hubStyles } from './mobile-source-control-hub-styles'
import type { SourceControlHubTab } from './mobile-source-control-hub-tab'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
import { isMobileConflictAborting } from './mobile-source-control-conflict-abort'
import { useMobilePrSidebarController } from '../session/use-mobile-pr-sidebar-controller'
import { prSidebarDetailsNeedFetch } from '../session/mobile-pr-sidebar-state'
import { MobilePrViewPanelBody } from '../components/pr-sidebar/MobilePrViewPanel'
import { openMobilePrUrl } from '../components/MobilePrComposeSheet'
export type MobileSourceControlPanelProps = {
hostId: string
@@ -15,6 +27,8 @@ export type MobileSourceControlPanelProps = {
/** Where the panel was launched from; drives the file-open dismissal path. */
origin?: string
embedded?: boolean
/** Initial hub segment (from the route's `tab` deep-link param). */
initialTab?: SourceControlHubTab
onRequestClose?: () => void
onFileOpenStart?: () => void
onOpenedFileDiff?: (relativePath: string) => void
@@ -26,10 +40,48 @@ export function MobileSourceControlPanel({
name = '',
origin = '',
embedded = false,
initialTab = 'changes',
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
}: MobileSourceControlPanelProps) {
const [activeTab, setActiveTab] = useState<SourceControlHubTab>(initialTab)
// Track first visit so Changes/History keep scroll state across segment switches
// without paying the mount cost until the user actually opens them. PR unmounts
// when inactive (WebViews / comment tree) but its controller state stays for the chip.
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<SourceControlHubTab>>(
() => new Set<SourceControlHubTab>([initialTab])
)
const [historyRefreshNonce, setHistoryRefreshNonce] = useState(0)
// Deep-link / push with a different `tab` param should adopt the new segment
// (expo-router can reuse the screen instance when only query params change).
useEffect(() => {
setActiveTab(initialTab)
setVisitedTabs((prev) => {
if (prev.has(initialTab)) {
return prev
}
const next = new Set(prev)
next.add(initialTab)
return next
})
}, [initialTab])
const selectTab = useCallback((tab: SourceControlHubTab) => {
setActiveTab(tab)
setVisitedTabs((prev) => {
if (prev.has(tab)) {
return prev
}
const next = new Set(prev)
next.add(tab)
return next
})
}, [])
const openHistoryTab = useCallback(() => selectTab('history'), [selectTab])
const openPrTab = useCallback(() => selectTab('pr'), [selectTab])
const state = useMobileSourceControlState({
hostId,
worktreeId,
@@ -38,12 +90,15 @@ export function MobileSourceControlPanel({
embedded,
onRequestClose,
onFileOpenStart,
onOpenedFileDiff
onOpenedFileDiff,
onOpenHistory: openHistoryTab
})
const actionSheetActions = useMobileSourceControlActionSheet(state)
const {
client,
connState,
forceReconnect,
insets,
router,
setRootRef,
worktreeLabel,
@@ -51,25 +106,207 @@ export function MobileSourceControlPanel({
busyAction,
openingPath,
openingBranchPath,
loadStatus
loadStatus,
status,
branchCompareResult,
branchLabel,
syncLabel,
unstagedCount,
stagedCount,
branchEntries,
abortConflictOperation
} = state
const ioBusy = busyAction !== null || openingPath !== null || openingBranchPath !== null
const ready = screenState.kind === 'ready'
// One PR controller feeds both the branch-card chip and the Pull Request
// segment, so the chip's rollup can never disagree with the checks list it
// links to. Branch + head come from the already-loaded git.status — no second
// status read. The chip loads independently, so it never blocks the file list.
// Keep last-known identity across a transient status unload (disconnect /
// failed refresh) so the controller does not wipe ready → hidden → cold start.
// Head SHA matches the review path: status.head ?? branchCompare headOid.
const lastPrBranchRef = useRef<string | null>(null)
const lastPrHeadRef = useRef<string | null>(null)
useEffect(() => {
lastPrBranchRef.current = null
lastPrHeadRef.current = null
}, [worktreeId])
const statusBranch = status?.branch ?? null
const statusHead = status?.head ?? branchCompareResult?.summary.headOid ?? null
// Write last-known identity in an effect, not the render body: a discarded
// concurrent render must not leave the fallback holding a never-committed value.
useEffect(() => {
if (statusBranch) {
lastPrBranchRef.current = statusBranch
}
if (statusHead) {
lastPrHeadRef.current = statusHead
}
}, [statusBranch, statusHead])
const prBranch = statusBranch ?? lastPrBranchRef.current
const prHeadSha = statusHead ?? lastPrHeadRef.current
const prController = useMobilePrSidebarController({
client,
connState,
worktreeId,
branch: prBranch,
headSha: prHeadSha
})
const isHostedRepo = prController.prSidebarIsGithubRepo
const prSidebarKind = prController.prSidebarState.kind
const refetchPr = prController.refetchPRSidebar
const ensurePrDetails = prController.ensurePrSidebarDetails
// Refs so tab effects do not re-fire when headSha recreates load() (soft refresh).
const refetchPrRef = useRef(refetchPr)
refetchPrRef.current = refetchPr
const ensurePrDetailsRef = useRef(ensurePrDetails)
ensurePrDetailsRef.current = ensurePrDetails
// Chip bootstrap: phase-1 only (PR + checks). Full comment payload waits until
// the Pull Request segment is open — opening SC to stage must not pull details.
useEffect(() => {
if (activeTab === 'pr') {
return
}
if (prBranch && isHostedRepo && prSidebarKind === 'hidden') {
void refetchPrRef.current({ includeDetails: false })
}
}, [activeTab, prBranch, isHostedRepo, prSidebarKind])
// Number of the ready PR whose phase-2 details are still missing (null when
// none). Keyed by PR number — not a boolean — so a same-branch PR swap during a
// chip-only soft refresh re-arms phase 2 for the new PR instead of leaving its
// comments on a forever-spinner (a stale ensure bails on the number mismatch).
// Placeholder details (failed phase 2) also count as missing so reopening the
// PR tab retries instead of leaving empty Description/Comments forever.
const prDetailsMissingFor =
prController.prSidebarState.kind === 'ready' &&
prSidebarDetailsNeedFetch(prController.prSidebarState.data.details)
? prController.prSidebarState.data.pr.number
: null
// PR segment: full load or phase-2 fill-in. Body only mounts while active.
// Guarded (and keyed) on prBranch so a branch that arrives while the segment is
// already open — e.g. mounted on a detached HEAD, then checkout — still loads;
// kind stays 'hidden' through that transition, so kind alone can't re-fire this.
useEffect(() => {
if (activeTab !== 'pr' || !isHostedRepo || !prBranch) {
return
}
if (prSidebarKind === 'hidden') {
void refetchPrRef.current({ includeDetails: true })
return
}
if (prDetailsMissingFor != null) {
void ensurePrDetailsRef.current()
}
}, [activeTab, isHostedRepo, prBranch, prSidebarKind, prDetailsMissingFor])
const prChip = useMemo(() => {
// No branch (detached HEAD / mid-rebase) never runs a PR load, so the shared
// state stays 'hidden' — which the chip would render as a forever spinner.
// Hide the chip instead; the Pull Request segment shows "branch unavailable".
if (!isHostedRepo || !prBranch) {
return null
}
const commentCount =
prController.prSidebarState.kind === 'ready'
? countUnresolvedReviewThreads(prController.prSidebarState.data.details?.comments)
: null
return buildMobilePrChipSummary(prController.prSidebarState, commentCount)
}, [isHostedRepo, prBranch, prController.prSidebarState])
// Design: refresh the active segment's body work, plus git.status for the shared
// branch card (counts/sync stay honest even while on History). Preserve ready
// status on a failed refresh so PR chip identity is not wiped to hidden.
const onRefresh = useCallback(() => {
void loadStatus({ preserveReadyOnFailure: true })
if (activeTab === 'history') {
setHistoryRefreshNonce((n) => n + 1)
return
}
if (!isHostedRepo) {
return
}
if (activeTab === 'pr') {
void refetchPr({ includeDetails: true })
return
}
// Changes: light chip refresh so the branch card stays current without comments.
void refetchPr({ includeDetails: false })
}, [activeTab, isHostedRepo, loadStatus, refetchPr])
// Embedded mode docks beside the terminal: close the dock instead of popping
// a route, and skip the full-screen safe-area chrome (the dock column owns it).
// Fall back to router.back() when embedded without a close handler so the button
// never silently no-ops.
const onBack = embedded ? (onRequestClose ?? (() => router.back())) : () => router.back()
// Chromeless PR body has no panel header — surface open-on-web on the hub chrome
// while the Pull Request segment is active (same affordance as the old /pr route).
const prWebUrl =
activeTab === 'pr' &&
prController.prSidebarState.kind === 'ready' &&
prController.prSidebarState.data.pr.url
? prController.prSidebarState.data.pr.url
: null
const prWebNumber =
prController.prSidebarState.kind === 'ready' ? prController.prSidebarState.data.pr.number : null
const header = (
<MobileSourceControlHeader
embedded={embedded}
worktreeLabel={worktreeLabel}
ioBusy={ioBusy}
onBack={onBack}
onRefresh={() => void loadStatus()}
onRefresh={onRefresh}
onOpenPrWeb={prWebUrl ? () => openMobilePrUrl(prWebUrl) : undefined}
prNumber={prWebNumber}
/>
)
const statusGate =
screenState.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
</View>
) : null
// History only needs the RPC client — do not block it behind git.status.
// Changes/PR need status (branch, file list, head SHA), so they stay gated.
const showChanges = ready && (activeTab === 'changes' || visitedTabs.has('changes'))
const showHistory = activeTab === 'history' || visitedTabs.has('history')
// Why: only mount the PR body while its segment is active. Keep-mounting retained
// Mermaid WebViews and re-rendered the comment tree on every commit keystroke.
// Controller + chip state still live for instant re-open without a full cold start.
const showPrBody = ready && activeTab === 'pr'
const conflictOperation = status?.conflictOperation ?? null
// Git status always reports a conflictOperation enum; 'unknown' means none.
const hasActiveConflict = conflictOperation != null && conflictOperation !== 'unknown'
const conflictAborting = isMobileConflictAborting(busyAction, conflictOperation)
return (
<View ref={setRootRef} style={styles.container}>
{embedded ? (
@@ -80,38 +317,68 @@ export function MobileSourceControlPanel({
</SafeAreaView>
)}
{screenState.kind === 'loading' ? (
<View style={styles.state}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<MobileSourceControlSegments active={activeTab} onSelect={selectTab} />
{/* Branch card + PR chip are the Changes/Commits glance layer. On the PR
tab they duplicate the ready PR body (#, state, checks rollup, branch
trajectory), so hide the whole card there and let the PR panel own it —
unless a merge/rebase conflict is active, which only this card can abort. */}
{ready && (activeTab !== 'pr' || hasActiveConflict) ? (
<MobileSourceControlBranchCard
branchLabel={branchLabel}
syncLabel={syncLabel}
unstagedCount={unstagedCount}
stagedCount={stagedCount}
branchCount={branchEntries.length}
conflictOperation={conflictOperation}
conflictBusy={busyAction !== null}
conflictAborting={conflictAborting}
onAbortConflict={(operation) => void abortConflictOperation(operation)}
prChip={prChip}
onOpenPr={openPrTab}
/>
) : null}
{showChanges ? (
<View style={activeTab === 'changes' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
<MobileSourceControlContent state={state} />
</View>
) : screenState.kind === 'error' || screenState.kind === 'unavailable' ? (
<View style={styles.state}>
<Text style={styles.stateTitle}>
{screenState.kind === 'unavailable' ? 'Source Control Unavailable' : 'Unable to Load'}
</Text>
<Text style={styles.stateText}>{screenState.message}</Text>
{screenState.kind === 'error' ? (
<Pressable
style={styles.retryButton}
onPress={() => {
// Why: retrying the request is useless while the transport's
// reconnect loop is parked at its give-up cap — revive the
// connection instead (issue #5049). loadStatus re-runs via
// its connState effect once the new client connects.
if (connState !== 'connected' && hostId) {
void forceReconnect(hostId)
return
}
void loadStatus()
}}
>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
) : null}
) : activeTab === 'changes' ? (
statusGate
) : null}
{showPrBody ? (
<View style={hubStyles.tabBody}>
<MobilePrViewPanelBody
client={client}
connState={connState}
worktreeId={worktreeId}
branch={prBranch}
headSha={prHeadSha}
gitStatus={status}
isGithubRepo={isHostedRepo}
// Gate on the probe too: isGithubRepo=false mid-probe must render as
// loading, not flash "unavailable for this provider" (old /pr parity).
branchContextLoaded={ready && prController.prSidebarRepoProbeLoaded}
controller={prController}
/>
</View>
) : (
<MobileSourceControlContent state={state} />
)}
) : activeTab === 'pr' ? (
statusGate
) : null}
{showHistory ? (
<View style={activeTab === 'history' ? hubStyles.tabBody : hubStyles.tabBodyHidden}>
<MobileGitHistoryList
client={client}
connState={connState}
worktreeId={worktreeId}
hostId={hostId}
bottomInset={insets.bottom}
refreshNonce={historyRefreshNonce}
/>
</View>
) : null}
<MobileSourceControlModals state={state} actionSheetActions={actionSheetActions} />
</View>
@@ -0,0 +1,121 @@
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import {
AlertTriangle,
Check,
ChevronRight,
CircleDot,
GitPullRequest,
MessageSquare,
X
} from 'lucide-react-native'
import { colors } from '../theme/mobile-theme'
import { statusColor } from '../components/pr-sidebar/pr-sidebar-status-color'
import { hubStyles } from './mobile-source-control-hub-styles'
import type { MobilePrChipRollup, MobilePrChipSummary } from './mobile-pr-chip-summary'
type Props = {
summary: MobilePrChipSummary
onPress: () => void
}
// The glanceable PR status line on the branch card. Tapping it switches to the
// Pull Request segment. Rendered only when the repo supports hosted review — the
// parent gates on that, so this component always has something meaningful to show.
export function MobileSourceControlPrChip({ summary, onPress }: Props) {
return (
<Pressable
style={({ pressed }) => [hubStyles.chip, pressed && hubStyles.chipPressed]}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={chipAccessibilityLabel(summary)}
>
<View style={hubStyles.chipIcon}>
<GitPullRequest size={15} color={colors.textSecondary} strokeWidth={2.1} />
</View>
{summary.kind === 'loading' ? (
<>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
Loading pull request…
</Text>
</>
) : summary.kind === 'none' ? (
<>
<Text style={hubStyles.chipCreateText}>Create pull request</Text>
<View style={hubStyles.chipSpacer} />
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
) : summary.kind === 'unavailable' ? (
<>
<Text style={hubStyles.chipMutedText} numberOfLines={1}>
{summary.message}
</Text>
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
) : (
<>
<Text style={hubStyles.chipNumber}>#{summary.number}</Text>
<View style={[hubStyles.statePill, { borderColor: statusColor(summary.stateToken) }]}>
<Text style={[hubStyles.statePillText, { color: statusColor(summary.stateToken) }]}>
{summary.stateLabel}
</Text>
</View>
<ChipRollup rollup={summary.rollup} />
{summary.commentCount != null && summary.commentCount > 0 ? (
<View style={hubStyles.comment}>
<MessageSquare size={13} color={colors.textSecondary} strokeWidth={2.1} />
<Text style={hubStyles.commentText}>{summary.commentCount}</Text>
</View>
) : null}
<View style={hubStyles.chipSpacer} />
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.1} />
</>
)}
</Pressable>
)
}
function ChipRollup({ rollup }: { rollup: MobilePrChipRollup }) {
const color = statusColor(rollup.token)
return (
<View style={hubStyles.rollup}>
<RollupIcon kind={rollup.kind} color={color} />
<Text style={[hubStyles.rollupText, { color }]}>{rollup.text}</Text>
</View>
)
}
function RollupIcon({ kind, color }: { kind: MobilePrChipRollup['kind']; color: string }) {
const size = 13
const strokeWidth = 2.3
switch (kind) {
case 'conflict':
return <AlertTriangle size={size} color={color} strokeWidth={strokeWidth} />
case 'failing':
return <X size={size} color={color} strokeWidth={strokeWidth} />
case 'running':
return <CircleDot size={size} color={color} strokeWidth={strokeWidth} />
case 'passed':
return <Check size={size} color={color} strokeWidth={strokeWidth} />
case 'none':
return null
}
}
function chipAccessibilityLabel(summary: MobilePrChipSummary): string {
switch (summary.kind) {
case 'loading':
return 'Loading pull request'
case 'none':
return 'Create pull request'
case 'unavailable':
return `Pull request unavailable: ${summary.message}`
case 'ready': {
const comments =
summary.commentCount != null && summary.commentCount > 0
? `, ${summary.commentCount} unresolved comments`
: ''
return `Pull request #${summary.number}, ${summary.stateLabel}, ${summary.rollup.text}${comments}. Open pull request.`
}
}
}
@@ -0,0 +1,45 @@
import { Pressable, Text, View } from 'react-native'
import {
SOURCE_CONTROL_HUB_TABS,
SOURCE_CONTROL_HUB_TAB_LABELS,
type SourceControlHubTab
} from './mobile-source-control-hub-tab'
import { hubStyles } from './mobile-source-control-hub-styles'
type Props = {
active: SourceControlHubTab
onSelect: (tab: SourceControlHubTab) => void
}
// The hub's top-level lens switcher. Switching is local state (no route push) so
// scroll position and the shared branch card persist across Changes/PR/History.
export function MobileSourceControlSegments({ active, onSelect }: Props) {
return (
<View style={hubStyles.segments} accessibilityRole="tablist">
{SOURCE_CONTROL_HUB_TABS.map((tab) => {
const isActive = tab === active
return (
<Pressable
key={tab}
style={({ pressed }) => [
hubStyles.segment,
isActive && hubStyles.segmentActive,
pressed && !isActive && hubStyles.segmentPressed
]}
onPress={() => onSelect(tab)}
accessibilityRole="tab"
accessibilityState={{ selected: isActive }}
accessibilityLabel={SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
>
<Text
style={[hubStyles.segmentText, isActive && hubStyles.segmentTextActive]}
numberOfLines={1}
>
{SOURCE_CONTROL_HUB_TAB_LABELS[tab]}
</Text>
</Pressable>
)
})}
</View>
)
}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { resolveMobileHistoryScreenView } from './mobile-history-screen-state'
import type { MobileCommitRow } from './mobile-git-history'
const row: MobileCommitRow = {
id: 'abc123',
shortId: 'abc123',
subject: 'commit',
author: 'author',
parentId: null,
relativeTime: '1m'
}
describe('resolveMobileHistoryScreenView', () => {
it('shows waiting (not an endless spinner) when disconnected with nothing loaded', () => {
// Regression (STA-1511): the load effect no-ops while disconnected, so
// rows stay null forever — the screen must offer a retry, not a spinner.
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: null })).toEqual({
kind: 'waiting'
})
})
it('spins only while connected and loading', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: null, error: null })).toEqual({
kind: 'loading'
})
})
it('keeps already-loaded rows visible across a connection drop', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: [row], error: null })).toEqual({
kind: 'rows',
rows: [row]
})
})
it('prefers the error state over waiting so failures stay actionable', () => {
expect(resolveMobileHistoryScreenView({ connected: false, rows: null, error: 'boom' })).toEqual(
{ kind: 'error', message: 'boom' }
)
})
it('distinguishes an empty history from a pending load', () => {
expect(resolveMobileHistoryScreenView({ connected: true, rows: [], error: null })).toEqual({
kind: 'empty'
})
})
})
@@ -0,0 +1,30 @@
import type { MobileCommitRow } from './mobile-git-history'
export type MobileHistoryScreenView =
| { kind: 'error'; message: string }
| { kind: 'waiting' }
| { kind: 'loading' }
| { kind: 'empty' }
| { kind: 'rows'; rows: MobileCommitRow[] }
// Why: the history load effect no-ops while the client isn't connected, so
// without an explicit 'waiting' view the screen showed an infinite spinner
// with no retry when opened during a reconnect window (STA-1511). Rows loaded
// before a drop stay visible — stale history beats a blank screen.
export function resolveMobileHistoryScreenView(input: {
connected: boolean
rows: MobileCommitRow[] | null
error: string | null
}): MobileHistoryScreenView {
const { connected, rows, error } = input
if (error) {
return { kind: 'error', message: error }
}
if (rows !== null) {
return rows.length === 0 ? { kind: 'empty' } : { kind: 'rows', rows }
}
if (!connected) {
return { kind: 'waiting' }
}
return { kind: 'loading' }
}
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest'
import type { PRCheckDetail, PRComment, PRInfo } from '../../../src/shared/types'
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
import { buildMobilePrChipSummary, countUnresolvedReviewThreads } from './mobile-pr-chip-summary'
function pr(overrides: Partial<PRInfo> = {}): PRInfo {
return {
number: 7701,
title: 'Make center split more visible',
state: 'open',
url: 'https://example.test/pr/7701',
checksStatus: 'success',
updatedAt: '2026-07-07T00:00:00Z',
mergeable: 'MERGEABLE',
...overrides
}
}
function check(
conclusion: PRCheckDetail['conclusion'],
status: PRCheckDetail['status'] = 'completed'
): PRCheckDetail {
return { name: `check-${conclusion}-${status}`, status, conclusion, url: null }
}
function ready(prInfo: PRInfo, checks: PRCheckDetail[]): PrSidebarState {
return { kind: 'ready', data: { pr: prInfo, checks, details: null } }
}
describe('buildMobilePrChipSummary', () => {
it('maps non-ready states', () => {
expect(buildMobilePrChipSummary({ kind: 'hidden' })).toEqual({ kind: 'loading' })
expect(buildMobilePrChipSummary({ kind: 'loading' })).toEqual({ kind: 'loading' })
expect(buildMobilePrChipSummary({ kind: 'none' })).toEqual({ kind: 'none' })
expect(buildMobilePrChipSummary({ kind: 'error', message: 'net' })).toEqual({
kind: 'unavailable',
message: 'net'
})
expect(buildMobilePrChipSummary({ kind: 'blocked', message: 'auth' })).toEqual({
kind: 'unavailable',
message: 'auth'
})
})
it('surfaces the PR number and state badge', () => {
const summary = buildMobilePrChipSummary(ready(pr({ state: 'draft' }), [check('success')]))
expect(summary.kind).toBe('ready')
if (summary.kind !== 'ready') {
return
}
expect(summary.number).toBe(7701)
expect(summary.stateLabel).toBe('Draft')
})
it('rolls up passed checks as passed/total', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('success'), check('skipped')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'passed', text: '2/3', token: 'statusGreen' })
})
it('prefers failing over running and passing', () => {
const summary = buildMobilePrChipSummary(
ready(pr(), [check('success'), check('failure'), check(null, 'in_progress')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'failing', text: '1 failing', token: 'statusRed' })
})
it('shows running when nothing has failed yet', () => {
const summary = buildMobilePrChipSummary(ready(pr(), [check('success'), check(null, 'queued')]))
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'running', text: '1 running', token: 'statusAmber' })
})
it('lets a merge conflict win over green checks', () => {
const summary = buildMobilePrChipSummary(
ready(pr({ mergeable: 'CONFLICTING' }), [check('success')])
)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup.kind).toBe('conflict')
})
it('reports no checks when the list is empty', () => {
const summary = buildMobilePrChipSummary(ready(pr(), []))
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.rollup).toEqual({ kind: 'none', text: 'No checks', token: 'textSecondary' })
})
it('passes through the unresolved comment count', () => {
const summary = buildMobilePrChipSummary(ready(pr(), [check('success')]), 3)
if (summary.kind !== 'ready') {
throw new Error('expected ready')
}
expect(summary.commentCount).toBe(3)
})
})
describe('countUnresolvedReviewThreads', () => {
function comment(overrides: Partial<PRComment>): PRComment {
return {
id: 1,
author: 'octocat',
authorAvatarUrl: '',
body: 'x',
createdAt: '2026-07-07T00:00:00Z',
url: '',
...overrides
}
}
it('returns null when details have not loaded', () => {
expect(countUnresolvedReviewThreads(null)).toBeNull()
expect(countUnresolvedReviewThreads(undefined)).toBeNull()
})
it('counts each unresolved thread once and ignores resolved threads', () => {
const comments = [
comment({ id: 1, threadId: 't1', isResolved: false }),
comment({ id: 2, threadId: 't1', isResolved: false }),
comment({ id: 3, threadId: 't2', isResolved: true }),
comment({ id: 4, threadId: 't3' }),
comment({ id: 5 })
]
expect(countUnresolvedReviewThreads(comments)).toBe(2)
})
})
@@ -0,0 +1,104 @@
import type { PRComment } from '../../../src/shared/types'
import type { PrSidebarState } from '../session/mobile-pr-sidebar-state'
import {
prStateBadge,
summarizePRChecks,
type MobileStatusToken
} from '../components/pr-sidebar/pr-checks-presentation'
// Pure derivation of the branch-card PR chip from the shared PR sidebar state.
// No React/native imports so the rollup precedence is unit-testable (KTD5). The
// chip and the Pull Request segment read the SAME PrSidebarState, so the chip's
// check rollup can never disagree with the checks list it links to.
// The single check-rollup token the chip shows. Exactly one wins, by precedence:
// merge conflict > failing > running > passed > no checks. Worst-actionable-first
// so a red/amber signal is never hidden behind a green count.
export type MobilePrChipRollup =
| { kind: 'conflict'; text: string; token: MobileStatusToken }
| { kind: 'failing'; text: string; token: MobileStatusToken }
| { kind: 'running'; text: string; token: MobileStatusToken }
| { kind: 'passed'; text: string; token: MobileStatusToken }
| { kind: 'none'; text: string; token: MobileStatusToken }
export type MobilePrChipSummary =
| { kind: 'loading' }
// GitHub repo, but this branch has no open/linked PR — the chip becomes a
// "create pull request" affordance that routes to the PR segment's composer.
| { kind: 'none' }
// A permanent (auth/permission) or transient failure loading PR data. The chip
// degrades to a muted, tappable line rather than vanishing.
| { kind: 'unavailable'; message: string }
| {
kind: 'ready'
number: number
stateLabel: string
stateToken: MobileStatusToken
rollup: MobilePrChipRollup
// Unresolved review-comment count, when the deferred details payload has
// loaded; null while it is still loading or unavailable.
commentCount: number | null
}
// Unresolved review threads (the "💬 n" count). Counts each inline thread once by
// threadId when it is not resolved; top-level conversation comments (no threadId)
// are not review threads and don't count. Returns null when details haven't loaded.
export function countUnresolvedReviewThreads(
comments: PRComment[] | null | undefined
): number | null {
if (!comments) {
return null
}
const unresolved = new Set<string>()
for (const comment of comments) {
if (comment.threadId && comment.isResolved !== true) {
unresolved.add(comment.threadId)
}
}
return unresolved.size
}
export function buildMobilePrChipSummary(
state: PrSidebarState,
commentCount: number | null = null
): MobilePrChipSummary {
switch (state.kind) {
case 'hidden':
case 'loading':
return { kind: 'loading' }
case 'none':
return { kind: 'none' }
case 'error':
case 'blocked':
return { kind: 'unavailable', message: state.message }
case 'ready': {
const badge = prStateBadge(state.data.pr.state)
return {
kind: 'ready',
number: state.data.pr.number,
stateLabel: badge.label,
stateToken: badge.token,
rollup: buildChipRollup(state),
commentCount
}
}
}
}
function buildChipRollup(state: Extract<PrSidebarState, { kind: 'ready' }>): MobilePrChipRollup {
// Conflicts win: the checks may be green, but the PR still can't merge.
if (state.data.pr.mergeable === 'CONFLICTING') {
return { kind: 'conflict', text: 'Conflicts', token: 'statusAmber' }
}
const checks = summarizePRChecks(state.data.checks)
if (checks.failed > 0) {
return { kind: 'failing', text: `${checks.failed} failing`, token: 'statusRed' }
}
if (checks.pending > 0) {
return { kind: 'running', text: `${checks.pending} running`, token: 'statusAmber' }
}
if (checks.passed > 0) {
return { kind: 'passed', text: `${checks.passed}/${checks.total}`, token: 'statusGreen' }
}
return { kind: 'none', text: 'No checks', token: 'textSecondary' }
}
@@ -52,7 +52,7 @@ describe('buildMobileSourceControlActions', () => {
expect(labels.some((l) => l.startsWith('Fast-forward'))).toBe(true)
expect(labels).toContain('Rebase onto base')
expect(labels).toContain('Switch branch')
expect(labels).toContain('History')
expect(labels).toContain('Commits')
expect(labels).toContain('Create PR')
})
@@ -90,7 +90,7 @@ describe('buildMobileSourceControlActions', () => {
const handlers = noopHandlers()
const actions = buildMobileSourceControlActions(args({ handlers }))
action(actions, 'Switch branch')?.onPress()
action(actions, 'History')?.onPress()
action(actions, 'Commits')?.onPress()
expect(handlers.checkout).toHaveBeenCalled()
expect(handlers.history).toHaveBeenCalled()
})
@@ -217,7 +217,7 @@ export function buildMobileSourceControlActions(
onPress: handlers.checkout
},
{
label: 'History',
label: 'Commits',
iconKey: 'history',
disabled: busy,
onPress: handlers.history
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import {
isMobileConflictAborting,
mobileConflictAbortLabel
} from './mobile-source-control-conflict-abort'
describe('isMobileConflictAborting', () => {
it('is true only for the matching abort action', () => {
expect(isMobileConflictAborting('abort-merge', 'merge')).toBe(true)
expect(isMobileConflictAborting('abort-rebase', 'rebase')).toBe(true)
})
it('is false for other busy actions (stage/commit must not look like abort)', () => {
expect(isMobileConflictAborting('stage-all', 'merge')).toBe(false)
expect(isMobileConflictAborting('commit', 'rebase')).toBe(false)
expect(isMobileConflictAborting(null, 'merge')).toBe(false)
expect(isMobileConflictAborting('abort-merge', 'rebase')).toBe(false)
expect(isMobileConflictAborting('abort-merge', 'unknown')).toBe(false)
})
})
describe('mobileConflictAbortLabel', () => {
it('shows Aborting only while abort is in flight', () => {
expect(mobileConflictAbortLabel('merge', true)).toBe('Aborting…')
expect(mobileConflictAbortLabel('merge', false)).toBe('Abort merge')
expect(mobileConflictAbortLabel('rebase', false)).toBe('Abort rebase')
})
})
@@ -0,0 +1,18 @@
// Pure helpers for the branch-card conflict Abort control. Kept free of React so
// the busy-label rule (abort-in-flight only) is unit-testable.
/** True while git.abortMerge / git.abortRebase is the active serial action. */
export function isMobileConflictAborting(
busyAction: string | null,
conflictOperation: string | null
): boolean {
if (conflictOperation !== 'merge' && conflictOperation !== 'rebase') {
return false
}
return busyAction === `abort-${conflictOperation}`
}
/** Label for the Abort control — never says "Aborting…" for unrelated busy work. */
export function mobileConflictAbortLabel(conflictOperation: string, aborting: boolean): string {
return aborting ? 'Aborting…' : `Abort ${conflictOperation}`
}
@@ -0,0 +1,126 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
// Styles for the hub's segmented control and the branch-card PR chip. Split from
// mobile-source-control-styles.ts so neither file crosses the line limit.
export const hubStyles = StyleSheet.create({
// Full-width tab bar under the hub header. Edge-to-edge, no inset track and no
// inner padding — segments share height evenly so the control doesn't float in a
// pill frame with gaps above/below the active cell.
segments: {
flexDirection: 'row',
alignItems: 'stretch',
width: '100%',
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
segment: {
flex: 1,
minHeight: 40,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.xs,
// Reserved so active underline doesn't change layout height.
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
segmentActive: {
borderBottomColor: colors.textPrimary
},
segmentPressed: {
opacity: 0.7
},
segmentText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
segmentTextActive: {
color: colors.textPrimary
},
// The PR chip sits below the count row inside the branch card, separated by a
// hairline so it reads as a distinct, tappable status line.
chip: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
chipPressed: {
opacity: 0.7
},
chipIcon: {
width: 18,
alignItems: 'center'
},
chipNumber: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '700'
},
statePill: {
paddingHorizontal: spacing.sm,
paddingVertical: 1,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth
},
statePillText: {
fontSize: typography.metaSize,
fontWeight: '700'
},
rollup: {
flexDirection: 'row',
alignItems: 'center',
gap: 4
},
rollupText: {
fontSize: typography.metaSize,
fontWeight: '600'
},
comment: {
flexDirection: 'row',
alignItems: 'center',
gap: 4
},
commentText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
// Pushes the chevron to the trailing edge without a fixed-width spacer.
chipSpacer: {
flex: 1,
minWidth: spacing.sm
},
chipCreateText: {
color: colors.accentBlue,
fontSize: typography.bodySize,
fontWeight: '600'
},
chipMutedText: {
flex: 1,
color: colors.textMuted,
fontSize: typography.metaSize
},
// Wraps the Changes-only controls (commit-failure/error notice, create-PR entry,
// bulk Stage/Unstage row) that used to live inside the summary card, now that the
// card is shared across segments and holds only branch status.
changesControls: {
paddingHorizontal: spacing.lg,
marginTop: spacing.xs
},
// Fills the remaining space below the header/segments/card so each segment's
// scroll view (SectionList / PR sidebar / history list) expands and scrolls.
tabBody: {
flex: 1
},
// Keep a previously-visited segment mounted (scroll + fetch state) without
// participating in layout while another segment is active.
tabBodyHidden: {
display: 'none'
}
})
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import { parseSourceControlHubTab, SOURCE_CONTROL_HUB_TABS } from './mobile-source-control-hub-tab'
describe('parseSourceControlHubTab', () => {
it('accepts each known tab', () => {
for (const tab of SOURCE_CONTROL_HUB_TABS) {
expect(parseSourceControlHubTab(tab)).toBe(tab)
}
})
it('reads the first element of an array param', () => {
expect(parseSourceControlHubTab(['pr', 'history'])).toBe('pr')
})
it('falls back to changes for unknown, empty, or missing values', () => {
expect(parseSourceControlHubTab('nope')).toBe('changes')
expect(parseSourceControlHubTab('')).toBe('changes')
expect(parseSourceControlHubTab(undefined)).toBe('changes')
expect(parseSourceControlHubTab(null)).toBe('changes')
expect(parseSourceControlHubTab([])).toBe('changes')
})
})
@@ -0,0 +1,29 @@
// The three lenses of the mobile Source Control hub. Kept as a pure module (no
// React/native imports) so tab parsing is unit-testable and the deep-link `tab`
// query param and the segmented control share one source of truth.
export type SourceControlHubTab = 'changes' | 'pr' | 'history'
export const SOURCE_CONTROL_HUB_TABS: readonly SourceControlHubTab[] = [
'changes',
'pr',
'history'
] as const
export const SOURCE_CONTROL_HUB_TAB_LABELS: Record<SourceControlHubTab, string> = {
changes: 'Changes',
pr: 'Pull Request',
history: 'Commits'
}
// Normalize a route param (possibly an array from expo-router, possibly unknown)
// to a valid tab, defaulting to 'changes'. Deep links that name a stale/invalid
// tab fall back rather than render a blank body.
export function parseSourceControlHubTab(
value: string | string[] | undefined | null
): SourceControlHubTab {
const first = Array.isArray(value) ? value[0] : value
return SOURCE_CONTROL_HUB_TABS.includes(first as SourceControlHubTab)
? (first as SourceControlHubTab)
: 'changes'
}
@@ -125,6 +125,24 @@ const baseStyles = StyleSheet.create({
fontWeight: '600',
textTransform: 'capitalize'
},
reconnectBanner: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginHorizontal: spacing.lg,
marginTop: spacing.lg,
marginBottom: -spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.statusAmber
},
reconnectBannerText: {
color: colors.textPrimary,
fontSize: typography.metaSize
},
actionError: {
marginTop: spacing.sm,
paddingHorizontal: spacing.md,
@@ -45,6 +45,8 @@ type Params = {
setCreatedPrUrl: (next: string | null) => void
setCreatedPrWarning: (next: string | null) => void
recordCommitFailure: RecordMobileCommitFailure
// Hub override: switch to the History segment instead of pushing the route.
onOpenHistory?: () => void
}
// All git workflow + action-sheet runners for the source-control panel. Split
@@ -78,7 +80,8 @@ export function useMobileSourceControlRunners(params: Params) {
setShowBranchPicker,
setCreatedPrUrl,
setCreatedPrWarning,
recordCommitFailure
recordCommitFailure,
onOpenHistory
} = params
const runGitWorkflow = useCallback(
@@ -248,14 +251,24 @@ export function useMobileSourceControlRunners(params: Params) {
const openHistory = useCallback(() => {
setShowActionSheet(false)
if (hostId && worktreeId) {
router.push(
`/h/${hostId}/history/${encodeURIComponent(worktreeId)}` as Parameters<
typeof router.push
>[0]
)
// Inside the hub, History is a segment — switch to it rather than pushing a
// route. Fallback pushes the hub with `tab=history` (not the redirecting
// /history route) so deep links land in one hop.
if (onOpenHistory) {
onOpenHistory()
return
}
}, [hostId, router, setShowActionSheet, worktreeId])
if (hostId && worktreeId) {
router.push({
pathname: '/h/[hostId]/source-control/[worktreeId]',
params: {
hostId,
worktreeId,
tab: 'history'
}
} as Parameters<typeof router.push>[0])
}
}, [hostId, onOpenHistory, router, setShowActionSheet, worktreeId])
// Switch to a local branch, then reload status.
const checkoutBranch = useCallback(

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