Merge origin/main (1bdbc23648) into settle-split part 2

Main's #28394 (chat scratch folders expire after 30 days unused) named the
idle sweep's two dispatch checks, `hasOpenDispatch` and
`providerHoldsDispatch`, so its new `runWhileExecutionIdle` veto can reuse
them. That crossed this part's `store` reads handed to the same sweep: the
sweep now gets this part's `store` plus main's named checks. The veto keeps
main's own reads.
This commit is contained in:
Brennan Benson
2026-10-14 19:28:51 -07:00
374 changed files with 14275 additions and 7394 deletions
@@ -54,7 +54,7 @@ runs:
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
NODE_VERSION: ${{ inputs.node-version }}
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
NATIVE_SOURCE_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.github/actions/prepare-native-runtime/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/initialize-windows-native-toolchain.mjs', 'config/scripts/capture-windows-native-environment.cmd', 'config/scripts/rebuild-native-deps.mjs', 'config/scripts/node-pty-job-ownership.cjs', 'config/scripts/windows-pe-machine.cjs', 'config/scripts/windows-process-tree-gyp-rebuild.mjs', 'config/scripts/windows-process-tree-creation-time.cjs', 'config/scripts/install-electron-package-binary.mjs', 'config/scripts/electron-platform-path.mjs', 'config/scripts/zip-extractor-command.mjs', 'src/shared/zip-extractor-command.ts', 'config/scripts/shared-electron-dist-cache.mjs', 'config/scripts/space-sharing-copy.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch', 'native/windows-registry/src/addon.cc', 'native/windows-registry/binding.gyp', 'native/windows-registry/package.json', 'native/windows-registry/index.js', 'native/windows-security/src/addon.cc', 'native/windows-security/binding.gyp', 'native/windows-security/package.json', 'native/windows-security/index.js') }}
run: |
scope="${ImageOS:-$RUNNER_OS}"
if [ -r /etc/os-release ]; then
@@ -120,4 +120,9 @@ runs:
shell: bash
env:
NATIVE_RUNTIME: ${{ inputs.native-runtime }}
run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
run: |
if [ "$RUNNER_OS" = Windows ] && [ "$NATIVE_RUNTIME" = node ]; then
node config/scripts/initialize-windows-native-toolchain.mjs
else
node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"
fi
@@ -8,6 +8,7 @@ permissions:
jobs:
warm-linux-arm:
if: github.event_name != 'pull_request'
concurrency:
group: ci-cache-warmup-linux-arm-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
@@ -54,6 +55,7 @@ jobs:
run: node config/scripts/ensure-native-runtime.mjs --check-only
- name: Cache Windows CLI launcher
if: runner.arch == 'X64'
uses: actions/cache@v5
with:
path: |
@@ -65,6 +67,7 @@ jobs:
key: windows-cli-launcher-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/windows-cli-launcher/**', 'native/windows-agent-hook/**', 'config/scripts/build-windows-cli-launcher.mjs', 'config/scripts/build-windows-agent-hook.mjs', 'config/scripts/windows-rust-executable-build.mjs', 'resources/build/icon.ico', 'package.json') }}
- name: Build the Windows native executables
if: runner.arch == 'X64'
shell: pwsh
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
@@ -76,7 +79,14 @@ jobs:
node config/scripts/build-windows-cli-launcher.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Warm the Windows ARM server launcher
if: runner.arch == 'ARM64'
uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: arm64
warm-linux-package-fixtures:
if: github.event_name != 'pull_request'
concurrency:
group: ci-cache-warmup-linux-package-fixtures-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name != 'schedule' }}
@@ -10,6 +10,7 @@ on:
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/actions/prepare-native-runtime/**'
- '.github/actions/prepare-windows-cli-launcher/**'
- '.github/actions/prepare-git-compatibility/**'
- '.github/actions/prepare-linux-package-fixture/**'
- 'config/docker/headless-serve-shutdown/**'
@@ -21,6 +22,8 @@ on:
- '.npmrc'
- '.pnpmfile.cjs'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/initialize-windows-native-toolchain.mjs'
- 'config/scripts/capture-windows-native-environment.cmd'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/node-pty-job-ownership.cjs'
- 'config/scripts/windows-pe-machine.cjs'
@@ -42,7 +45,6 @@ on:
- 'resources/build/icon.ico'
pull_request:
paths:
- '.github/workflows/ci-cache-warmup.yml'
- '.github/workflows/ci-cache-warmup-platforms.yml'
- '.github/workflows/ci-cache-warmup-platform-jobs.yml'
+3 -2
View File
@@ -14,6 +14,7 @@ on:
- '.github/actions/install-node-dependencies/**'
- '.github/actions/restore-pnpm-verification/**'
- '.github/actions/prepare-native-runtime/**'
- '.github/actions/prepare-windows-cli-launcher/**'
- '.github/actions/prepare-git-compatibility/**'
- '.github/actions/prepare-headless-compiler/**'
- 'config/scripts/headless-detector-compiler-cache.mjs'
@@ -30,6 +31,8 @@ on:
- '.npmrc'
- '.pnpmfile.cjs'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/initialize-windows-native-toolchain.mjs'
- 'config/scripts/capture-windows-native-environment.cmd'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/node-pty-job-ownership.cjs'
- 'config/scripts/windows-pe-machine.cjs'
@@ -47,8 +50,6 @@ on:
pull_request:
paths:
- '.github/workflows/ci-cache-warmup.yml'
- '.github/workflows/ci-cache-warmup-platform-jobs.yml'
- '.github/workflows/ci-cache-warmup-platforms.yml'
- '.github/actions/prepare-headless-compiler/**'
- 'config/scripts/headless-detector-compiler-cache.mjs'
- '.github/actions/prepare-wayland-clipboard/**'
+27 -18
View File
@@ -62,12 +62,23 @@ on:
description: Comma-separated cell ids from src/main/ssh/ssh-windows-host-cells.ts; empty runs the default set (orcad-cli-* cells run only when named).
required: false
default: ''
preparation_mode:
description: Compare inbox capability preparation overlapping builds or running after them.
type: choice
options: [overlap, serial]
required: false
default: overlap
arm_inbox_only:
description: Run only the native ARM inbox lane for a preparation timing comparison.
type: boolean
required: false
default: false
permissions:
contents: read
concurrency:
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}
group: ssh-windows-hosts-${{ github.event.pull_request.number || github.ref }}${{ github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only && format('-{0}', inputs.preparation_mode || 'overlap') || '' }}
cancel-in-progress: true
jobs:
@@ -77,21 +88,10 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- arch: x64
runner: windows-2022
server: inbox
- arch: x64
runner: windows-2022
server: preview
archive: OpenSSH-Win64.zip
- arch: arm64
runner: windows-11-arm
server: inbox
- arch: arm64
runner: windows-11-arm
server: preview
archive: OpenSSH-ARM64.zip
include: >-
${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.arm_inbox_only &&
'[{"arch":"arm64","runner":"windows-11-arm","server":"inbox"}]' ||
'[{"arch":"x64","runner":"windows-2022","server":"inbox"},{"arch":"x64","runner":"windows-2022","server":"preview","archive":"OpenSSH-Win64.zip"},{"arch":"arm64","runner":"windows-11-arm","server":"inbox"},{"arch":"arm64","runner":"windows-11-arm","server":"preview","archive":"OpenSSH-ARM64.zip"}]') }}
runs-on: ${{ matrix.runner }}
# Installing the inbox capability alone can take several minutes on a fresh image; the CLI matrix
# cells launch the e2e app several times each.
@@ -99,6 +99,7 @@ jobs:
env:
ORCA_BACKGROUND_LAUNCH: '1'
ORCA_ISOLATED_SSH_CI: '1'
INBOX_PREPARATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.preparation_mode == 'serial' && 'serial' || 'overlap' }}
steps:
- uses: actions/checkout@v6
with:
@@ -127,11 +128,11 @@ jobs:
background: true
shell: pwsh
run: |
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){
if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64' -and $env:INBOX_PREPARATION_MODE -eq 'overlap'){
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json')
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
}
- uses: ./.github/actions/install-node-dependencies
id: dependencies
@@ -164,6 +165,14 @@ jobs:
pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}"
node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}"
- wait: inbox-capability
- name: Prepare the Windows inbox SSH capability after the builds
if: ${{ matrix.server == 'inbox' && matrix.arch == 'arm64' && env.INBOX_PREPARATION_MODE == 'serial' }}
shell: pwsh
run: |
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1
Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE
- name: Run the Windows host cells against a private ${{ matrix.server }} sshd
shell: pwsh
timeout-minutes: ${{ contains(github.event.inputs.cells || '', 'orcad-cli') && 130 || 50 }}
@@ -0,0 +1,64 @@
name: Windows native preparation comparison
on:
workflow_dispatch:
inputs:
source_sha:
description: Exact commit containing this benchmark and the Windows compiler candidate.
required: true
type: string
permissions:
contents: read
concurrency:
group: windows-native-preparation-comparison-${{ github.ref }}
cancel-in-progress: false
env:
ORCA_BACKGROUND_LAUNCH: '1'
jobs:
compare:
name: Native preparation comparison (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
matrix:
os: [windows-2022, windows-11-arm]
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.source_sha }}
persist-credentials: false
- name: Record measurement start and validate source
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
[[ "$SOURCE_SHA" =~ ^[0-9a-f]{40}$ ]]
test "$(git rev-parse HEAD)" = "$SOURCE_SHA"
mkdir -p .build/windows-native-benchmark
node -e 'require("node:fs").writeFileSync(".build/windows-native-benchmark/job-start.json", JSON.stringify({startedAt:new Date().toISOString()}))'
- uses: ./.github/actions/install-node-dependencies
with:
node-version: '24.21.0'
native-runtime: none
cache-pnpm-store: 'false'
cache-pnpm-verification: 'false'
- name: Compare isolated cold and same-build warm preparation
shell: bash
env:
SOURCE_SHA: ${{ inputs.source_sha }}
BENCHMARK_RUNNER: ${{ matrix.os }}
run: node config/scripts/benchmark-windows-native-preparation.mjs
- uses: actions/upload-artifact@v7
if: always()
with:
name: windows-native-preparation-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }}
path: .build/windows-native-benchmark/
include-hidden-files: true
if-no-files-found: error
retention-days: 14
@@ -168,7 +168,7 @@ try {
$report.nativeInputs=$verified
Write-Stage 'preview-native-input-verification-complete'
} else {
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage}
Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
$verified=@()
foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){
$path=Join-Path $sshDir $binary
@@ -38,22 +38,52 @@ try {
function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}}
function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}}
function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}}
function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}}
function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}}
function Assert-CapabilityCommand([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction,[string]$Operation){
if(-not $Online -or $Name -ne 'OpenSSH.Server~~~~0.0.1.0' -or $ErrorAction -ne 'Stop' -or [IO.Path]::GetDirectoryName($LogPath) -ne $script:capabilityLogs -or [IO.Path]::GetFileName($LogPath) -notlike "inbox-capability-$Operation-*.log"){throw 'Capability command changed its online identity, error handling or receipt log boundary'}
$script:capabilityLogPaths.Add($LogPath)
[IO.File]::WriteAllText($LogPath,"$Operation fixture")
}
function Get-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
$script:capabilityQueries++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'query'
if($script:failCapabilityQuery){Write-Error 'Injected capability query failure' -ErrorAction $ErrorAction}
return @{State=$script:capabilityState}
}
function Add-WindowsCapability([switch]$Online,[string]$Name,[string]$LogPath,[object]$ErrorAction){
$script:capabilityAdds++;Assert-CapabilityCommand -Online:$Online -Name $Name -LogPath $LogPath -ErrorAction $ErrorAction -Operation 'install'
if($script:activeCapabilityReceipt){
$script:installObservedReceipt=Get-Content -LiteralPath $script:activeCapabilityReceipt -Raw | ConvertFrom-Json
if($script:installObservedReceipt.inboxCapabilityInitialState -ne 'NotPresent' -or $script:installObservedReceipt.inboxCapabilityOperations[0].status -ne 'passed' -or $script:installObservedReceipt.inboxCapabilityOperations[1].status -ne 'running'){throw 'Initial capability state was not durably recorded before installation'}
}
if($script:failCapabilityInstall){Write-Error 'Injected capability install failure' -ErrorAction $ErrorAction}
return @{Path='C:\';Online=$true;RestartNeeded=$script:capabilityRestartNeeded}
}
function Reset-CapabilityControl([string]$State){
$script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0
$script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false
$script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false;$script:failCapabilityQuery=$false
$script:activeCapabilityReceipt=$null;$script:installObservedReceipt=$null;$script:capabilityRestartNeeded=$false
$script:capabilityStages=[Collections.Generic.List[string]]::new()
$script:capabilityLogPaths=[Collections.Generic.List[string]]::new()
}
$script:capabilityLogs=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
try {
foreach($state in @('Installed','NotPresent')){
Reset-CapabilityControl $state
$capabilityReport=@{}
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)}
Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs
$expectedAdds=if($state -eq 'Installed'){0}else{1}
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
$expectedStages=@('inbox-capability-start','inbox-capability-query-start','inbox-capability-query-finished')
if($expectedAdds){$expectedStages+=@('inbox-capability-install-start','inbox-capability-install-finished')}
$expectedStages+=@('inbox-capability-guards-start','inbox-capability-complete')
if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityQueries -ne 1 -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne ($expectedStages -join ',')){throw 'Shared capability preparation did not preserve the install and guard boundaries'}
if($capabilityReport.inboxCapabilityOperations.Count -ne 1+$expectedAdds){throw 'Capability operations missing'}
foreach($operation in $capabilityReport.inboxCapabilityOperations){
if($operation.status -ne 'passed' -or $operation.elapsedMs -isnot [long] -or $operation.elapsedMs -lt 0 -or -not [IO.File]::Exists((Join-Path $script:capabilityLogs $operation.log))){throw 'Capability operation did not retain its timing, verdict or diagnostic log'}
}
if($expectedAdds -and ($capabilityReport.inboxCapabilityInstallResult.online -ne $true -or $capabilityReport.inboxCapabilityInstallResult.path -ne 'C:\' -or $capabilityReport.inboxCapabilityInstallResult.restartNeeded -ne $false)){throw 'Capability install result omitted'}
if(-not $expectedAdds -and $capabilityReport.ContainsKey('inboxCapabilityInstallResult')){throw 'Installed capability invented an install result'}
}
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){
foreach($fault in @('host','global-before','shell-before','global-after','shell-after','query','install')){
Reset-CapabilityControl 'NotPresent'
switch($fault){
'host' {$script:refuseCapabilityHost=$true}
@@ -61,26 +91,45 @@ foreach($fault in @('host','global-before','shell-before','global-after','shell-
'shell-before' {$script:refuseShellCheck=1}
'global-after' {$script:refuseGlobalCheck=2}
'shell-after' {$script:refuseShellCheck=2}
'query' {$script:failCapabilityQuery=$true}
'install' {$script:failCapabilityInstall=$true}
}
$rejected=$false
try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true}
$faultReport=@{}
try {Install-WindowsInboxSshCapability 'x64' $faultReport {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs} catch {$rejected=$true}
if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"}
if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'}
if($fault -eq 'query' -and $script:capabilityAdds){throw 'Failed query proceeded to installation'}
if($fault -in @('query','install') -and ($faultReport.inboxCapabilityOperations[-1].status -ne 'failed' -or $faultReport.inboxCapabilityOperations[-1].elapsedMs -isnot [long])){throw 'Failed servicing operation lost its verdict or duration'}
}
Reset-CapabilityControl 'NotPresent'
foreach($invocation in 1..2){Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)} $script:capabilityLogs}
if($script:capabilityLogPaths.Count -ne 4 -or ($script:capabilityLogPaths | Select-Object -Unique).Count -ne 4){throw 'Separate servicing operations or invocations reused a diagnostic log'}
} finally {Remove-Item -LiteralPath $script:capabilityLogs -Recurse -Force -ErrorAction SilentlyContinue}
$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N'))
try {
New-Item -ItemType Directory -Path $capabilityRoot | Out-Null
$capabilityReceipt=Join-Path $capabilityRoot 'capability.json'
$script:capabilityLogs=$capabilityRoot
Reset-CapabilityControl 'Installed'
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt
$completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'}
Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true
if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed' -or $completed.preparationMode -ne 'overlap'){throw 'Capability success receipt missing its observed initial state or default mode'}
Reset-CapabilityControl 'NotPresent';$script:capabilityLogs=$capabilityRoot;$script:activeCapabilityReceipt=$capabilityReceipt;$script:capabilityRestartNeeded=$true
Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt 'serial'
$installed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if($installed.status -ne 'passed' -or $installed.preparationMode -ne 'serial' -or $installed.inboxCapabilityInstallResult.restartNeeded -ne $true -or -not $script:installObservedReceipt){throw 'Serial preparation lost its mode, restart result or pre-install receipt'}
Reset-CapabilityControl 'NotPresent';$script:activeCapabilityReceipt=$capabilityReceipt;$script:failCapabilityInstall=$true
$rejected=$false
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
$failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'}
if($failed.inboxCapabilityOperations[-1].status -ne 'failed' -or -not $failed.stages.stage.Contains('inbox-capability-install-finished')){throw 'Failed preparation omitted its servicing result'}
Reset-CapabilityControl 'NotPresent';$script:failCapabilityQuery=$true
$rejected=$false
try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true}
$failedQuery=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json
if(-not $rejected -or $failedQuery.status -ne 'failed' -or $failedQuery.inboxCapabilityOperations[0].status -ne 'failed' -or $failedQuery.inboxCapabilityOperations.Count -ne 1 -or $script:capabilityAdds -or $failedQuery.inboxCapabilityInitialState){throw 'Failed query receipt invented a capability state or proceeded to installation'}
} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue}
& {
param($ProofAst)
@@ -33,26 +33,48 @@ function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[strin
return $written
}
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) {
function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage,[Parameter(Mandatory=$true)][string]$LogDirectory) {
Assert-IsolatedWindowsSshCi $Arch
Assert-WindowsSshGlobalServerDormant 'inbox'
Assert-WindowsSshStockShell
& $Stage 'inbox-capability-start'
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0'
$Report.inboxCapabilityInitialState=[string]$capability.State
if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null}
New-Item -ItemType Directory -Force -Path $LogDirectory | Out-Null
$invocation=[Guid]::NewGuid().ToString('N')
$query=@{operation='query';status='running';log="inbox-capability-query-$invocation.log"}
$Report.inboxCapabilityOperations=@($query)
& $Stage 'inbox-capability-query-start'
$watch=[Diagnostics.Stopwatch]::StartNew()
try {
$capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $query.log) -ErrorAction Stop
$Report.inboxCapabilityInitialState=[string]$capability.State
$query.status='passed'
} catch {$query.status='failed';$query.error=$_.Exception.Message;throw}
finally {$watch.Stop();$query.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-query-finished'}
if($capability.State -ne 'Installed'){
$install=@{operation='install';status='running';log="inbox-capability-install-$invocation.log"}
$Report.inboxCapabilityOperations+=@($install)
& $Stage 'inbox-capability-install-start'
$watch=[Diagnostics.Stopwatch]::StartNew()
try {
$result=Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' -LogPath (Join-Path $LogDirectory $install.log) -ErrorAction Stop
$Report.inboxCapabilityInstallResult=@{path=$result.Path;online=$result.Online;restartNeeded=$result.RestartNeeded}
$install.status='passed'
} catch {$install.status='failed';$install.error=$_.Exception.Message;throw}
finally {$watch.Stop();$install.elapsedMs=$watch.ElapsedMilliseconds;& $Stage 'inbox-capability-install-finished'}
}
& $Stage 'inbox-capability-guards-start'
Assert-WindowsSshGlobalServerDormant 'inbox'
Assert-WindowsSshStockShell
& $Stage 'inbox-capability-complete'
}
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) {
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt,[ValidateSet('overlap','serial')][string]$PreparationMode='overlap') {
$report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;preparationMode=$PreparationMode;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'}
try {
if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'}
Install-WindowsInboxSshCapability $Arch $report {param($name)
if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'}
}
} ([IO.Path]::GetDirectoryName([IO.Path]::GetFullPath($Receipt)))
$report.status='passed'
} catch {$report.status='failed';$report.error=$_.Exception.Message;throw}
finally {
+3 -3
View File
@@ -54,12 +54,12 @@
3 src/main/runtime/mobile-session-layout-projection.ts
2 src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
2 src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
2 src/main/runtime/orca-runtime-attach-window.ts
2 src/main/runtime/session-graph/session-graph-promotion.ts
2 src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
1 src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
1 src/main/runtime/session-tabs/structured-session-tab-close.ts
1 src/main/runtime/orca-runtime-get-runtime-id.ts
2 src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
1 src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
1 src/main/runtime/session-tabs/terminal-surface-retirements.ts
1 src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
3 src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
1 src/main/runtime/orca-runtime-terminal-drivers.ts
@@ -0,0 +1,518 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { performance } from 'node:perf_hooks'
import { pathToFileURL } from 'node:url'
import { runProcess } from '@orca/process-host'
import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs'
import { summarizeBenchmarkSamples } from './benchmark-sample-summary.mjs'
import { resolvePnpmCliInvocation } from './pnpm-cli-invocation.mjs'
import { hasWindowsCompilerSelection } from './initialize-windows-native-toolchain.mjs'
import {
compilerEnvironmentKey,
environmentReceipt,
hash
} from './windows-native-benchmark-observer.mjs'
const addonNames = [
'node-pty',
'@orca/windows-registry',
'@orca/windows-security',
'@vscode/windows-process-tree'
]
const candidate = 'initialize-windows-native-toolchain.mjs'
const control = 'ensure-native-runtime.mjs'
const unchangedFiles = [
`.github/actions/prepare-native-runtime/action.yml`,
`config/scripts/${control}`,
`config/scripts/${candidate}`,
'config/scripts/capture-windows-native-environment.cmd',
'config/scripts/windows-native-benchmark-observer.mjs',
'config/scripts/benchmark-windows-native-preparation.mjs'
]
export function validateObservedPreparation(receipt, variant, warm = false) {
assert.equal(
receipt.parentBefore.hash,
receipt.parentAfter.hash,
'Outer compiler environment changed'
)
const calls = (phase) => receipt.calls.filter((call) => call.phase === phase)
const captured = variant !== 'control' && !warm
assert.equal(calls('discovery').length, captured ? 1 : 0, 'Unexpected compiler discovery count')
assert.equal(calls('capture').length, captured ? 1 : 0, 'Unexpected developer command count')
assert.equal(
calls('probe').length,
variant === 'control' ? 0 : 1,
'Missing helper preflight observation'
)
for (const call of receipt.calls) {
assert(
!call.error && !call.timedOut && !call.outputTruncated,
'Incomplete compiler observation'
)
}
if (variant === 'control') {
assert.equal(calls('addon-build').length, warm ? 0 : 4, 'Expected all four cold addon builds')
assert(
receipt.calls.every((call) => call.code === 0),
'Control preparation failed'
)
return
}
const [prepare] = calls('preparation')
assert.equal(calls('preparation').length, 1, 'Missing unchanged preparation child')
assert.equal(prepare.code, 0, 'Native preparation failed')
assert.equal(calls('probe')[0].code, warm ? 0 : 1, 'Unexpected helper cold/warm probe')
if (warm || variant === 'fallback') {
assert.equal(
prepare.environment.hash,
receipt.parentBefore.hash,
'Fallback/warm changed compiler selection'
)
assert.deepEqual(prepare.changedNames, [], 'Fallback/warm altered child environment')
} else {
assert.equal(calls('discovery')[0].code, 0)
assert.equal(calls('capture')[0].code, 0)
assert(prepare.changedNames.length > 0, 'Captured environment was not applied')
assert(
prepare.changedNames.every((name) => compilerEnvironmentKey.test(name)),
'Unrelated environment changed'
)
assert(prepare.environment.compiler.vcinstalldir, 'Prepared child lacks compiler selection')
}
if (variant === 'fallback' && !warm) {
assert.equal(calls('discovery')[0].code, 0)
assert.equal(calls('capture')[0].code, 7, 'Fixed failure fixture did not run')
}
}
export function assertNativeColdProbe(result) {
assert.equal(result.code, 1, 'Cold fixture unexpectedly passed')
for (const name of addonNames) {
assert(result.stderr.includes(`${name}:`), `Cold fixture did not fail ${name}`)
}
}
export function assertNoHeaderDownloads(result) {
assert(
!/gyp http (GET|200|download)/i.test(`${result.stdout}\n${result.stderr}`),
'Timed preparation downloaded headers/libraries'
)
}
function digestFiles(root, paths) {
return Object.fromEntries(
paths
.filter((path) => existsSync(join(root, path)))
.map((path) => [path, hash(readFileSync(join(root, path)))])
)
}
function treeDigest(root) {
const digest = createHash('sha256')
const files = []
const visit = (path) => {
for (const name of readdirSync(path).sort()) {
const child = join(path, name)
if (statSync(child).isDirectory()) {
visit(child)
} else {
files.push(child)
}
}
}
visit(root)
for (const file of files) {
digest.update(relative(root, file)).update('\0').update(readFileSync(file))
}
return { sha256: digest.digest('hex'), files: files.length }
}
function nativeDirectories(snapshot) {
const require = createRequire(join(snapshot, 'package.json'))
const root = realpathSync(snapshot)
return addonNames.map((name) => {
const directory = realpathSync(dirname(require.resolve(`${name}/package.json`)))
assert(directory.startsWith(`${root}${sep}`), `Addon escapes its isolated copy: ${name}`)
return { name, directory }
})
}
function binaryReceipt(snapshot) {
const require = createRequire(join(snapshot, 'package.json'))
const paths = [
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty.node'),
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/conpty.dll'),
join(dirname(require.resolve('node-pty/package.json')), 'build/Release/conpty/OpenConsole.exe'),
join(snapshot, 'native/windows-registry/build/Release/orca_windows_registry.node'),
join(snapshot, 'native/windows-security/build/Release/orca_windows_security.node'),
join(
dirname(require.resolve('@vscode/windows-process-tree/package.json')),
'build/Release/windows_process_tree.node'
)
]
return paths.map((path) => {
assert(existsSync(path), `Missing prepared artifact: ${path}`)
return {
path: relative(snapshot, path),
bytes: statSync(path).size,
sha256: hash(readFileSync(path))
}
})
}
export async function benchmarkWindowsNativePreparation(
root = resolve(import.meta.dirname, '../..')
) {
assert.equal(process.platform, 'win32', 'This hosted comparison requires Windows')
assert(['x64', 'arm64'].includes(process.arch), 'Unsupported Windows runner')
assert.equal(process.versions.node, '24.21.0', 'Comparison requires pinned Node')
assert(
!hasWindowsCompilerSelection(process.env),
'Explicit compiler selection prevents a qualified automatic-capture comparison'
)
assert(/^[0-9a-f]{40}$/.test(process.env.SOURCE_SHA ?? ''), 'SOURCE_SHA must be an exact commit')
const output = join(root, '.build/windows-native-benchmark')
mkdirSync(output, { recursive: true })
const temporary = mkdtempSync(
join(process.env.RUNNER_TEMP || tmpdir(), 'orca-native-comparison-')
)
const archive = join(temporary, 'source.tar')
const headers = join(temporary, 'headers')
const pnpm = resolvePnpmCliInvocation()
const environment = {
...process.env,
ORCA_BACKGROUND_LAUNCH: '1',
npm_config_devdir: headers,
npm_package_config_node_gyp_devdir: headers
}
const parentBefore = environmentReceipt(process.env)
const githubEnvironmentBefore = existsSync(process.env.GITHUB_ENV ?? '')
? hash(readFileSync(process.env.GITHUB_ENV))
: null
const report = {
schemaVersion: 1,
startedAt: new Date().toISOString(),
qualification: 'incomplete',
firstRecordedJobStep: JSON.parse(readFileSync(join(output, 'job-start.json'), 'utf8')),
phases: [],
samples: [],
scope: {
warm: 'Same-build warm preparation; no Actions native-cache restore/save is performed.',
metric:
'Cold addon preparation with identical diagnostic preload; installs, header downloads, standalone qualification probes and artifact upload are recorded separately; the helper preflight remains included.',
occupancy:
'Use complete hosted job started_at/completed_at, including setup and upload/post steps, for actual runner occupancy. This artifact only measures its explicit window.',
excluded:
'No Node-to-Electron, cross-target template, exact-key restore, or CI workload gain is claimed.'
}
}
const writeReport = () =>
writeFileSync(join(output, 'receipt.json'), `${JSON.stringify(report, null, 2)}\n`)
const command = async (label, program, args, cwd = root, env = environment, accepted = [0]) => {
const startedAt = new Date().toISOString()
const start = performance.now()
const result = await runProcess({
program,
args,
cwd,
env,
timeoutMs: 1_200_000,
maxOutputBytes: 64 * 1024 * 1024,
terminationBarrier: true
})
const elapsedMs = performance.now() - start
const stdoutFile = `${label}.stdout.log`
const stderrFile = `${label}.stderr.log`
writeFileSync(join(output, stdoutFile), result.stdout)
writeFileSync(join(output, stderrFile), result.stderr)
report.phases.push({
label,
program,
args,
startedAt,
finishedAt: new Date().toISOString(),
durationMs: elapsedMs,
code: result.code,
signal: result.signal,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated,
stdoutFile,
stderrFile,
stdoutSha256: hash(result.stdout),
stderrSha256: hash(result.stderr)
})
writeReport()
assert(
!result.timedOut &&
!result.signal &&
!result.outputTruncated &&
accepted.includes(result.code),
`${label} failed: ${result.stderr.slice(-4000)}`
)
return { ...result, durationMs: elapsedMs }
}
const pnpmCommand = (label, args, cwd) =>
command(label, pnpm.command, [...pnpm.prefixArgs, ...args], cwd)
try {
const head = (await command('source-head', 'git', ['rev-parse', 'HEAD'])).stdout.trim()
assert.equal(head, process.env.SOURCE_SHA, 'Checkout differs from frozen source')
await command('source-clean', 'git', ['diff', '--exit-code', 'HEAD', '--'])
const tree = (await command('source-tree', 'git', ['rev-parse', 'HEAD^{tree}'])).stdout.trim()
const pnpmVersion = (await pnpmCommand('pnpm-version', ['--version'], root)).stdout.trim()
assert.equal(pnpmVersion, '12.8.1', 'Comparison requires pinned pnpm')
const action = readFileSync(
join(root, '.github/actions/prepare-native-runtime/action.yml'),
'utf8'
)
const hashExpression = action.match(/NATIVE_SOURCE_HASH: (.+)/)?.[1]
assert(hashExpression, 'Missing production source identity')
const inputs = [...hashExpression.matchAll(/'([^']+)'/g)].map((match) => match[1])
assert(
inputs.includes(`config/scripts/${candidate}`),
'Frozen source lacks the candidate native-cache fingerprint'
)
const requiredScriptHashes = digestFiles(root, unchangedFiles)
assert.equal(
Object.keys(requiredScriptHashes).length,
unchangedFiles.length,
'Frozen source lacks required scripts'
)
const sourceInputs = digestFiles(root, inputs)
report.identity = {
sourceSha: head,
tree,
runtime: 'node',
node: process.versions.node,
nodeAbi: process.versions.modules,
arch: process.arch,
pnpm: pnpmVersion,
runner: process.env.BENCHMARK_RUNNER,
imageOS: process.env.ImageOS,
imageVersion: process.env.ImageVersion,
workflowRef: process.env.GITHUB_WORKFLOW_REF,
workflowSha: process.env.GITHUB_WORKFLOW_SHA,
runId: process.env.GITHUB_RUN_ID,
runAttempt: process.env.GITHUB_RUN_ATTEMPT,
sourceInputs,
requiredScriptHashes,
sourceInputDigest: hash(JSON.stringify(sourceInputs)),
productionHashExpression: hashExpression,
parentCompilerEnvironment: parentBefore.compiler
}
await command('archive-source', 'git', ['archive', '--format=tar', '--output', archive, 'HEAD'])
report.identity.archiveSha256 = hash(readFileSync(archive))
const require = createRequire(join(root, 'package.json'))
const nodeGyp = process.env.npm_config_node_gyp || require.resolve('node-gyp/bin/node-gyp.js')
report.identity.nodeGyp = JSON.parse(
readFileSync(resolve(dirname(nodeGyp), '..', 'package.json'), 'utf8')
).version
report.identity.nodeGypEntrySha256 = hash(readFileSync(nodeGyp))
await command('download-headers-before-measurement', process.execPath, [
nodeGyp,
'install',
`--target=${process.versions.node}`,
`--arch=${process.arch}`,
`--devdir=${headers}`
])
const headersBefore = treeDigest(headers)
report.identity.headers = headersBefore
const physicalDirectories = new Set()
const schedule = buildCounterbalancedSchedule(2, 'control', 'candidate').flat()
report.schedule = schedule
for (const [index, variant] of [...schedule, 'fallback'].entries()) {
const label = `sample-${String(index + 1).padStart(2, '0')}`
const snapshot = join(temporary, label)
mkdirSync(snapshot)
await command(`${label}-extract`, 'tar', ['-xf', archive, '-C', snapshot])
await pnpmCommand(
`${label}-install`,
[
'install',
'--offline',
'--frozen-lockfile',
'--ignore-scripts',
'--package-import-method=copy',
'--side-effects-cache=false'
],
snapshot
)
await pnpmCommand(`${label}-internal-packages`, ['run', 'build:packages'], snapshot)
const directories = nativeDirectories(snapshot)
for (const { directory } of directories) {
assert(
!physicalDirectories.has(directory.toLowerCase()),
'Native copies share a build directory'
)
physicalDirectories.add(directory.toLowerCase())
rmSync(join(directory, 'build'), { recursive: true, force: true })
}
const initial = await command(
`${label}-cold-probe`,
process.execPath,
[join(snapshot, 'config/scripts', control), '--check-only'],
snapshot,
environment,
[1]
)
assertNativeColdProbe(initial)
const snapshotScriptHashes = digestFiles(snapshot, unchangedFiles)
assert.deepEqual(
snapshotScriptHashes,
requiredScriptHashes,
'Ordinary snapshot source/config drift'
)
const originalCapture = readFileSync(
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd')
)
if (variant === 'fallback') {
writeFileSync(
join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'),
'@echo off\r\nexit /b 7\r\n'
)
}
const sample = {
label,
variant,
sourceHashesBeforeInjection: snapshotScriptHashes,
originalCaptureSha256: hash(originalCapture),
addonDirectories: directories,
measurements: []
}
report.samples.push(sample)
for (const warm of [false, true]) {
const stage = `${label}-${warm ? 'warm' : 'cold'}`
const traceFile = join(output, `${stage}.trace.json`)
const script = join(snapshot, 'config/scripts', variant === 'control' ? control : candidate)
const args = [
'--import',
pathToFileURL(join(snapshot, 'config/scripts/windows-native-benchmark-observer.mjs'))
.href,
script,
...(variant === 'control' ? ['--runtime=node'] : [])
]
const result = await command(stage, process.execPath, args, snapshot, {
...environment,
ORCA_NATIVE_BENCHMARK_TRACE: traceFile
})
assertNoHeaderDownloads(result)
const receipt = JSON.parse(readFileSync(traceFile, 'utf8'))
validateObservedPreparation(receipt, variant, warm)
if (variant === 'fallback' && !warm) {
assert(result.stderr.includes('retaining node-gyp discovery'), 'Fallback warning missing')
}
const final = await command(
`${stage}-final-probe`,
process.execPath,
[join(snapshot, 'config/scripts', control), '--check-only'],
snapshot
)
assert.equal(final.code, 0)
const calls = receipt.calls
const milliseconds = (phase) =>
calls
.filter((call) => call.phase === phase)
.reduce((total, call) => total + call.durationMs, 0)
sample.measurements.push({
warm,
totalPreparationMs: result.durationMs,
helperDiscoveryMs: milliseconds('discovery'),
helperCaptureMs: milliseconds('capture'),
helperPreprobeMs: milliseconds('probe'),
unchangedPreparationChildMs: variant === 'control' ? null : milliseconds('preparation'),
helperOutsidePreparationChildMs:
variant === 'control' ? null : result.durationMs - milliseconds('preparation'),
traceFile: relative(output, traceFile),
traceSha256: hash(readFileSync(traceFile)),
binaries: binaryReceipt(snapshot)
})
assert.deepEqual(
treeDigest(headers),
headersBefore,
'Timed preparation changed prefilled headers/libraries'
)
assert.equal(
environmentReceipt(process.env).hash,
parentBefore.hash,
'Parent environment changed'
)
assert.equal(
existsSync(process.env.GITHUB_ENV ?? '')
? hash(readFileSync(process.env.GITHUB_ENV))
: null,
githubEnvironmentBefore,
'Compiler setup wrote GITHUB_ENV'
)
writeReport()
}
sample.isolatedCopyWindowMs = report.phases
.filter((phase) => phase.label.startsWith(`${label}-`))
.reduce((total, phase) => total + phase.durationMs, 0)
sample.injectedCaptureSha256 =
variant === 'fallback'
? hash(
readFileSync(join(snapshot, 'config/scripts/capture-windows-native-environment.cmd'))
)
: null
}
report.comparison = Object.fromEntries(
[false, true].map((warm) => {
const timings = (variant) =>
report.samples
.filter((sample) => sample.variant === variant)
.map(
(sample) =>
sample.measurements.find((measurement) => measurement.warm === warm)
.totalPreparationMs
)
const baseline = timings('control')
const proposed = timings('candidate')
return [
warm ? 'sameBuildWarm' : 'cold',
{
control: summarizeBenchmarkSamples(baseline),
candidate: summarizeBenchmarkSamples(proposed),
pairDeltaMs: baseline.map((value, index) => proposed[index] - value),
role: 'Two counterbalanced pairs are descriptive; no significance or ordinary CI hit-rate estimate.'
}
]
})
)
report.parentEnvironment = {
before: parentBefore,
after: environmentReceipt(process.env),
githubEnvironmentBefore,
githubEnvironmentAfter: githubEnvironmentBefore
}
report.qualification = 'passed'
} catch (error) {
report.failure = error.message
report.qualification = 'failed'
throw error
} finally {
rmSync(temporary, { recursive: true, force: true })
report.finishedAt = new Date().toISOString()
report.explicitWindowMs = Date.parse(report.finishedAt) - Date.parse(report.startedAt)
report.explicitJobWindowMs =
Date.parse(report.finishedAt) - Date.parse(report.firstRecordedJobStep.startedAt)
writeReport()
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await benchmarkWindowsNativePreparation()
}
@@ -11,7 +11,8 @@ import {
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { runProcessSync } from '@orca/process-host'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
shouldReuseCompiledWindowsRustExecutable,
windowsRustExecutableFileVersion,
@@ -45,6 +46,43 @@ function itWindows(name, test) {
runner(name, { timeout: 300_000 }, test)
}
let sharedLauncherRoot = null
let sharedLauncherPath = null
let sharedLauncherBuild = null
// Compile once; each launch case gets a private copy so it can own its fixture tree.
beforeAll(() => {
if (process.platform !== 'win32') {
return
}
sharedLauncherRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher build '))
sharedLauncherPath = join(sharedLauncherRoot, 'orca.exe')
const result = runProcessSync({
program: process.execPath,
args: ['config/scripts/build-windows-cli-launcher.mjs', '--output', sharedLauncherPath],
cwd: projectRoot,
timeoutMs: 300_000
})
sharedLauncherBuild = { status: result.code, stdout: result.stdout, stderr: result.stderr }
if (result.code !== 0) {
throw new Error(`${result.stdout}\n${result.stderr}`)
}
}, 300_000)
afterAll(() => {
if (sharedLauncherRoot) {
removeFixtureTree(sharedLauncherRoot)
}
})
function copySharedLauncher(target) {
if (!sharedLauncherPath) {
throw new Error('shared Windows CLI launcher was not built')
}
mkdirSync(dirname(target), { recursive: true })
copyFileSync(sharedLauncherPath, target)
}
describe('Windows CLI launcher', () => {
it('reuses restored builds only while all embedded inputs and the release version match', () => {
const root = mkdtempSync(join(tmpdir(), 'orca-cli-launcher-reuse-'))
@@ -107,12 +145,12 @@ describe('Windows CLI launcher', () => {
const root = mkdtempSync(join(tmpdir(), 'orca launcher metadata '))
try {
const launcherPath = join(root, 'orca.exe')
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
expect(sharedLauncherBuild).not.toBeNull()
expect(
sharedLauncherBuild.status,
`${sharedLauncherBuild.stdout}\n${sharedLauncherBuild.stderr}`
).toBe(0)
copySharedLauncher(launcherPath)
const inspect = spawnSync(
'powershell.exe',
[
@@ -183,12 +221,7 @@ describe('Windows CLI launcher', () => {
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
copySharedLauncher(launcherPath)
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
const powershell = spawnSync(
@@ -247,12 +280,7 @@ describe('Windows CLI launcher', () => {
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
copySharedLauncher(launcherPath)
writeFileSync(
join(dirname(launcherPath), 'orca-launcher.cfg'),
`node=${process.execPath}\ncli=${cliPath}\nuser-data=${profile}\n`
@@ -326,12 +354,7 @@ describe('Windows CLI launcher', () => {
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
copySharedLauncher(launcherPath)
const compiler = findFrameworkCompiler()
expect(compiler).not.toBeNull()
@@ -0,0 +1,6 @@
@echo off
chcp 65001 >nul
call "%ORCA_WINDOWS_VSDEVCMD%" -no_logo -arch=%ORCA_WINDOWS_NATIVE_ARCH% -host_arch=%ORCA_WINDOWS_NATIVE_ARCH%
if errorlevel 1 exit /b 1
echo ORCA_WINDOWS_NATIVE_ENVIRONMENT
set
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
const read = (path) => parse(readFileSync(path, 'utf8'))
const workflow = (name) => read(`.github/workflows/${name}.yml`)
@@ -300,6 +301,116 @@ describe('CI dependency download caches', () => {
}
})
it('keeps PR warmer validation within the workflow family that runs it', () => {
const shared = workflow('ci-cache-warmup')
const platforms = workflow('ci-cache-warmup-platforms')
expect(shared.on.pull_request.paths).toEqual([
'.github/workflows/ci-cache-warmup.yml',
'.github/actions/prepare-headless-compiler/**',
'config/scripts/headless-detector-compiler-cache.mjs',
'.github/actions/prepare-wayland-clipboard/**'
])
expect(platforms.on.pull_request.paths).toEqual([
'.github/workflows/ci-cache-warmup-platforms.yml',
'.github/workflows/ci-cache-warmup-platform-jobs.yml'
])
expect(shared.jobs['warm-platforms'].uses).toBe(platforms.jobs['warm-platforms'].uses)
expect(shared.on.schedule).toEqual([{ cron: '41 */6 * * *' }])
expect(shared.on).toHaveProperty('workflow_dispatch')
for (const producer of [shared, platforms]) {
expect(producer.on.push.branches).toEqual(['main'])
expect(producer.on.push.paths).toEqual(
expect.arrayContaining([
'.github/workflows/ci-cache-warmup.yml',
'.github/workflows/ci-cache-warmup-platforms.yml',
'.github/workflows/ci-cache-warmup-platform-jobs.yml',
'.github/actions/install-node-dependencies/**',
'.github/actions/prepare-native-runtime/**',
'pnpm-lock.yaml'
])
)
}
})
it.each([
['pull_request', false, false],
['push', true, false],
['schedule', true, true],
['workflow_dispatch', true, true]
])('retains cache producers and Windows validation on %s', (event, armRuns, sharedPlatforms) => {
const shared = workflow('ci-cache-warmup')
const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses)
const context = { github: { event_name: event } }
expect(runInNewContext(platforms.jobs['warm-linux-arm'].if, context)).toBe(armRuns)
expect(runInNewContext(shared.jobs['warm-platforms'].if, context)).toBe(sharedPlatforms)
expect(shared.jobs.warm.if).toBeUndefined()
expect(platforms.jobs['warm-windows'].if).toBeUndefined()
expect(platforms.jobs['warm-windows'].strategy.matrix.os).toEqual([
'windows-2022',
'windows-11-arm'
])
})
it('validates the omitted ARM warmer preparation in the real PR consumers', () => {
const platforms = read(workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses)
const warm = platforms.jobs['warm-linux-arm']
const pr = workflow('pr')
const units = workflow('unit-tests').jobs.test
const installer = './.github/actions/install-node-dependencies'
const warmInstall = warm.steps.find((step) => step.uses === installer)
const unitInstall = units.steps.find((step) => step.uses === installer)
expect(warm['runs-on']).toBe('ubuntu-24.04-arm')
expect(pr.jobs.preflight['runs-on']).toBe(warm['runs-on'])
expect(pr.jobs.test.with.runner).toBe(warm['runs-on'])
expect(units['runs-on']).toBe('${{ inputs.runner }}')
expect(JSON.parse(pr.jobs.test.with.node_versions)).toEqual(['24'])
expect(warmInstall.if).toBeUndefined()
expect(unitInstall.if).toBeUndefined()
expect(warmInstall.with['native-runtime']).toBe('node')
expect(unitInstall.with['native-runtime']).toBe(warmInstall.with['native-runtime'])
expect(warmInstall.with['node-version']).toBe('24')
expect(unitInstall.with['node-version']).toBe('${{ matrix.node }}')
expect(unitInstall.with['cache-electron-package']).toBe(
warmInstall.with['cache-electron-package']
)
const preflightInstalls = pr.jobs.preflight.steps.filter((step) => step.uses === installer)
expect(preflightInstalls.map((step) => step.if)).toEqual([
"needs.code_paths.outputs.mobile_dependencies != 'true'",
"needs.code_paths.outputs.mobile_dependencies == 'true'"
])
for (const step of preflightInstalls) {
expect(step.with['native-runtime']).toBe('node')
expect(step.with['node-version']).toBe('24')
}
const populate = 'node config/scripts/install-electron-package-binary.mjs'
const unitPopulate = units.steps.find((step) => step.run === populate)
expect(unitPopulate.if).toBeUndefined()
expect(warm.steps.map((step) => step.uses ?? step.run)).toEqual([
'actions/checkout@v6',
installer,
populate,
'node config/scripts/ensure-native-runtime.mjs --check-only'
])
const native = read('.github/actions/prepare-native-runtime/action.yml')
const identity = native.runs.steps.find((step) => step.id === 'native-cache-scope')
const inputs = [...identity.env.NATIVE_SOURCE_HASH.matchAll(/'([^']+)'/g)].map(
(match) => match[1]
)
for (const path of [
...inputs,
'.github/actions/install-node-dependencies/action.yml',
...workflow('ci-cache-warmup-platforms').on.pull_request.paths,
'.github/workflows/ci-cache-warmup.yml'
]) {
expect(classifyPrJobs([path], { allowMobileCiTools: true }), path).toMatchObject({
static_analysis: true,
typecheck: true,
test: true,
mobile_ci_tools: false
})
}
})
it('restores Windows packaging downloads from the release cache without a PR upload', () => {
const packaging = workflow('pr').jobs.package_windows
const restore = packaging.steps.find((step) => step.name === 'Cache electron-builder downloads')
+2 -3
View File
@@ -4456,7 +4456,6 @@
"src/main/runtime/mobile-subscribe-idle-pane-hydrate.test.ts": 5902,
"src/main/runtime/mobile-subscribe-integration.test.ts": 22684,
"src/main/runtime/multi-client-navigation-isolation.integration.test.ts": 5282,
"src/main/runtime/native-chat-visuals-workspace-verdict.test.ts": 326,
"src/main/runtime/omp-readiness-transcripts.test.ts": 7797,
"src/main/runtime/opencode-agent-row-enter-transcript.test.ts": 562,
"src/main/runtime/opencode-agent-row-structure-transcript.test.ts": 347,
@@ -8422,7 +8421,7 @@
"src/renderer/src/components/sidebar/workspace-attachment-source-label.test.ts": 76,
"src/renderer/src/components/sidebar/workspace-attachment-source-result.test.ts": 1483,
"src/renderer/src/components/sidebar/workspace-board-task-status-sync.test.ts": 243,
"src/renderer/src/components/sidebar/workspace-creator-visibility.test.ts": 69,
"src/shared/workspace-creator-visibility.test.ts": 69,
"src/renderer/src/components/sidebar/workspace-delete-lineage.test.ts": 46,
"src/renderer/src/components/sidebar/workspace-hover-attachments.test.ts": 106,
"src/renderer/src/components/sidebar/workspace-kanban-area-selection.test.ts": 186,
@@ -10075,7 +10074,7 @@
"src/renderer/src/lib/renderer-agent-status-observation-ingress.test.ts": 137,
"src/renderer/src/lib/renderer-app-platform.test.ts": 177,
"src/renderer/src/lib/renderer-memory-profile.test.ts": 64,
"src/renderer/src/lib/repo-display-labels.test.ts": 50,
"src/shared/repo-display-labels.test.ts": 50,
"src/renderer/src/lib/repo-runtime-owner.test.ts": 61,
"src/renderer/src/lib/repo-search.test.ts": 43,
"src/renderer/src/lib/repo-slug-cache.test.ts": 37,
@@ -477,7 +477,12 @@ describe('packaged runtime resources', () => {
{ recursive: true }
)
await seedBundledRipgrep(resourcesDir)
await seedClipboardPublisher(resourcesDir, 0x3e)
// Layout fixtures use a cross-arch slice; native PTY probes need real packaged binaries.
const target =
process.arch === 'x64'
? { electronArch: 3, machine: 0xb7 }
: { electronArch: 1, machine: 0x3e }
await seedClipboardPublisher(resourcesDir, target.machine)
await stubObjdump(root)
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
@@ -512,7 +517,7 @@ describe('packaged runtime resources', () => {
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux',
arch: 1,
arch: target.electronArch,
packager: { appInfo: { version: '9.9.9' } }
})
@@ -0,0 +1,159 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { runProcessSync } from '@orca/process-host'
const ENVIRONMENT_MARKER = 'ORCA_WINDOWS_NATIVE_ENVIRONMENT'
const compilerEnvironmentKey =
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
export function parseWindowsCompilerEnvironment(output, arch) {
const lines = output.split(/\r?\n/)
const marker = lines.indexOf(ENVIRONMENT_MARKER)
if (marker === -1) {
throw new Error('Developer environment receipt is missing')
}
const environment = {}
for (const line of lines.slice(marker + 1)) {
const separator = line.indexOf('=')
if (separator <= 0) {
continue
}
const name = line.slice(0, separator)
if (compilerEnvironmentKey.test(name)) {
environment[name] = line.slice(separator + 1)
}
}
const value = (name) => {
const key = Object.keys(environment).find((key) => key.toLowerCase() === name.toLowerCase())
return key ? environment[key] : undefined
}
if (!value('VCINSTALLDIR') || !value('VSCMD_VER') || !value('WindowsSDKVersion')) {
throw new Error('Developer environment lacks the compiler or Windows SDK')
}
if (
!/^\d+\.\d+\./.test(value('VSCMD_VER')) ||
!/^\d+\.\d+\.\d+\.\d+\\?$/.test(value('WindowsSDKVersion'))
) {
throw new Error('Developer environment has an invalid compiler or Windows SDK version')
}
const target = value('VSCMD_ARG_TGT_ARCH')?.toLowerCase()
if (!(target === arch || (arch === 'x64' && target === 'amd64'))) {
throw new Error('Developer environment targets a different architecture')
}
return environment
}
export function hasWindowsCompilerSelection(environment) {
return Object.entries(environment).some(
([name, value]) =>
/^(VCINSTALLDIR|GYP_MSVS_VERSION|npm_config_msvs_version|npm_package_config_node_gyp_msvs_version)$/i.test(
name
) && value
)
}
export function mergeWindowsCompilerEnvironment(environment, compilerEnvironment) {
const compilerKeys = new Set(Object.keys(compilerEnvironment).map((key) => key.toLowerCase()))
return {
...Object.fromEntries(
Object.entries(environment).filter(([key]) => !compilerKeys.has(key.toLowerCase()))
),
...compilerEnvironment
}
}
export function initializeWindowsNativeToolchain() {
if (process.platform !== 'win32') {
return
}
if (hasWindowsCompilerSelection(process.env)) {
return
}
try {
if (!['x64', 'arm64'].includes(process.arch)) {
throw new Error('Windows compiler setup requires a supported runner architecture')
}
const programFiles = Object.entries(process.env)
.filter(([key]) => /^(ProgramFiles|ProgramFiles\(x86\))$/i.test(key))
.map(([, value]) => value)
const vswhere = programFiles
.map((directory) => join(directory, 'Microsoft Visual Studio', 'Installer', 'vswhere.exe'))
.find(existsSync)
if (!vswhere) {
throw new Error('Visual Studio discovery tool is unavailable')
}
const discovery = runProcessSync({
program: vswhere,
args: [
'-latest',
'-products',
'*',
'-utf8',
'-requires',
process.arch === 'arm64'
? 'Microsoft.VisualStudio.Component.VC.Tools.ARM64'
: 'Microsoft.VisualStudio.Component.VC.Tools.x86.x64',
'-property',
'installationPath'
],
stdio: 'pipe',
timeoutMs: 30_000
})
const installations = discovery.stdout.trim().split(/\r?\n/).filter(Boolean)
if (discovery.code !== 0 || installations.length !== 1) {
throw new Error('Visual Studio discovery did not select one compiler')
}
const devcmd = join(installations[0], 'Common7', 'Tools', 'VsDevCmd.bat')
if (!existsSync(devcmd)) {
throw new Error('Visual Studio developer command is unavailable')
}
// process-host owns the fixed batch runner's quoting and hidden console.
const result = runProcessSync({
program: join(import.meta.dirname, 'capture-windows-native-environment.cmd'),
args: [],
env: {
...process.env,
ORCA_WINDOWS_VSDEVCMD: devcmd,
ORCA_WINDOWS_NATIVE_ARCH: process.arch
},
stdio: 'pipe',
timeoutMs: 60_000
})
if (result.code !== 0) {
throw new Error('Visual Studio developer command failed')
}
const environment = parseWindowsCompilerEnvironment(result.stdout, process.arch)
console.log(`[native-runtime] Initialized Windows ${process.arch} compiler environment`)
return environment
} catch {
console.warn(
'[native-runtime] Compiler environment setup unavailable; retaining node-gyp discovery'
)
}
}
export function prepareWindowsNodeRuntime(runProcess = runProcessSync) {
const script = join(import.meta.dirname, 'ensure-native-runtime.mjs')
const check = runProcess({
program: process.execPath,
args: [script, '--check-only'],
stdio: 'pipe',
timeoutMs: null
})
const compilerEnvironment = check.code === 0 ? undefined : initializeWindowsNativeToolchain()
const result = runProcess({
program: process.execPath,
args: [script, '--runtime=node'],
env: compilerEnvironment
? mergeWindowsCompilerEnvironment(process.env, compilerEnvironment)
: process.env,
stdio: 'inherit',
timeoutMs: null
})
return result.code ?? 1
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exitCode = prepareWindowsNodeRuntime()
}
@@ -0,0 +1,85 @@
import { describe, expect, it, vi } from 'vitest'
import {
hasWindowsCompilerSelection,
mergeWindowsCompilerEnvironment,
parseWindowsCompilerEnvironment,
prepareWindowsNodeRuntime
} from './initialize-windows-native-toolchain.mjs'
const receipt = [
'Developer Command Prompt banner, not an environment variable',
'TOKEN=should-not-be-exported',
'ORCA_WINDOWS_NATIVE_ENVIRONMENT',
'VCINSTALLDIR=C:\\Visual Studio\\VC\\',
'VSCMD_VER=18.10.12224.181',
'WindowsSDKVersion=10.0.28000.0\\',
'VSCMD_ARG_TGT_ARCH=arm64',
'Path=C:\\工具;C:\\Program Files\\Visual Studio',
'INCLUDE=C:\\SDK=Custom\\Include',
'TOKEN=should-not-be-exported',
'=C:=C:\\',
''
].join('\r\n')
describe('Windows compiler environment receipt', () => {
it('keeps compiler paths and equals signs while excluding unrelated variables and banners', () => {
expect(parseWindowsCompilerEnvironment(receipt, 'arm64')).toEqual({
VCINSTALLDIR: 'C:\\Visual Studio\\VC\\',
VSCMD_VER: '18.10.12224.181',
WindowsSDKVersion: '10.0.28000.0\\',
VSCMD_ARG_TGT_ARCH: 'arm64',
Path: 'C:\\工具;C:\\Program Files\\Visual Studio',
INCLUDE: 'C:\\SDK=Custom\\Include'
})
})
it.each([
receipt.replace('ORCA_WINDOWS_NATIVE_ENVIRONMENT', 'missing'),
receipt.replace('VCINSTALLDIR=', 'unrelated='),
receipt.replace('VSCMD_VER=', 'unrelated='),
receipt.replace('WindowsSDKVersion=', 'unrelated='),
receipt.replace('10.0.28000.0\\', 'unavailable'),
receipt.replace('18.10.12224.181', 'unavailable'),
receipt.replace('VSCMD_ARG_TGT_ARCH=arm64', 'VSCMD_ARG_TGT_ARCH=x64')
])('rejects incomplete, invalid or wrong-architecture setup before use', (output) => {
expect(() => parseWindowsCompilerEnvironment(output, 'arm64')).toThrow()
})
})
describe('Windows native compiler environment scope', () => {
it('replaces path keys case-insensitively without changing the parent environment', () => {
const original = { PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' }
expect(
mergeWindowsCompilerEnvironment(original, { Path: 'compiler-path', Lib: 'compiler-lib' })
).toEqual({ Path: 'compiler-path', Lib: 'compiler-lib', TOKEN: 'unchanged' })
expect(original).toEqual({ PATH: 'original-path', LIB: 'original-lib', TOKEN: 'unchanged' })
})
it.each([
'VCINSTALLDIR',
'GYP_MSVS_VERSION',
'npm_config_msvs_version',
'npm_package_config_node_gyp_msvs_version'
])('retains an existing compiler selection through %s', (name) => {
expect(hasWindowsCompilerSelection({ [name.toLowerCase()]: 'requested' })).toBe(true)
expect(hasWindowsCompilerSelection({ [name]: '' })).toBe(false)
})
it('uses the original preparation and environment after a passing warm probe', () => {
const runProcess = vi.fn(() => ({ code: 0 }))
expect(prepareWindowsNodeRuntime(runProcess)).toBe(0)
expect(runProcess).toHaveBeenCalledTimes(2)
expect(runProcess).toHaveBeenLastCalledWith(
expect.objectContaining({
args: [expect.stringContaining('ensure-native-runtime.mjs'), '--runtime=node'],
env: process.env,
stdio: 'inherit'
})
)
})
it('propagates native preparation failure after a passing warm probe', () => {
const runProcess = vi.fn().mockReturnValueOnce({ code: 0 }).mockReturnValueOnce({ code: 9 })
expect(prepareWindowsNodeRuntime(runProcess)).toBe(9)
})
})
@@ -1,9 +1,11 @@
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { runInNewContext } from 'node:vm'
import { delimiter, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { runProcessSync } from '@orca/process-host'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
const action = parse(
readFileSync('.github/actions/prepare-linux-package-fixture/action.yml', 'utf8')
@@ -132,3 +134,45 @@ it('uses exact context/platform keys and the same restore-only action in package
.every((step) => step.with['save-cache'] === "${{ github.event_name != 'pull_request' }}")
).toBe(true)
})
it.each([
['pull_request', false],
['push', true],
['schedule', true],
['workflow_dispatch', true]
])('keeps fixture seeding available without a redundant PR runner on %s', (event, runs) => {
const source = workflow('ci-cache-warmup-platforms').jobs['warm-platforms'].uses
const warm = parse(readFileSync(source, 'utf8')).jobs['warm-linux-package-fixtures']
const context = { github: { event_name: event } }
expect(warm['runs-on']).toBe('ubuntu-latest')
expect(warm.steps.map((step) => step.uses)).toEqual([
'actions/checkout@v6',
'./.github/actions/prepare-linux-package-fixture',
'./.github/actions/prepare-linux-package-fixture',
'./.github/actions/prepare-linux-package-fixture'
])
expect(runInNewContext(warm.if, context)).toBe(runs)
for (const step of warm.steps.slice(1)) {
expect(runInNewContext(step.with['save-cache'].slice(3, -2), context)).toBe(runs)
}
const consumers = workflow('pr').jobs.package.steps.filter((step) =>
step.uses?.endsWith('/prepare-linux-package-fixture')
)
expect(consumers.every((step) => step.with['save-cache'] === undefined)).toBe(true)
expect(consumers.map((step) => step.with.fixture)).toEqual(
warm.steps.slice(1).map((step) => step.with.fixture)
)
})
it.each([
'.github/actions/prepare-linux-package-fixture/action.yml',
'config/docker/daemon-shutdown-descendants/Dockerfile',
'config/docker/headless-serve-shutdown/Dockerfile',
'config/docker/cli-launch-contract/Dockerfile'
])('validates a changed fixture in Linux packaging: %s', (file) => {
expect(classifyPrJobs([file])).toMatchObject({
test: true,
package: true,
package_windows: false
})
})
@@ -3734,9 +3734,6 @@
"auto.components.settings.terminal.clipboard.search.5ffcd13c90": {
"ko": "tmux"
},
"auto.components.settings.terminal.clipboard.search.603818e8d8": {
"ko": "터미널 선택 영역이 만들어지는 즉시 자동으로 클립보드에 복사합니다."
},
"auto.components.settings.terminal.clipboard.search.e87c6d776d": {
"ko": "자동"
},
@@ -5246,9 +5243,6 @@
"auto.components.settings.TerminalPane.a9d47451d1": {
"ko": "\"새 탭\"은 현재 포커스를 가져가지 않고 \"Setup\"이라는 제목의 백그라운드 탭에서 설정 명령을 엽니다."
},
"auto.components.settings.TerminalPane.4729c645fc": {
"ko": "터미널에서 선택한 내용을 클립보드에 자동으로 복사합니다."
},
"auto.components.settings.TerminalPane.9129b7e805": {
"ko": "터미널 패널 위에 마우스를 올리면 클릭하지 않고도 활성화됩니다."
},
@@ -41,6 +41,8 @@ const ALWAYS_FILES = new Set([
'config/scripts/workspace-source-exports.mjs',
'config/scripts/workspace-source-exports.test.mjs',
'config/scripts/headless-detector-compiler-cache.mjs',
'config/scripts/initialize-windows-native-toolchain.mjs',
'config/scripts/capture-windows-native-environment.cmd',
'config/scripts/node-server-qualification.mjs',
// The source graph sees process-host source, not the build that emits the dist servers load.
'src/packages/process-host/.gitignore',
@@ -59,6 +59,8 @@ function context(os, arch, event, ref, template = false) {
},
success: () => true,
fromJSON: JSON.parse,
format: (template, ...values) =>
template.replace(/\{(\d+)\}/g, (_match, index) => String(values[Number(index)])),
contains: (values, value) => values.includes(value)
}
}
@@ -173,6 +175,38 @@ describe('SSH Windows consumers of qualified server slots', () => {
const sshWorkflow = parse(readFileSync('.github/workflows/ssh-windows-hosts.yml', 'utf8'))
const sshSteps = sshWorkflow.jobs.hosts.steps
const sshPrepare = sshSteps.find((step) => step.uses === prepare.uses)
const inboxBackground = sshSteps.find((step) => step.id === 'inbox-capability')
const inboxSerial = sshSteps.find((step) => step.name?.endsWith('after the builds'))
const allLanes = [
{ arch: 'x64', runner: 'windows-2022', server: 'inbox' },
{ arch: 'x64', runner: 'windows-2022', server: 'preview', archive: 'OpenSSH-Win64.zip' },
{ arch: 'arm64', runner: 'windows-11-arm', server: 'inbox' },
{ arch: 'arm64', runner: 'windows-11-arm', server: 'preview', archive: 'OpenSSH-ARM64.zip' }
]
function sshContext(event, inputs = {}) {
const ctx = context('Windows', 'ARM64', event, 'refs/heads/main')
ctx.github.event = { inputs, pull_request: { number: undefined } }
ctx.inputs = inputs
ctx.env = {
INBOX_PREPARATION_MODE: sshValue(sshWorkflow.jobs.hosts.env.INBOX_PREPARATION_MODE, ctx)
}
return ctx
}
function sshValue(value, ctx) {
return evaluate(value.slice(3, -2).trim(), ctx)
}
function backgroundPreparationRuns(ctx) {
const condition = inboxBackground.run
.match(/^\s*if\((.*)\)\{\s*$/m)[1]
.replace(/'\$\{\{ matrix\.(arch|server) \}\}'/g, (_match, name) => `matrix.${name}`)
.replaceAll('$env:INBOX_PREPARATION_MODE', 'env.INBOX_PREPARATION_MODE')
.replaceAll(' -eq ', ' === ')
.replaceAll(' -and ', ' && ')
return evaluate(condition, ctx)
}
it.each(['pull_request', 'workflow_dispatch'])(
'restores only PR slots and keeps manual %s qualification fresh',
@@ -188,13 +222,8 @@ describe('SSH Windows consumers of qualified server slots', () => {
it('keeps both architectures, both sshd versions and the existing build order', () => {
expect(
sshWorkflow.jobs.hosts.strategy.matrix.include.map(({ arch, server }) => [arch, server])
).toEqual([
['x64', 'inbox'],
['x64', 'preview'],
['arm64', 'inbox'],
['arm64', 'preview']
])
sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, sshContext('pull_request'))
).toEqual(allLanes)
const addon = sshSteps.findIndex((step) =>
step.run?.includes('build-windows-process-tree-addon.mjs')
)
@@ -215,6 +244,92 @@ describe('SSH Windows consumers of qualified server slots', () => {
'config/scripts/orcad-windows-prebuild-cache.mjs'
)
})
it.each([
['pull_request', {}, 'overlap', false],
['pull_request', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
['push', { preparation_mode: 'serial', arm_inbox_only: true }, 'overlap', false],
['workflow_dispatch', {}, 'overlap', false],
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: false }, 'overlap', false],
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: false }, 'serial', false],
['workflow_dispatch', { preparation_mode: 'overlap', arm_inbox_only: true }, 'overlap', true],
['workflow_dispatch', { preparation_mode: 'serial', arm_inbox_only: true }, 'serial', true]
])('%s inputs=%j mode=%s ARM-only=%s', (event, inputs, mode, armOnly) => {
const ctx = sshContext(event, inputs)
const lanes = sshValue(sshWorkflow.jobs.hosts.strategy.matrix.include, ctx)
expect(lanes).toEqual(armOnly ? [allLanes[2]] : allLanes)
expect(ctx.env.INBOX_PREPARATION_MODE).toBe(mode)
for (const matrix of lanes) {
ctx.matrix = matrix
const inboxArm = matrix.server === 'inbox' && matrix.arch === 'arm64'
expect(backgroundPreparationRuns(ctx)).toBe(inboxArm && mode === 'overlap')
expect(sshValue(inboxSerial.if, ctx)).toBe(inboxArm && mode === 'serial')
}
})
it('defaults to the existing overlap schedule and keeps serial after the successful wait', () => {
expect(sshWorkflow.on.workflow_dispatch.inputs.preparation_mode).toMatchObject({
type: 'choice',
default: 'overlap',
options: ['overlap', 'serial']
})
expect(sshWorkflow.on.workflow_dispatch.inputs.arm_inbox_only).toMatchObject({
type: 'boolean',
default: false
})
const dependencies = sshSteps.findIndex((step) => step.id === 'dependencies')
const template = sshSteps.findIndex((step) => step.run?.includes('build-orcad-template.mjs'))
const wait = sshSteps.findIndex((step) => step.wait === 'inbox-capability')
const serial = sshSteps.indexOf(inboxSerial)
const hosts = sshSteps.findIndex((step) => step.name?.startsWith('Run the Windows host cells'))
expect(inboxBackground.background).toBe(true)
expect(inboxBackground.if).toBeUndefined()
expect(sshSteps.indexOf(inboxBackground)).toBeLessThan(dependencies)
expect(template).toBeLessThan(wait)
expect(serial).toBe(wait + 1)
expect(hosts).toBe(serial + 1)
expect(inboxSerial.background).toBeUndefined()
for (const preparation of [inboxBackground, inboxSerial]) {
expect(preparation['continue-on-error']).toBeUndefined()
expect(preparation.run).toContain(
'. config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'
)
expect(preparation.run).toContain(
"-Receipt (Join-Path $receipts 'inbox-capability-preparation.json') -PreparationMode $env:INBOX_PREPARATION_MODE"
)
}
expect(sshSteps[hosts].run).toContain('-InboxPreparationReceipt $preparation')
expect(sshWorkflow.jobs.hosts.env.ORCA_BACKGROUND_LAUNCH).toBe('1')
expect(sshWorkflow.jobs.hosts.env.ORCA_ISOLATED_SSH_CI).toBe('1')
for (const event of ['pull_request', 'workflow_dispatch']) {
for (const preparation_mode of ['overlap', 'serial']) {
for (const [cells, jobMinutes, hostMinutes] of [
['', 75, 50],
['orcad-cli-managed', 160, 130]
]) {
const ctx = sshContext(event, { preparation_mode, arm_inbox_only: true, cells })
expect(sshValue(sshWorkflow.jobs.hosts['timeout-minutes'], ctx)).toBe(jobMinutes)
expect(sshValue(sshSteps[hosts]['timeout-minutes'], ctx)).toBe(hostMinutes)
}
}
}
})
it('isolates manual comparison modes while preserving ordinary cancellation groups', () => {
const group = (ctx) =>
sshWorkflow.concurrency.group.replace(/\$\{\{(.*?)\}\}/g, (_match, expression) =>
String(evaluate(expression.trim(), ctx))
)
const regular = sshContext('pull_request')
regular.github.event.pull_request = { number: 28669 }
expect(group(regular)).toBe('ssh-windows-hosts-28669')
expect(group(sshContext('workflow_dispatch'))).toBe('ssh-windows-hosts-refs/heads/main')
for (const mode of ['overlap', 'serial']) {
expect(
group(sshContext('workflow_dispatch', { arm_inbox_only: true, preparation_mode: mode }))
).toBe(`ssh-windows-hosts-refs/heads/main-${mode}`)
}
})
})
const commandMocks = `
+4
View File
@@ -267,6 +267,8 @@ const MANAGED_HOOK_PREFIXES = [
const NATIVE_RUNTIME_PREFIXES = [
'config/scripts/ensure-native-runtime',
'config/scripts/initialize-windows-native-toolchain',
'config/scripts/capture-windows-native-environment',
'config/scripts/rebuild-native-deps',
'config/scripts/node-pty-job-ownership',
'config/scripts/windows-pe-machine',
@@ -288,6 +290,8 @@ const NATIVE_CACHE_FILES = new Set([
'.npmrc',
'.pnpmfile.cjs',
'config/scripts/ensure-native-runtime.mjs',
'config/scripts/initialize-windows-native-toolchain.mjs',
'config/scripts/capture-windows-native-environment.cmd',
'config/scripts/rebuild-native-deps.mjs',
'config/scripts/node-pty-job-ownership.cjs',
'config/scripts/windows-pe-machine.cjs',
@@ -37,7 +37,10 @@ const STACK_WRITERS = [
'src/renderer/src/runtime/sync-runtime-graph.ts',
'src/renderer/src/runtime/web-session-tabs-sync/store-patch.ts',
'src/main/runtime/orca-runtime-split-terminal.ts',
'src/main/runtime/orca-runtime-sync-window-graph.ts',
'src/main/runtime/session-graph/session-window-graph.ts',
'src/main/runtime/session-tabs/mobile-session-hydration.ts',
'src/main/runtime/session-tabs/mobile-session-reconciliation.ts',
'src/main/runtime/session-tabs/mobile-session-preservation.ts',
'src/main/runtime/mobile-session-layout-projection.ts',
'src/main/runtime/headless-runtime-graph.ts',
'src/main/persistence/loading-store/session-host-partitions.ts',
@@ -38,6 +38,10 @@ const LAYOUT_ORACLE_PREFIXES = [
'src/main/runtime/terminal-surface-close.ts',
'src/main/runtime/terminal/output/terminal-derived-agent-facts.ts',
'src/main/runtime/terminal/output/terminal-output-generation.ts',
'src/main/runtime/session-graph/session-window-graph.ts',
'src/main/runtime/session-tabs/mobile-session-hydration.ts',
'src/main/runtime/session-tabs/mobile-session-reconciliation.ts',
'src/main/runtime/session-tabs/mobile-session-preservation.ts',
'src/renderer/src/components/terminal/terminal-tab-actions.ts',
'src/renderer/src/components/tab-bar/web-runtime-tab-move-mirror.ts',
'src/shared/rpc-contract/workspace-layout-params.ts',
@@ -54,6 +54,7 @@ function fixture(onTestFinished, distSeed) {
JSON.stringify({
compilerOptions: {
target: 'ES2022',
lib: ['ES2022'],
module: 'Node16',
moduleResolution: 'Node16',
rootDir: 'src',
@@ -69,6 +69,7 @@ function fixture(): { root: string; packageDir: string; main: string } {
extends: '../../../tsconfig.base.json',
compilerOptions: {
target: 'ES2022',
lib: ['ES2022'],
module: 'Node16',
moduleResolution: 'Node16',
rootDir: 'src',
@@ -153,6 +153,7 @@ export const SQLITE_RUNTIME_INCLUDE = [
'src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts',
'src/main/native-chat/native-chat-visuals-sweep.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-replaced-server-turn.test.ts',
'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts',
@@ -2,10 +2,11 @@ import { readdirSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
/**
* Bare JS scripts cannot use the TS runProcess chokepoint; resolve package bins
* under process.execPath instead (see oxc-cli-invocation.mjs). The list only shrinks.
* Resolve package bins under process.execPath (see oxc-cli-invocation.mjs).
* The legacy list only shrinks; reviewed native compiler references use process-host.
* The literal check catches quoted .cmd/.bat paths, excluding drive letters and
* templates; comment-only lines are dropped, but trailing comments fail closed.
* The AST check catches spawn/spawnSync/execFile/execFileSync first arguments
@@ -13,10 +14,10 @@ import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
* local variable initializers and ternaries. It does not follow imports, function
* returns, assignments, destructuring, aliased spawn names or computed members.
* Names are matched without scope/import resolution; shadowed names and non-path
* join/resolve calls can fail closed. Paths are joined textually, without reducing
* dot segments. Neither check proves a Windows branch or an actual unsafe spawn.
* join/resolve calls can fail closed. Package paths fold dot segments.
* Neither check proves a Windows branch or an actual unsafe spawn.
*/
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/i
const WINDOWS_SHIM_LITERAL = /['"][\w./\\-]*\.(?:cmd|bat)['"]/gi
const SCANNED_ROOTS = ['config/scripts', 'tests/tools']
@@ -83,7 +84,13 @@ describe('windows batch shim spawn boundary', () => {
)
const offenders = scripts.filter((relativePath) => {
const contents = readFileSync(path.join(repoRoot, relativePath), 'utf8')
return WINDOWS_SHIM_LITERAL.test(codeText(contents)) || hasNodeModulesBinSpawn(contents)
const literals = [...codeText(contents).matchAll(WINDOWS_SHIM_LITERAL)].map((match) =>
match[0].slice(1, -1)
)
return (
hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals) ||
hasNodeModulesBinSpawn(contents)
)
})
it('scans a plausible number of scripts', () => {
@@ -95,7 +102,7 @@ describe('windows batch shim spawn boundary', () => {
const unlisted = offenders.filter((name) => !WINDOWS_SHIM_SPAWN_ALLOWLIST.includes(name))
expect(
unlisted,
'Node cannot spawn a Windows batch shim without a shell. Resolve the real executable — see oxc-cli-invocation.mjs.'
'Resolve package shims to their real executable. Fixed native compiler batch runners must pass the scoped process-host policy.'
).toEqual([])
})
@@ -0,0 +1,166 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import { hasWindowsNativeBatchReferenceViolation } from './windows-native-batch-reference-policy'
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
const benchmark = 'config/scripts/benchmark-windows-native-preparation.mjs'
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
const metadataCallers = [
'config/scripts/node-server-change-scope.mjs',
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/windows-native-toolchain-routing.test.mjs'
]
const capture = 'capture-windows-native-environment.cmd'
const initializerSource = `
import { runProcessSync as captureEnvironment } from '@orca/process-host'
const devcmd = join(installation, 'Common7', 'Tools', 'VsDevCmd.bat')
existsSync(devcmd)
captureEnvironment({
program: join(import.meta.dirname, '${capture}'),
env: { ORCA_WINDOWS_VSDEVCMD: devcmd }
})
`
const benchmarkSource = `
import { runProcess } from '@orca/process-host'
const source = 'config/scripts/${capture}'
await runProcess({ program: process.execPath, args: [source] })
`
function hasViolation(relativePath: string, contents: string): boolean {
const literals = [...contents.matchAll(/['"][\w./\\-]*\.(?:cmd|bat)['"]/gi)].map((match) =>
match[0].slice(1, -1)
)
return hasWindowsNativeBatchReferenceViolation(relativePath, contents, literals)
}
describe('reviewed Windows native batch references', () => {
it.each([...metadataCallers, initializer, benchmark, observer])(
'accepts the current reviewed source in %s',
(relativePath) => {
const root = path.resolve(import.meta.dirname, '..', '..')
expect(hasViolation(relativePath, readFileSync(path.join(root, relativePath), 'utf8'))).toBe(
false
)
}
)
it.each(metadataCallers)('allows the fixed source path as metadata in %s', (relativePath) => {
expect(hasViolation(relativePath, `const source = 'config/scripts/${capture}'`)).toBe(false)
})
it('accepts the process-host executor through its imported alias', () => {
expect(hasViolation(initializer, initializerSource)).toBe(false)
})
it('accepts the benchmark executor', () => {
expect(hasViolation(benchmark, benchmarkSource)).toBe(false)
})
it.each([
'config/scripts/other-native-preparation.mjs',
'tests/tools/initialize-windows-native-toolchain.mjs',
`config/scripts/nested/${path.basename(initializer)}`
])('rejects the fixed runner in another caller: %s', (relativePath) => {
expect(hasViolation(relativePath, initializerSource)).toBe(true)
})
it.each([
'elsewhere/capture-windows-native-environment.cmd',
'./capture-windows-native-environment.cmd',
'CAPTURE-WINDOWS-NATIVE-ENVIRONMENT.cmd',
'other.cmd',
'other.bat'
])('rejects another batch path in a reviewed executor: %s', (literal) => {
expect(hasViolation(initializer, `${initializerSource}\nconst other = '${literal}'`)).toBe(true)
})
it.each(metadataCallers)('rejects other batch metadata in %s', (relativePath) => {
expect(
hasViolation(relativePath, `const sources = ['config/scripts/${capture}', 'other.cmd']`)
).toBe(true)
expect(hasViolation(relativePath, `const command = 'VsDevCmd.bat'`)).toBe(true)
})
it.each([
[initializer, initializerSource],
[benchmark, benchmarkSource],
[observer, `require('@orca/process-host'); const capture = '${capture}'`]
])('rejects a missing process-host load in %s', (relativePath, contents) => {
expect(hasViolation(relativePath, contents.replace('@orca/process-host', 'other-runner'))).toBe(
true
)
})
it('rejects an unused process-host import beside an unverified executor', () => {
expect(
hasViolation(initializer, initializerSource.replace('captureEnvironment({', 'otherRunner({'))
).toBe(true)
})
it('rejects another executor for the fixed capture script beside a valid call', () => {
expect(
hasViolation(
initializer,
`${initializerSource}\notherRunner({ program: join(import.meta.dirname, '${capture}') })`
)
).toBe(true)
})
it.each([
"import { spawnSync as execute } from 'node:child_process'",
"import * as cp from 'child_process'",
"await import('node:child_process')",
"const builtins = 'node:child_process'; process.getBuiltinModule(builtins)",
"const require = createRequire(import.meta.url); const cp = require('child_process')"
])('rejects raw child-process module loads: %s', (load) => {
expect(hasViolation(initializer, `${initializerSource}\n${load}`)).toBe(true)
})
it.each([
"spawn('program', [])",
"cp.spawnSync('program', [])",
"cp['execFile']('program', [])",
"execFileSync('program', [])",
"exec('program')",
"execSync('program')",
"fork('program')"
])('rejects raw process calls: %s', (call) => {
expect(hasViolation(initializer, `${initializerSource}\n${call}`)).toBe(true)
})
it.each([
"runProcess({ program: 'node_modules/.bin/pnpm' })",
"const bin = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program: bin })",
"runProcess({ program: join(root, 'node_modules', 'tools', '..', '.bin', 'pnpm') })",
"const spec = { 'program': 'root\\\\node_modules\\\\.bin\\\\pnpm' }; runProcess(spec)",
"const program = join(root, 'node_modules', '.bin', 'pnpm'); runProcess({ program })",
"runProcess({ ['program']: join(root, 'node_modules', '.bin', 'pnpm') })"
])('rejects package-bin programs through process-host: %s', (call) => {
expect(hasViolation(benchmark, `${benchmarkSource}\n${call}`)).toBe(true)
})
it.each(metadataCallers)('rejects process execution added to metadata in %s', (relativePath) => {
expect(
hasViolation(
relativePath,
`const capture = 'config/scripts/${capture}'; runProcess({ program: capture })`
)
).toBe(true)
expect(
hasViolation(
relativePath,
`import { runProcess as execute } from '@orca/process-host'; const capture = 'config/scripts/${capture}'; execute({ program: capture })`
)
).toBe(true)
})
it.each([
"captureEnvironment({ program: 'VsDevCmd.bat' })",
'captureEnvironment({ program: devcmd })',
'const program = devcmd; captureEnvironment({ program })',
"const devcmd = join(other, 'VsDevCmd.bat')"
])('keeps the discovered developer command as metadata: %s', (extra) => {
expect(hasViolation(initializer, `${initializerSource}\n${extra}`)).toBe(true)
})
})
@@ -0,0 +1,229 @@
import ts from 'typescript-api'
import {
collectModuleSpecifiers,
literalString,
propertyName
} from './static-module-specifiers.mjs'
import { hasNodeModulesBinSpawn } from './windows-bin-spawn-predicate.mjs'
const references = new Map([
[
'config/scripts/node-server-change-scope.mjs',
'config/scripts/capture-windows-native-environment.cmd'
],
[
'config/scripts/pr-code-change-scope.mjs',
'config/scripts/capture-windows-native-environment.cmd'
],
[
'config/scripts/windows-native-toolchain-routing.test.mjs',
'config/scripts/capture-windows-native-environment.cmd'
],
[
'config/scripts/windows-native-benchmark-observer.mjs',
'capture-windows-native-environment.cmd'
],
[
'config/scripts/initialize-windows-native-toolchain.mjs',
'capture-windows-native-environment.cmd'
],
[
'config/scripts/benchmark-windows-native-preparation.mjs',
'config/scripts/capture-windows-native-environment.cmd'
]
])
const executors = new Map([
['config/scripts/initialize-windows-native-toolchain.mjs', 'runProcessSync'],
['config/scripts/benchmark-windows-native-preparation.mjs', 'runProcess']
])
const initializer = 'config/scripts/initialize-windows-native-toolchain.mjs'
const observer = 'config/scripts/windows-native-benchmark-observer.mjs'
const rawProcessMethods = new Set([
'spawn',
'spawnSync',
'exec',
'execSync',
'execFile',
'execFileSync',
'fork'
])
const processHostMethods = new Set(['runProcess', 'runProcessSync', 'spawnProcess'])
function callName(node: ts.Expression): string {
return ts.isIdentifier(node) ? node.text : (propertyName(node) ?? '')
}
function isDeveloperCommandMetadata(node: ts.StringLiteral): boolean {
const call = node.parent
const declaration = call.parent
return (
ts.isCallExpression(call) &&
callName(call.expression) === 'join' &&
call.arguments.at(-1) === node &&
ts.isVariableDeclaration(declaration) &&
ts.isIdentifier(declaration.name) &&
declaration.name.text === 'devcmd'
)
}
function isDeveloperCommandUse(node: ts.Identifier): boolean {
const parent = node.parent
return (
(ts.isVariableDeclaration(parent) && parent.name === node) ||
(ts.isCallExpression(parent) &&
callName(parent.expression) === 'existsSync' &&
parent.arguments[0] === node) ||
(ts.isPropertyAssignment(parent) &&
parent.initializer === node &&
ts.isIdentifier(parent.name) &&
parent.name.text === 'ORCA_WINDOWS_VSDEVCMD')
)
}
function isProcessHostCapture(node: ts.StringLiteral, importedMethods: Set<string>): boolean {
const path = node.parent
if (
!ts.isCallExpression(path) ||
callName(path.expression) !== 'join' ||
path.arguments.length !== 2 ||
path.arguments[0].getText() !== 'import.meta.dirname' ||
path.arguments[1] !== node
) {
return false
}
const program = path.parent
if (
!ts.isPropertyAssignment(program) ||
!ts.isIdentifier(program.name) ||
program.name.text !== 'program'
) {
return false
}
const spec = program.parent
const call = spec.parent
return (
ts.isObjectLiteralExpression(spec) &&
ts.isCallExpression(call) &&
call.arguments[0] === spec &&
ts.isIdentifier(call.expression) &&
importedMethods.has(call.expression.text)
)
}
export function hasWindowsNativeBatchReferenceViolation(
relativePath: string,
contents: string,
literals: readonly string[]
): boolean {
const capture = references.get(relativePath)
if (!capture) {
return literals.length > 0
}
if (
literals.some(
(literal) =>
literal !== capture && !(relativePath === initializer && literal === 'VsDevCmd.bat')
)
) {
return true
}
const modules = collectModuleSpecifiers(relativePath, contents)
if (modules.some((module) => module === 'child_process' || module === 'node:child_process')) {
return true
}
const expectedMethod = executors.get(relativePath)
if ((expectedMethod || relativePath === observer) && !modules.includes('@orca/process-host')) {
return true
}
if (!expectedMethod && relativePath !== observer && modules.includes('@orca/process-host')) {
return true
}
const source = ts.createSourceFile(relativePath, contents, ts.ScriptTarget.Latest, true)
const importedMethods = new Set<string>()
const calls: ts.CallExpression[] = []
const programs: ts.Expression[] = []
const captureReferences: ts.StringLiteral[] = []
let violation = false
let developerCommandDeclarations = 0
function visit(node: ts.Node): void {
if (
ts.isImportDeclaration(node) &&
ts.isStringLiteral(node.moduleSpecifier) &&
node.moduleSpecifier.text === '@orca/process-host'
) {
const bindings = node.importClause?.namedBindings
if (bindings && ts.isNamedImports(bindings)) {
for (const binding of bindings.elements) {
if ((binding.propertyName ?? binding.name).text === expectedMethod) {
importedMethods.add(binding.name.text)
}
}
}
}
if (ts.isCallExpression(node)) {
calls.push(node)
const method = callName(node.expression)
if (rawProcessMethods.has(method)) {
violation = true
}
if (!expectedMethod && relativePath !== observer && processHostMethods.has(method)) {
violation = true
}
}
if (
ts.isPropertyAssignment(node) &&
((ts.isIdentifier(node.name) && node.name.text === 'program') ||
(ts.isStringLiteral(node.name) && node.name.text === 'program') ||
(ts.isComputedPropertyName(node.name) && literalString(node.name.expression) === 'program'))
) {
programs.push(node.initializer)
}
if (ts.isShorthandPropertyAssignment(node) && node.name.text === 'program') {
programs.push(node.name)
}
if (relativePath === initializer) {
if (ts.isStringLiteral(node) && node.text === capture) {
captureReferences.push(node)
}
if (
ts.isStringLiteral(node) &&
node.text === 'VsDevCmd.bat' &&
!isDeveloperCommandMetadata(node)
) {
violation = true
}
if (ts.isIdentifier(node) && node.text === 'devcmd') {
if (!isDeveloperCommandUse(node)) {
violation = true
}
if (ts.isVariableDeclaration(node.parent) && node.parent.name === node) {
developerCommandDeclarations += 1
}
}
}
ts.forEachChild(node, visit)
}
visit(source)
if (
expectedMethod &&
!calls.some(
(call) => ts.isIdentifier(call.expression) && importedMethods.has(call.expression.text)
)
) {
violation = true
}
if (relativePath === initializer && developerCommandDeclarations !== 1) {
violation = true
}
if (captureReferences.some((node) => !isProcessHostCapture(node, importedMethods))) {
violation = true
}
// Reuse package-path folding for process-host's named program option.
return (
violation ||
hasNodeModulesBinSpawn(contents) ||
programs.some((program) =>
hasNodeModulesBinSpawn(`${contents}\nspawnSync(${program.getText(source)})`)
)
)
}
@@ -0,0 +1,118 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { basename } from 'node:path'
import { performance } from 'node:perf_hooks'
export const compilerEnvironmentKey =
/^(PATH|INCLUDE|LIB|LIBPATH|VCINSTALLDIR|VSINSTALLDIR|VisualStudioVersion|VSCMD_.+|VCTools.+|WindowsSDK.+|UniversalCRT.+|UCRTVersion)$/i
export const hash = (value) => createHash('sha256').update(value).digest('hex')
export function environmentReceipt(environment) {
const entries = Object.entries(environment)
.filter(([, value]) => value !== undefined)
.map(([name, value]) => [name.toLowerCase(), String(value)])
.sort(([left], [right]) => left.localeCompare(right))
assert.equal(
new Set(entries.map(([name]) => name)).size,
entries.length,
'Duplicate environment keys'
)
return {
hash: hash(JSON.stringify(entries)),
versions: Object.fromEntries(
entries.filter(
([name, value]) =>
/^(visualstudioversion|vscmd_ver|windowssdkversion|vctoolsversion|vscmd_arg_tgt_arch)$/.test(
name
) && /^(?:[\d.]+\\?|x64|amd64|arm64)$/.test(value)
)
),
compiler: Object.fromEntries(
entries
.filter(([name]) => compilerEnvironmentKey.test(name))
.map(([name, value]) => [name, hash(value)])
)
}
}
export function changedEnvironmentNames(before, after) {
const normalize = (environment) =>
new Map(Object.entries(environment).map(([name, value]) => [name.toLowerCase(), value]))
const original = normalize(before)
const prepared = normalize(after)
return [...new Set([...original.keys(), ...prepared.keys()])]
.filter((name) => original.get(name) !== prepared.get(name))
.sort()
}
export function processPhase(spec) {
const name = basename(spec.program).toLowerCase()
if (name === 'vswhere.exe') {
return 'discovery'
}
if (name === 'capture-windows-native-environment.cmd') {
return 'capture'
}
if (spec.args?.some((arg) => arg.endsWith('ensure-native-runtime.mjs'))) {
return spec.args.includes('--check-only') ? 'probe' : 'preparation'
}
return spec.args?.includes('rebuild') ? 'addon-build' : 'other'
}
export function installNativeBenchmarkObserver(api, output, environment = process.env) {
const original = api.runProcessSync
const parent = environmentReceipt(environment)
const receipt = { parentBefore: parent, parentAfter: parent, calls: [] }
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
api.runProcessSync = (spec) => {
const startedAt = new Date().toISOString()
const start = performance.now()
const childEnvironment = spec.env ?? environment
const call = {
phase: processPhase(spec),
program: basename(spec.program),
args: spec.args,
startedAt,
environment: environmentReceipt(childEnvironment),
changedNames: changedEnvironmentNames(environment, childEnvironment)
}
try {
const result = original(spec)
Object.assign(call, {
code: result.code,
timedOut: result.timedOut,
outputTruncated: result.outputTruncated,
stdoutSha256: hash(result.stdout ?? ''),
stderrSha256: hash(result.stderr ?? ''),
stdoutBytes: Buffer.byteLength(result.stdout ?? ''),
stderrBytes: Buffer.byteLength(result.stderr ?? '')
})
return result
} catch (error) {
call.error = error.message
throw error
} finally {
call.durationMs = performance.now() - start
call.finishedAt = new Date().toISOString()
receipt.calls.push(call)
receipt.parentAfter = environmentReceipt(environment)
assert.equal(
receipt.parentAfter.hash,
parent.hash,
'Compiler setup changed its parent environment'
)
writeFileSync(output, `${JSON.stringify(receipt, null, 2)}\n`)
}
}
return receipt
}
if (process.env.ORCA_NATIVE_BENCHMARK_TRACE) {
const require = createRequire(import.meta.url)
installNativeBenchmarkObserver(
require('@orca/process-host'),
process.env.ORCA_NATIVE_BENCHMARK_TRACE
)
}
@@ -0,0 +1,212 @@
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { test } from 'vitest'
import { parse } from 'yaml'
import { runProcess } from '@orca/process-host'
import {
assertNativeColdProbe,
assertNoHeaderDownloads,
validateObservedPreparation
} from './benchmark-windows-native-preparation.mjs'
import {
changedEnvironmentNames,
environmentReceipt,
installNativeBenchmarkObserver
} from './windows-native-benchmark-observer.mjs'
const original = { Path: 'unchanged', TOKEN: 'private' }
const parent = environmentReceipt(original)
const captureEnvironment = { ...original, VCINSTALLDIR: 'compiler' }
const call = (phase, code = 0, prepared = false) => ({
phase,
code,
environment: prepared ? environmentReceipt(captureEnvironment) : parent,
changedNames: prepared ? ['vcinstalldir'] : []
})
const receipt = (calls) => ({ parentBefore: parent, parentAfter: parent, calls })
const candidate = () =>
receipt([call('probe', 1), call('discovery'), call('capture'), call('preparation', 0, true)])
for (const [variant, warm, calls] of [
[
'control',
false,
[call('addon-build'), call('addon-build'), call('addon-build'), call('addon-build')]
],
['control', true, []],
['candidate', false, candidate().calls],
['candidate', true, [call('probe'), call('preparation')]],
[
'fallback',
false,
[call('probe', 1), call('discovery'), call('capture', 7), call('preparation')]
],
['fallback', true, [call('probe'), call('preparation')]]
]) {
test(`qualifies ${variant} ${warm ? 'warm' : 'cold'} receipts`, () =>
validateObservedPreparation(receipt(calls), variant, warm))
}
test('fails closed on an unobserved or unchanged candidate', () => {
assert.throws(() => validateObservedPreparation(receipt([]), 'candidate'), /discovery count/)
const missingCapture = candidate()
missingCapture.calls.splice(2, 1)
assert.throws(
() => validateObservedPreparation(missingCapture, 'candidate'),
/developer command count/
)
const unchanged = candidate()
unchanged.calls[3] = call('preparation')
assert.throws(() => validateObservedPreparation(unchanged, 'candidate'), /not applied/)
})
test('rejects failed preparation and unrelated or persistent environment changes', () => {
for (const mutate of [
(value) => {
value.calls[3].code = 2
},
(value) => {
value.calls[3].changedNames = ['token']
},
(value) => {
value.parentAfter = environmentReceipt(captureEnvironment)
},
(value) => {
value.calls[2].timedOut = true
}
]) {
const value = candidate()
mutate(value)
assert.throws(() => validateObservedPreparation(value, 'candidate'))
}
assert.throws(
() =>
validateObservedPreparation(
receipt([call('probe'), call('preparation', 0, true)]),
'candidate',
true
),
/changed compiler/
)
})
test('requires all four initial addon failures and excludes timed header downloads', () => {
const result = {
code: 1,
stderr: [
'node-pty',
'@orca/windows-registry',
'@orca/windows-security',
'@vscode/windows-process-tree'
]
.map((name) => `${name}: unavailable`)
.join('\n')
}
assertNativeColdProbe(result)
assert.throws(() =>
assertNativeColdProbe({ ...result, stderr: result.stderr.replace('node-pty:', 'missing:') })
)
assertNoHeaderDownloads({ stdout: '', stderr: 'gyp info find VS using VS2026' })
assert.throws(
() =>
assertNoHeaderDownloads({
stdout: '',
stderr: 'gyp http GET https://nodejs.org/header.tar.gz'
}),
/downloaded/
)
})
test('uses case-insensitive environment comparisons and never publishes values', () => {
assert.equal(
environmentReceipt(original).hash,
environmentReceipt({ PATH: 'unchanged', token: 'private' }).hash
)
assert.deepEqual(
changedEnvironmentNames(original, {
PATH: 'unchanged',
TOKEN: 'private',
VCINSTALLDIR: 'compiler'
}),
['vcinstalldir']
)
assert(!JSON.stringify(parent).includes('private'))
assert.throws(() => environmentReceipt({ PATH: 'first', Path: 'second' }), /Duplicate/)
})
test('writes an empty warm trace and observes calls without changing execution', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-test-'))
try {
const output = join(directory, 'trace.json')
const result = { code: 0, stdout: 'original stdout', stderr: '', timedOut: false }
const seen = []
const api = {
runProcessSync: (spec) => {
seen.push(spec)
return result
}
}
installNativeBenchmarkObserver(api, output, original)
assert.deepEqual(JSON.parse(readFileSync(output, 'utf8')).calls, [])
const spec = { program: 'vswhere.exe', args: ['-latest'], stdio: 'pipe' }
assert.equal(api.runProcessSync(spec), result)
assert.deepEqual(seen, [spec])
const value = JSON.parse(readFileSync(output, 'utf8'))
assert.equal(value.calls[0].phase, 'discovery')
assert.equal(value.calls[0].stdoutBytes, result.stdout.length)
assert.equal(value.parentBefore.hash, value.parentAfter.hash)
assert(!readFileSync(output, 'utf8').includes('original stdout'))
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
test('preload observes the CJS process-host export imported by production ESM code', async () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-native-observer-esm-test-'))
try {
const trace = join(directory, 'trace.json')
const program = `import { runProcessSync } from '@orca/process-host'; const value = runProcessSync({program:process.execPath,args:['-e','process.stdout.write("proof")']}); if(value.stdout !== 'proof') process.exit(2);`
const result = await runProcess({
program: process.execPath,
args: [
'--import',
pathToFileURL(join(import.meta.dirname, 'windows-native-benchmark-observer.mjs')).href,
'--input-type=module',
'-e',
program
],
cwd: join(import.meta.dirname, '../..'),
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1', ORCA_NATIVE_BENCHMARK_TRACE: trace }
})
assert.equal(result.code, 0, result.stderr)
const value = JSON.parse(readFileSync(trace, 'utf8'))
assert.equal(value.calls.length, 1)
assert.equal(value.calls[0].code, 0)
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
test('manual workflow freezes source and limits Windows occupancy without changing a PR gate', () => {
const workflow = parse(
readFileSync(
join(import.meta.dirname, '../../.github/workflows/windows-native-preparation-benchmark.yml'),
'utf8'
)
)
assert.deepEqual(Object.keys(workflow.on), ['workflow_dispatch'])
assert.equal(workflow.jobs.compare.strategy['max-parallel'], 2)
assert.deepEqual(workflow.jobs.compare.strategy.matrix.os, ['windows-2022', 'windows-11-arm'])
assert.equal(workflow.jobs.compare.steps[0].with.ref, '${{ inputs.source_sha }}')
const install = workflow.jobs.compare.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
assert.equal(install.with['native-runtime'], 'none')
assert.equal(install.with['node-version'], '24.21.0')
assert.equal(install.with['cache-pnpm-store'], 'false')
assert.equal(workflow.jobs.compare.steps.at(-1).if, 'always()')
assert(!Object.hasOwn(workflow.jobs, 'verify'))
})
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { classifyPrJobs } from './pr-code-change-scope.mjs'
import { classifyNodeServerChanges } from './node-server-change-scope.mjs'
import { nodeServerQualification } from './node-server-qualification.mjs'
const toolchainSources = [
'config/scripts/initialize-windows-native-toolchain.mjs',
'config/scripts/capture-windows-native-environment.cmd'
]
describe('Windows native toolchain CI routing', () => {
it.each(toolchainSources)(
'prepares native caches and packages for a change to %s alone',
(file) => {
expect(classifyPrJobs([file])).toMatchObject({
should_run: true,
native_cache_changed: true,
package: true,
package_windows: true
})
}
)
it.each(toolchainSources)(
'selects both Windows server runners for a change to %s alone',
async (file) => {
const collect = vi.fn(async () => new Set())
for (const options of [
{},
{ deferGraph: true },
{ modifiedFiles: [file] },
{ deferGraph: true, modifiedFiles: [file] }
]) {
const scope = await classifyNodeServerChanges([file], collect, options)
expect(scope).toEqual({ shouldRun: true, reason: `Build or CI input changed: ${file}` })
expect(nodeServerQualification([file], scope)).toEqual({
qualification: false,
runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm']
})
}
expect(collect).not.toHaveBeenCalled()
}
)
it('preserves unrelated build-script routing', async () => {
const file = 'config/scripts/unrelated-toolchain.mjs'
expect(classifyPrJobs([file])).toMatchObject({
test: true,
native_cache_changed: false,
package: false,
package_windows: false
})
expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(false)
})
})
-17
View File
@@ -5,13 +5,9 @@
# Regenerate/prune: pnpm check:ts-nocheck-ratchet --prune (removes stale entries only)
src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts
src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts
src/main/runtime/orca-runtime-attach-window.ts
src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-build-pty-terminal-summary.ts
src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts
src/main/runtime/orca-runtime-close-mobile-session-tab.ts
src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts
src/main/runtime/orca-runtime-collect-mobile-visible-graph-changed-worktrees.ts
src/main/runtime/orca-runtime-controller-knows-pty-is-live.ts
src/main/runtime/orca-runtime-create-agent-session.ts
src/main/runtime/orca-runtime-create-mobile-session-terminal.ts
@@ -26,29 +22,23 @@ src/main/runtime/orca-runtime-focus-terminal.ts
src/main/runtime/orca-runtime-get-agent-session-execution-namespace.ts
src/main/runtime/orca-runtime-get-pty-record-for-pane-key.ts
src/main/runtime/orca-runtime-get-runtime-id.ts
src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts
src/main/runtime/orca-runtime-get-worktree-ps.ts
src/main/runtime/orca-runtime-get-worktree-terminal-provisioning-host.ts
src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts
src/main/runtime/orca-runtime-has-live-or-persisted-serve-or-ssh-owned-pty-binding.ts
src/main/runtime/orca-runtime-has-terminals-for-worktree.ts
src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts
src/main/runtime/orca-runtime-invalidate-all-handles-for-pty.ts
src/main/runtime/orca-runtime-linear-commands.ts
src/main/runtime/orca-runtime-list-managed-worktrees.ts
src/main/runtime/orca-runtime-merge-preserved-headless-mobile-session-tabs.ts
src/main/runtime/orca-runtime-notify-ssh-state-changed.ts
src/main/runtime/orca-runtime-on-pty-exit.ts
src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts
src/main/runtime/orca-runtime-persist-headless-terminal-title.ts
src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
src/main/runtime/orca-runtime-prepare-pty-execution-context.ts
src/main/runtime/orca-runtime-preserved-branch-cleanup.ts
src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts
src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts
src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts
src/main/runtime/orca-runtime-refresh-floating-workspace-pty-liveness.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-from-controller.ts
src/main/runtime/orca-runtime-refresh-pty-worktree-records-with-controller-inventory.ts
src/main/runtime/orca-runtime-refuse-unattributed-mobile-session-tab-close.ts
@@ -57,8 +47,6 @@ src/main/runtime/orca-runtime-resolve-browser-network-execution-host-for-worktre
src/main/runtime/orca-runtime-resolve-known-workspace-file-target.ts
src/main/runtime/orca-runtime-resolve-terminal-pane.ts
src/main/runtime/orca-runtime-resolve-terminal-split-source-authority.ts
src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts
src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts
src/main/runtime/orca-runtime-run-create-mobile-session-terminal.ts
src/main/runtime/orca-runtime-runtime-id.ts
src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts
@@ -68,16 +56,11 @@ src/main/runtime/orca-runtime-split-pty-backed-terminal.ts
src/main/runtime/orca-runtime-split-terminal.ts
src/main/runtime/orca-runtime-state-fields.ts
src/main/runtime/orca-runtime-stop-exact-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stop-explicitly-closed-tab-ptys.ts
src/main/runtime/orca-runtime-stop-requested-pty-ids.ts
src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts
src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts
src/main/runtime/orca-runtime-sync-window-graph.ts
src/main/runtime/orca-runtime-terminal-create-deduplication.ts
src/main/runtime/orca-runtime-terminal-drivers.ts
src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts
src/main/runtime/orca-runtime-transition-graph-reload-to-terminal-state.ts
src/main/runtime/orca-runtime-verify-orchestration-compatibility-caller.ts
src/main/runtime/orca-runtime-wait-for-leaf-pty-id.ts
src/main/runtime/orca-runtime-wait-for-mobile-terminal-surface.ts
+6
View File
@@ -54,6 +54,8 @@
"fflate": "0.8.3",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"markdown-it": "^14.3.2",
"markdown-it-footnote": "^4.0.0",
"mermaid": "11.17.2",
"react": "19.2.8",
"react-dom": "19.2.8",
@@ -67,6 +69,7 @@
"react-native-web": "^0.21.3",
"react-native-webview": "13.16.2",
"react-native-worklets": "^0.8.3",
"remend": "1.4.0",
"tweetnacl": "^1.0.3",
"ws": "^8.22.0",
"zod": "~4.6.5",
@@ -74,6 +77,9 @@
},
"devDependencies": {
"@orca/process-host": "link:../src/packages/process-host",
"@types/markdown-it": "14.1.2",
"@types/markdown-it-footnote": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/react": "~19.2.18",
"@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
+105
View File
@@ -275,6 +275,12 @@ importers:
lucide-react-native:
specifier: ^1.14.0
version: 1.14.0(react-native-svg@15.15.5(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
markdown-it:
specifier: ^14.3.2
version: 14.3.2
markdown-it-footnote:
specifier: ^4.0.0
version: 4.0.0
mermaid:
specifier: 11.17.2
version: 11.17.2
@@ -314,6 +320,9 @@ importers:
react-native-worklets:
specifier: ^0.8.3
version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.8))(react@19.2.8)
remend:
specifier: 1.4.0
version: 1.4.0
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
@@ -330,6 +339,15 @@ importers:
'@orca/process-host':
specifier: link:../src/packages/process-host
version: link:../src/packages/process-host
'@types/markdown-it':
specifier: 14.1.2
version: 14.1.2
'@types/markdown-it-footnote':
specifier: ^3.0.4
version: 3.0.4
'@types/mdast':
specifier: ^4.0.4
version: 4.0.4
'@types/react':
specifier: ~19.2.18
version: 19.2.18
@@ -2883,6 +2901,24 @@ packages:
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it-footnote@3.0.4':
resolution: {integrity: sha512-XJ6n+v+2u+2gmYLSHcxyoNT/YrgrKvHuDJQlykFjuxCQCr86P2/fx1V6/0lcKxv5cSIlCaJ6sUcNS3zDI7I+LA==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/markdown-it@14.2.0':
resolution: {integrity: sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
'@types/node@25.6.0':
resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
@@ -5443,6 +5479,9 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
linkify-it@5.0.2:
resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -5514,6 +5553,13 @@ packages:
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
markdown-it-footnote@4.0.0:
resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
markdown-it@14.3.2:
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
hasBin: true
marked@16.4.2:
resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==}
engines: {node: '>= 20'}
@@ -5529,6 +5575,9 @@ packages:
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
memoize-one@5.2.1:
resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==}
@@ -6071,6 +6120,10 @@ packages:
psl@1.15.0:
resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -6281,6 +6334,9 @@ packages:
resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==}
hasBin: true
remend@1.4.0:
resolution: {integrity: sha512-mHiYhhwphs1IgIZmkToKLwJn0U99IjPXuutkA2acrNcG0vlEq2G4TGnd2eT0saIHwh2KjaCpvleHcsd5SAz96A==}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -6840,6 +6896,9 @@ packages:
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
hasBin: true
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -10239,6 +10298,28 @@ snapshots:
'@types/json5@0.0.29': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it-footnote@3.0.4':
dependencies:
'@types/markdown-it': 14.2.0
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/markdown-it@14.2.0':
dependencies:
'@types/linkify-it': 5.0.0
'@types/mdurl': 2.0.0
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@2.0.0': {}
'@types/node@25.6.0':
dependencies:
undici-types: 7.19.2
@@ -13388,6 +13469,10 @@ snapshots:
lines-and-columns@1.2.4: {}
linkify-it@5.0.2:
dependencies:
uc.micro: 2.1.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -13455,6 +13540,17 @@ snapshots:
dependencies:
tmpl: 1.0.5
markdown-it-footnote@4.0.0: {}
markdown-it@14.3.2:
dependencies:
argparse: 2.0.1
entities: 4.5.0
linkify-it: 5.0.2
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 2.1.0
marked@16.4.2: {}
marky@1.3.0: {}
@@ -13463,6 +13559,8 @@ snapshots:
mdn-data@2.0.14: {}
mdurl@2.1.0: {}
memoize-one@5.2.1: {}
memoize-one@6.0.0: {}
@@ -14244,6 +14342,8 @@ snapshots:
dependencies:
punycode: 2.3.1
punycode.js@2.3.1: {}
punycode@2.3.1: {}
pure-rand@6.1.0: {}
@@ -14520,6 +14620,8 @@ snapshots:
dependencies:
jsesc: 3.1.0
remend@1.4.0: {}
require-directory@2.1.1: {}
requires-port@1.0.0: {}
@@ -15102,6 +15204,8 @@ snapshots:
ua-parser-js@1.0.41: {}
uc.micro@2.1.0: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -15408,3 +15512,4 @@ snapshots:
dependencies:
'@types/emscripten': 1.41.5
type-fest: 5.6.0
File diff suppressed because it is too large Load Diff
@@ -4,80 +4,6 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"0c4296f215a4": {
"name": "settings.get#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"settings": {
"defaultTuiAgent": "codex",
"disabledTuiAgents": ["claude"],
"hostSettingOverrides": {},
"prBotAuthorOverrides": ["bot-user"],
"visibleTaskProviders": ["github", "linear"]
}
}
}
}
},
"12e70439f294": {
"name": "repo.list#1",
"ordinal": 1,
@@ -145,18 +71,13 @@
}
}
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"18762ff064ed": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
"value": "host.platform"
"value": "settings.get"
},
{
"name": "params",
@@ -172,10 +93,29 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-3",
"ok": true,
"result": {
"settings": {
"defaultTuiAgent": "codex",
"disabledTuiAgents": ["claude"],
"hostSettingOverrides": {},
"prBotAuthorOverrides": ["bot-user"],
"visibleTaskProviders": ["github", "linear"]
}
}
}
}
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1b23dab83fcf": {
"name": "repo.list#1",
"ordinal": 1,
@@ -210,23 +150,44 @@
}
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"44136fa355b3": {},
"4526bff18b74": {
@@ -310,30 +271,39 @@
}
}
},
"53226cb82ec3": {
"name": "hostPlatform",
"ordinal": 15,
"value": "linux"
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"624ec4e5082c": {
"name": "repo.list#1",
@@ -370,10 +340,66 @@
}
}
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"747c556da67a": {
"name": "repo.list#1",
@@ -406,13 +432,31 @@
}
}
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"8a72d3d14d44": {
"name": "repo.list#1",
@@ -449,21 +493,14 @@
}
}
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -487,54 +524,24 @@
"startedAt": 0
}
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 13,
"value": "linux"
},
"bea13a4a2e87": {
"name": "hostLabelById",
"ordinal": 14,
"value": [["ssh:ssh-1", "SSH"]]
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"c752e67787f9": {
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d68475063b62": {
"name": "repo.list#1",
@@ -573,6 +580,11 @@
}
}
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"dae756300589": {
"name": "repo.list#1",
"ordinal": 1,
@@ -605,6 +617,19 @@
}
}
},
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
@@ -647,24 +672,6 @@
"ok": false
}
}
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
}
},
"recording": {
@@ -673,34 +680,32 @@
{
"id": "settings-repo-metadata-fulfilled.normal:settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settings-repo-metadata-fulfilled.normal:settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,43 +4,9 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"0c4296f215a4": {
"18762ff064ed": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 6,
"args": [
{
"name": "method",
@@ -83,13 +49,13 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "host.platform"
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
@@ -105,33 +71,28 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2053629911e7": {
"35f30397fcb2": {
"name": "repo.list#2",
"ordinal": 17,
"ordinal": 15,
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"4526bff18b74": {
"name": "repo.list#1",
"ordinal": 1,
@@ -179,59 +140,135 @@
}
}
},
"53226cb82ec3": {
"name": "hostPlatform",
"ordinal": 15,
"value": "linux"
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -255,23 +292,9 @@
"startedAt": 0
}
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"ba8a19e3e2b1": {
"status": "pending",
"startedAt": 60000
},
"bea13a4a2e87": {
"name": "hostLabelById",
"ordinal": 14,
"value": [["ssh:ssh-1", "SSH"]]
},
"c402c05b8b96": {
"9157a9ad3cbd": {
"name": "repo.list#2",
"ordinal": 16,
"ordinal": 14,
"args": [
{
"name": "method",
@@ -295,44 +318,46 @@
"startedAt": 60000
}
},
"c752e67787f9": {
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 13,
"value": "linux"
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"ba8a19e3e2b1": {
"status": "pending",
"startedAt": 60000
},
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -349,24 +374,6 @@
"value": {
"$rpc": "undefined"
}
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
}
},
"recording": {
@@ -375,79 +382,73 @@
{
"id": "settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
{
"id": "cache-hit",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a",
"cached-query": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
{
"id": "cache-warm",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a",
"cached-query": "eb79a9b3682a",
"warm-query": "f43afee17848"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
@@ -456,17 +457,17 @@
"observation": {
"sender": [
"4526bff18b74",
"c752e67787f9",
"0c4296f215a4",
"03c9a2ba60b9",
"c402c05b8b96"
"299d571abf13",
"18762ff064ed",
"5248b37ec085",
"9157a9ad3cbd"
],
"payloads": [
"19b2979d7fc2",
"668a7e313975",
"b428bce85ee7",
"36a96ddbd396",
"2053629911e7"
"c1246f5792ee",
"8b618db22116",
"c1d6fc758acc",
"35f30397fcb2"
],
"settlements": {
"mount": "eb79a9b3682a",
@@ -475,15 +476,13 @@
"warm-query": "f43afee17848",
"expired-query": "ba8a19e3e2b1"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
}
@@ -4,43 +4,9 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"0c4296f215a4": {
"18762ff064ed": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 6,
"args": [
{
"name": "method",
@@ -83,13 +49,13 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "host.platform"
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
@@ -105,28 +71,23 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"4526bff18b74": {
"name": "repo.list#1",
"ordinal": 1,
@@ -174,59 +135,135 @@
}
}
},
"53226cb82ec3": {
"name": "hostPlatform",
"ordinal": 15,
"value": "linux"
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -250,54 +287,42 @@
"startedAt": 0
}
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 13,
"value": "linux"
},
"bea13a4a2e87": {
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 14,
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"c752e67787f9": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -306,24 +331,6 @@
"value": {
"$rpc": "undefined"
}
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
}
},
"recording": {
@@ -332,34 +339,32 @@
{
"id": "settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
}
@@ -4,27 +4,68 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"0fd72e1aba93": {
"desktopRepos": [
{
"badgeColor": "#123456",
"displayName": "Lucide",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1",
"repoIcon": {
"name": "Rocket",
"type": "lucide"
}
},
{
"displayName": "Emoji",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2",
"repoIcon": {
"emoji": "🐳",
"type": "emoji"
}
},
{
"displayName": "Avatar",
"executionHostId": "ssh:ssh-1",
"id": "repo-3",
"path": "repo-3",
"repoIcon": {
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
}
],
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "darwin",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"],
["repo-3", "ssh:ssh-1"]
]
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"19bf6d2469a6": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Lucide", "#123456"],
["Emoji", "#f97316"],
["Avatar", "#f97316"]
]
"1c76208c9beb": {
"name": "hostPlatform",
"ordinal": 13,
"value": "darwin"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "host.platform"
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
@@ -40,70 +81,26 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"25b44bca9e45": {
"name": "hostPlatform",
"ordinal": 15,
"value": "darwin"
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
},
"6338d57f282f": {
"repoColorsByName": [
["Lucide", "#123456"],
["Emoji", "#f97316"],
["Avatar", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"],
["repo-3", "ssh:ssh-1"]
],
"repoIconsByName": [
[
"Lucide",
{
"name": "Rocket",
"type": "lucide"
}
],
[
"Emoji",
{
"emoji": "🐳",
"type": "emoji"
}
],
[
"Avatar",
{
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
]
],
"repoIdsByName": [
["Lucide", "repo-1"],
["Emoji", "repo-2"],
["Avatar", "repo-3"]
]
},
"64f5522cc303": {
"466c0317a24a": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 6,
"args": [
{
"name": "method",
@@ -139,36 +136,40 @@
}
}
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"6bc5e5b7a259": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"],
["repo-3", "ssh:ssh-1"]
]
},
"77cbfd1f087c": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Lucide", "repo-1"],
["Emoji", "repo-2"],
["Avatar", "repo-3"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -192,6 +193,86 @@
"startedAt": 0
}
},
"910617d262f4": {
"desktopRepos": [
{
"badgeColor": "#123456",
"displayName": "Lucide",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1",
"repoIcon": {
"name": "Rocket",
"type": "lucide"
}
},
{
"displayName": "Emoji",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2",
"repoIcon": {
"emoji": "🐳",
"type": "emoji"
}
},
{
"displayName": "Avatar",
"executionHostId": "ssh:ssh-1",
"id": "repo-3",
"path": "repo-3",
"repoIcon": {
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"],
["repo-3", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"9bc91d02ce24": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "darwin"
}
}
}
},
"a9add325bb0a": {
"name": "repo.list#1",
"ordinal": 1,
@@ -259,128 +340,34 @@
}
}
},
"ae27a5b525fa": {
"name": "repoIconsByName",
"b4ad28ffe331": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
[
"Lucide",
{
"name": "Rocket",
"type": "lucide"
}
],
[
"Emoji",
{
"emoji": "🐳",
"type": "emoji"
}
],
[
"Avatar",
{
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
]
]
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"bea13a4a2e87": {
"name": "hostLabelById",
"ordinal": 14,
"value": [["ssh:ssh-1", "SSH"]]
},
"c020f709d835": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "darwin",
"repoColorsByName": [
["Lucide", "#123456"],
["Emoji", "#f97316"],
["Avatar", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"],
["repo-3", "ssh:ssh-1"]
],
"repoIconsByName": [
[
"Lucide",
{
"name": "Rocket",
"type": "lucide"
}
],
[
"Emoji",
{
"emoji": "🐳",
"type": "emoji"
}
],
[
"Avatar",
{
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
]
],
"repoIdsByName": [
["Lucide", "repo-1"],
["Emoji", "repo-2"],
["Avatar", "repo-3"]
]
},
"c752e67787f9": {
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -390,39 +377,44 @@
"$rpc": "undefined"
}
},
"ee8e16df5187": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
"f9217d79f53e": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
"badgeColor": "#123456",
"displayName": "Lucide",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1",
"repoIcon": {
"name": "Rocket",
"type": "lucide"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
"displayName": "Emoji",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2",
"repoIcon": {
"emoji": "🐳",
"type": "emoji"
}
},
{
"displayName": "Avatar",
"executionHostId": "ssh:ssh-1",
"id": "repo-3",
"path": "repo-3",
"repoIcon": {
"label": "acme/orca",
"source": "github",
"src": "https://github.com/acme.png?size=64",
"type": "image"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "darwin"
}
}
}
]
}
},
"recording": {
@@ -431,34 +423,32 @@
{
"id": "settings-pending",
"observation": {
"sender": ["a9add325bb0a", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["a9add325bb0a", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6338d57f282f",
"effects": ["19bf6d2469a6", "ae27a5b525fa", "77cbfd1f087c", "6bc5e5b7a259"]
"state": "910617d262f4",
"effects": ["f9217d79f53e", "b4ad28ffe331"]
}
},
{
"id": "settled",
"observation": {
"sender": ["a9add325bb0a", "c752e67787f9", "64f5522cc303", "ee8e16df5187"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["a9add325bb0a", "299d571abf13", "466c0317a24a", "9bc91d02ce24"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "c020f709d835",
"state": "0fd72e1aba93",
"effects": [
"19bf6d2469a6",
"ae27a5b525fa",
"77cbfd1f087c",
"6bc5e5b7a259",
"6063e534d9db",
"bea13a4a2e87",
"25b44bca9e45"
"f9217d79f53e",
"b4ad28ffe331",
"e2881f748a46",
"d9c464d0dc78",
"1c76208c9beb"
]
}
}
@@ -4,43 +4,14 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
"00d007a90b2c": {
"name": "hostPlatform",
"ordinal": 26,
"value": "linux"
},
"0c4296f215a4": {
"18762ff064ed": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 6,
"args": [
{
"name": "method",
@@ -78,19 +49,14 @@
}
}
},
"0ef3d910f42e": {
"name": "hostLabelById",
"ordinal": 29,
"value": [["ssh:ssh-1", "SSH"]]
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"1e4a3aa71af5": {
"name": "host.platform#2",
"ordinal": 20,
"args": [
{
"name": "method",
@@ -110,37 +76,79 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-8",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"2053629911e7": {
"name": "repo.list#2",
"ordinal": 17,
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"306fcf2b8536": {
"name": "desktopRepos",
"ordinal": 16,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"376d3874239c": {
"name": "repoIconsByName",
"ordinal": 19,
"value": []
"35f30397fcb2": {
"name": "repo.list#2",
"ordinal": 15,
"json": "{\"id\":\"frame-5\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"4526bff18b74": {
"name": "repo.list#1",
@@ -189,44 +197,9 @@
}
}
},
"455855a85ab4": {
"name": "settings.get#2",
"ordinal": 23,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "settings refused"
},
"id": "frame-7",
"ok": false
}
}
},
"4771e3dbbbcf": {
"name": "host.platform#2",
"ordinal": 24,
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
@@ -250,7 +223,7 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-8",
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
@@ -258,64 +231,106 @@
}
}
},
"53226cb82ec3": {
"name": "hostPlatform",
"ordinal": 15,
"value": "linux"
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"69f575e15e15": {
"name": "settings.get#2",
"ordinal": 22,
"json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"8c5c812580ad": {
"name": "host.platform#2",
"ordinal": 27,
"json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -339,78 +354,33 @@
"startedAt": 0
}
},
"a67416f78b93": {
"name": "hostHealthById",
"ordinal": 28,
"value": []
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"bea13a4a2e87": {
"name": "hostLabelById",
"ordinal": 14,
"value": [["ssh:ssh-1", "SSH"]]
},
"c497d2d846c9": {
"name": "ssh.listTargetSummaries#2",
"ordinal": 25,
"json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c752e67787f9": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"ceef481be25c": {
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 30,
"ordinal": 13,
"value": "linux"
},
"d51254d2537f": {
"name": "settings.get#2",
"ordinal": 26,
"json": "{\"id\":\"frame-7\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"d569b5b2500f": {
"9b915e5b8831": {
"name": "host.platform#2",
"ordinal": 23,
"json": "{\"id\":\"frame-8\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"a465a07eb4b0": {
"name": "hostLabelById",
"ordinal": 25,
"value": [["ssh:ssh-1", "SSH"]]
},
"af3366c70550": {
"name": "ssh.listTargetSummaries#2",
"ordinal": 22,
"ordinal": 21,
"json": "{\"id\":\"frame-6\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"bc879379ce13": {
"name": "ssh.listTargetSummaries#2",
"ordinal": 18,
"args": [
{
"name": "method",
@@ -447,17 +417,63 @@
}
}
},
"da7cf9752395": {
"name": "repoColorsByName",
"ordinal": 18,
"bd0d18cd2160": {
"name": "repoHostIdByRepoId",
"ordinal": 17,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"df78d2202264": {
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"df3bf9bbb7bf": {
"name": "repo.list#2",
"ordinal": 16,
"ordinal": 14,
"args": [
{
"name": "method",
"value": "repo.list"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e3e3d558d06c": {
"name": "repo.list#2",
"ordinal": 14,
"args": [
{
"name": "method",
@@ -502,55 +518,26 @@
}
}
},
"ea2ced0895f3": {
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 21,
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
"e7d590a1a17e": {
"name": "hostHealthById",
"ordinal": 24,
"value": []
},
"eb885d2ceee5": {
"name": "repoIdsByName",
"ordinal": 20,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"fa585aceeec2": {
"name": "repo.list#2",
"ordinal": 16,
"eab4940c8ca3": {
"name": "settings.get#2",
"ordinal": 19,
"args": [
{
"name": "method",
"value": "repo.list"
"value": "settings.get"
},
{
"name": "params",
@@ -566,8 +553,25 @@
}
],
"settlement": {
"status": "pending",
"startedAt": 0
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "settings refused"
},
"id": "frame-7",
"ok": false
}
}
},
"eb79a9b3682a": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"$rpc": "undefined"
}
}
},
@@ -577,55 +581,51 @@
{
"id": "settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
{
"id": "data-present",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "0c4296f215a4", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "18762ff064ed", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
@@ -634,32 +634,30 @@
"observation": {
"sender": [
"4526bff18b74",
"c752e67787f9",
"0c4296f215a4",
"03c9a2ba60b9",
"fa585aceeec2"
"299d571abf13",
"18762ff064ed",
"5248b37ec085",
"df3bf9bbb7bf"
],
"payloads": [
"19b2979d7fc2",
"668a7e313975",
"b428bce85ee7",
"36a96ddbd396",
"2053629911e7"
"c1246f5792ee",
"8b618db22116",
"c1d6fc758acc",
"35f30397fcb2"
],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a",
"reload": "9270aeb7d9c6"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
},
@@ -668,45 +666,41 @@
"observation": {
"sender": [
"4526bff18b74",
"c752e67787f9",
"0c4296f215a4",
"03c9a2ba60b9",
"df78d2202264",
"d569b5b2500f",
"455855a85ab4",
"4771e3dbbbcf"
"299d571abf13",
"18762ff064ed",
"5248b37ec085",
"e3e3d558d06c",
"bc879379ce13",
"eab4940c8ca3",
"1e4a3aa71af5"
],
"payloads": [
"19b2979d7fc2",
"668a7e313975",
"b428bce85ee7",
"36a96ddbd396",
"2053629911e7",
"c497d2d846c9",
"d51254d2537f",
"8c5c812580ad"
"c1246f5792ee",
"8b618db22116",
"c1d6fc758acc",
"35f30397fcb2",
"af3366c70550",
"69f575e15e15",
"9b915e5b8831"
],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a",
"reload": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3",
"da7cf9752395",
"376d3874239c",
"eb885d2ceee5",
"ea2ced0895f3",
"a67416f78b93",
"0ef3d910f42e",
"ceef481be25c"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77",
"306fcf2b8536",
"bd0d18cd2160",
"e7d590a1a17e",
"a465a07eb4b0",
"00d007a90b2c"
]
}
}
@@ -4,83 +4,18 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2e2ca6f7dc3f": {
"name": "settings.get#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "settings.get"
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
@@ -100,28 +35,19 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "settings refused"
},
"id": "frame-3",
"ok": false
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"4526bff18b74": {
"name": "repo.list#1",
"ordinal": 1,
@@ -169,59 +95,170 @@
}
}
},
"53226cb82ec3": {
"name": "hostPlatform",
"ordinal": 15,
"value": "linux"
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
"61b27dbdf3a7": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"error": {
"code": "refused",
"message": "settings refused"
},
"id": "frame-3",
"ok": false
}
}
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"96e0c54d86b0": {
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 8,
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -245,54 +282,42 @@
"startedAt": 0
}
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 13,
"value": "linux"
},
"bea13a4a2e87": {
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 14,
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"c752e67787f9": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -301,24 +326,6 @@
"value": {
"$rpc": "undefined"
}
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
}
},
"recording": {
@@ -327,34 +334,32 @@
{
"id": "settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "2e2ca6f7dc3f", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "61b27dbdf3a7", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
}
@@ -9,9 +9,9 @@
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"1e027d0127f3": {
"name": "host.platform#1",
"ordinal": 9,
"ordinal": 7,
"args": [
{
"name": "method",
@@ -30,35 +30,118 @@
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
}
},
"1f909a505b54": {
"name": "hostPlatform",
"ordinal": 13,
"value": {
"$rpc": "null"
}
},
"2207323d0bd5": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
}
},
"302f82cb9a68": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
}
},
"35960f0ff1b9": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2bd489a9fa29": {
"repoColorsByName": [
["Remote", "#f97316"],
["Remote folder", "#ec4899"]
],
"repoHostIdByRepoId": [
["repo-1", "ssh:ssh-1"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Remote", "repo-1"],
["Remote folder", "repo-2"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"379c0eb6dc14": {
"name": "hostLabelById",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
"value": []
},
"4b6956e45bac": {
"name": "repo.list#1",
@@ -105,41 +188,27 @@
}
}
},
"4bf1ed33de14": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
"6e0383a0869e": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-1",
"path": "repo-1"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
"displayName": "Remote folder",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
}
]
},
"53dea128b181": {
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 9,
"ordinal": 7,
"args": [
{
"name": "method",
@@ -159,33 +228,22 @@
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
"status": "pending",
"startedAt": 0
}
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"6a227ad27189": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
"value": "settings.get"
},
{
"name": "params",
@@ -209,96 +267,48 @@
"status": "pending",
"startedAt": 0
},
"937107a86348": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Remote", "repo-1"],
["Remote folder", "repo-2"]
]
},
"96e0c54d86b0": {
"name": "settings.get#1",
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 8,
"args": [
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"d23c5c4cde9a": {
"desktopRepos": [
{
"name": "method",
"value": "settings.get"
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-1",
"path": "repo-1"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
"displayName": "Remote folder",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"9d20695fdb34": {
"name": "hostPlatform",
"ordinal": 15,
"value": {
"$rpc": "null"
}
},
"a086cfd03185": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
"repoHostIdByRepoId": [
["repo-1", "ssh:ssh-1"],
["repo-2", "ssh:ssh-1"]
]
},
"b428bce85ee7": {
"name": "settings.get#1",
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"daecdc0e521b": {
"name": "hostLabelById",
"ordinal": 14,
"value": []
},
"dd3998116e8e": {
"name": "settings.get#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "rejected",
"startedAt": 0,
"settledAt": 0,
"error": {
"category": "Error",
"message": "Connection closed",
"isRpcDeliveryUnknown": true
}
}
"e7dd8e429107": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "ssh:ssh-1"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -307,14 +317,6 @@
"value": {
"$rpc": "undefined"
}
},
"fc0785cc0b08": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Remote", "#f97316"],
["Remote folder", "#ec4899"]
]
}
},
"recording": {
@@ -323,34 +325,32 @@
{
"id": "single-host-without-label-lookups",
"observation": {
"sender": ["4b6956e45bac", "6a227ad27189", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4b6956e45bac", "35960f0ff1b9", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "2bd489a9fa29",
"effects": ["fc0785cc0b08", "1ff13ee8621c", "937107a86348", "a086cfd03185"]
"state": "d23c5c4cde9a",
"effects": ["6e0383a0869e", "e7dd8e429107"]
}
},
{
"id": "cleanup",
"observation": {
"sender": ["4b6956e45bac", "4bf1ed33de14", "dd3998116e8e", "53dea128b181"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4b6956e45bac", "2207323d0bd5", "302f82cb9a68", "1e027d0127f3"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "2bd489a9fa29",
"state": "d23c5c4cde9a",
"effects": [
"fc0785cc0b08",
"1ff13ee8621c",
"937107a86348",
"a086cfd03185",
"6063e534d9db",
"daecdc0e521b",
"9d20695fdb34"
"6e0383a0869e",
"e7dd8e429107",
"e2881f748a46",
"379c0eb6dc14",
"1f909a505b54"
]
}
}
@@ -4,13 +4,18 @@
"family": "settings.repo-metadata",
"namedDeltas": [],
"values": {
"03c9a2ba60b9": {
"name": "host.platform#1",
"ordinal": 9,
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"299d571abf13": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 5,
"args": [
{
"name": "method",
"value": "host.platform"
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
@@ -30,63 +35,19 @@
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"id": "frame-2",
"ok": true,
"result": {
"platform": "linux"
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
},
"19b2979d7fc2": {
"name": "repo.list#1",
"ordinal": 2,
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"repo.list\"}"
},
"1a160fc2d137": {
"name": "host.platform#1",
"ordinal": 9,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"1ff13ee8621c": {
"name": "repoIconsByName",
"ordinal": 4,
"value": []
},
"2f1b95eac8f1": {
"name": "repoColorsByName",
"ordinal": 3,
"value": [
["Local", "#6366f1"],
["Remote", "#f97316"]
]
},
"36a96ddbd396": {
"name": "host.platform#1",
"ordinal": 12,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"4526bff18b74": {
"name": "repo.list#1",
"ordinal": 1,
@@ -134,14 +95,180 @@
}
}
},
"53226cb82ec3": {
"5248b37ec085": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-4",
"ok": true,
"result": {
"platform": "linux"
}
}
}
},
"661b4721aec6": {
"name": "desktopRepos",
"ordinal": 3,
"value": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
]
},
"68916f125c3c": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"6d43117882f3": {
"desktopRepos": [
{
"displayName": "Local",
"executionHostId": "local",
"id": "repo-1",
"path": "repo-1"
},
{
"displayName": "Remote",
"executionHostId": "ssh:ssh-1",
"id": "repo-2",
"path": "repo-2"
}
],
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"7c508088602c": {
"name": "host.platform#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "host.platform"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"8b618db22116": {
"name": "settings.get#1",
"ordinal": 9,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"902c0b2ed3ce": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"91e7f4ef9a77": {
"name": "hostPlatform",
"ordinal": 15,
"ordinal": 13,
"value": "linux"
},
"6021c58b2fd2": {
"name": "settings.get#1",
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"c1246f5792ee": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 8,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"c1d6fc758acc": {
"name": "host.platform#1",
"ordinal": 10,
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"host.platform\"}"
},
"ce32fe5b8377": {
"name": "settings.get#1",
"ordinal": 6,
"args": [
{
"name": "method",
@@ -171,125 +298,23 @@
}
}
},
"6063e534d9db": {
"name": "hostHealthById",
"ordinal": 13,
"value": []
},
"6134b73f18d0": {
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"668a7e313975": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 10,
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"ssh.listTargetSummaries\"}"
},
"7e772c1c111d": {
"name": "repoIdsByName",
"ordinal": 5,
"value": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
},
"8ef3b348c179": {
"name": "repoHostIdByRepoId",
"ordinal": 6,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"9270aeb7d9c6": {
"status": "pending",
"startedAt": 0
},
"96e0c54d86b0": {
"name": "settings.get#1",
"ordinal": 8,
"args": [
{
"name": "method",
"value": "settings.get"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "pending",
"startedAt": 0
}
},
"b428bce85ee7": {
"name": "settings.get#1",
"ordinal": 11,
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"settings.get\"}"
},
"bea13a4a2e87": {
"d9c464d0dc78": {
"name": "hostLabelById",
"ordinal": 14,
"ordinal": 12,
"value": [["ssh:ssh-1", "SSH"]]
},
"c752e67787f9": {
"name": "ssh.listTargetSummaries#1",
"ordinal": 7,
"args": [
{
"name": "method",
"value": "ssh.listTargetSummaries"
},
{
"name": "params",
"value": {
"$rpc": "absent"
}
},
{
"name": "options",
"value": {
"$rpc": "absent"
}
}
],
"settlement": {
"status": "fulfilled",
"startedAt": 0,
"settledAt": 0,
"value": {
"id": "frame-2",
"ok": true,
"result": {
"targets": [
{
"id": "ssh-1",
"label": "SSH"
}
]
}
}
}
"e2881f748a46": {
"name": "hostHealthById",
"ordinal": 11,
"value": []
},
"e49da3e1abca": {
"name": "repoHostIdByRepoId",
"ordinal": 4,
"value": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
]
},
"eb79a9b3682a": {
"status": "fulfilled",
@@ -298,24 +323,6 @@
"value": {
"$rpc": "undefined"
}
},
"f4667dda0187": {
"hostHealthById": [],
"hostLabelById": [["ssh:ssh-1", "SSH"]],
"hostPlatform": "linux",
"repoColorsByName": [
["Local", "#6366f1"],
["Remote", "#f97316"]
],
"repoHostIdByRepoId": [
["repo-1", "local"],
["repo-2", "ssh:ssh-1"]
],
"repoIconsByName": [],
"repoIdsByName": [
["Local", "repo-1"],
["Remote", "repo-2"]
]
}
},
"recording": {
@@ -324,34 +331,32 @@
{
"id": "settings-pending",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "96e0c54d86b0", "1a160fc2d137"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "902c0b2ed3ce", "7c508088602c"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "9270aeb7d9c6"
},
"state": "6134b73f18d0",
"effects": ["2f1b95eac8f1", "1ff13ee8621c", "7e772c1c111d", "8ef3b348c179"]
"state": "68916f125c3c",
"effects": ["661b4721aec6", "e49da3e1abca"]
}
},
{
"id": "settled",
"observation": {
"sender": ["4526bff18b74", "c752e67787f9", "6021c58b2fd2", "03c9a2ba60b9"],
"payloads": ["19b2979d7fc2", "668a7e313975", "b428bce85ee7", "36a96ddbd396"],
"sender": ["4526bff18b74", "299d571abf13", "ce32fe5b8377", "5248b37ec085"],
"payloads": ["19b2979d7fc2", "c1246f5792ee", "8b618db22116", "c1d6fc758acc"],
"settlements": {
"mount": "eb79a9b3682a",
"load": "eb79a9b3682a"
},
"state": "f4667dda0187",
"state": "6d43117882f3",
"effects": [
"2f1b95eac8f1",
"1ff13ee8621c",
"7e772c1c111d",
"8ef3b348c179",
"6063e534d9db",
"bea13a4a2e87",
"53226cb82ec3"
"661b4721aec6",
"e49da3e1abca",
"e2881f748a46",
"d9c464d0dc78",
"91e7f4ef9a77"
]
}
}
@@ -62,9 +62,10 @@ describe('MobileMarkdown file links', () => {
expect(pressables(tree)).toEqual([])
})
it('preserves nested labels and empty image labels', () => {
it('links the inner label of a nested bracket, and names an empty image label', () => {
const tree = render('[[nested](https://example.com) ![](https://example.com/image)')
pressByText(tree, '[nested')
expect(flattenText(tree.root)).toBe('[nested image')
pressByText(tree, 'nested')
expect(openURL).toHaveBeenLastCalledWith('https://example.com')
pressByText(tree, 'image')
expect(openURL).toHaveBeenLastCalledWith('https://example.com/image')
@@ -0,0 +1,164 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
const openURL = vi.fn((_url: string) => Promise.resolve())
vi.mock('react-native', () => ({
Linking: { openURL: (url: string) => openURL(url) },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
openURL.mockClear()
})
function render(content: string, growing = false): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content, growing }))
})
return renderer!
}
function flatten(node: ReactTestInstance | string): string {
return typeof node === 'string' ? node : node.children.map(flatten).join('')
}
const isText = (node: ReactTestInstance): boolean => String(node.type) === 'Text'
/** Every Text not inside another Text, in reading order: what the reader sees, block by block. */
function texts(tree: ReactTestRenderer): string[] {
return tree.root
.findAll((node) => isText(node) && !(node.parent && hasTextAncestor(node.parent)))
.map(flatten)
}
function hasTextAncestor(node: ReactTestInstance): boolean {
return isText(node) || (node.parent !== null && hasTextAncestor(node.parent))
}
function press(tree: ReactTestRenderer, label: string): void {
const target = tree.root.find(
(node) => isText(node) && typeof node.props.onPress === 'function' && flatten(node) === label
)
act(() => target.props.onPress())
}
describe('MobileMarkdown rendering', () => {
it.each([
[
'C1: nested lists keep their own markers and numbers',
'1. **Frontend**\n - React\n - Vite\n2. Backend',
['1.', 'Frontend', '-', 'React', '-', 'Vite', '2.', 'Backend']
],
[
'C1: blank lines keep one list, and a list cut short keeps its numbers',
'1. Install\n\n Pulls in everything.\n\n2. Build\n\nDone.\n\n3. Ship',
['1.', 'Install', 'Pulls in everything.', '2.', 'Build', 'Done.', '3.', 'Ship']
],
[
'C1: a wrapped item stays one item',
'- a long item\nwrapped at sixty\n- next',
['-', 'a long item\nwrapped at sixty', '-', 'next']
],
[
'C1: a number starting a wrapped line is not a list',
'We counted to\n42. Then we stop.',
['We counted to\n42. Then we stop.']
],
[
'C2: code, links and emphasis inside bold',
'Edit **`file.ts`** per **[docs](https://x.com)**, ***both***, *a **b** c*, **bold _ital_**',
['Edit file.ts per docs, both, a b c, bold ital']
],
[
'C4: a fence with an unusual language tag',
'```c++\nint x;\n```\nAfter.\n\n```ts title="a.ts"\nconst a = 1\n```',
['c++', 'int x;', 'After.', 'ts', 'const a = 1']
],
[
'C5: tilde and four-backtick fences, with HTML inside left alone',
'~~~html\n<div className="a"><span>Hi</span></div>\n~~~\n\n````md\n```js\nx\n```\n````',
['html', '<div className="a"><span>Hi</span></div>', 'md', '```js\nx\n```']
],
[
'C6: a fence under a list step',
'1. Install:\n ```bash\n pnpm i\n ```\n2. Run',
['1.', 'Install:', 'bash', 'pnpm i', '2.', 'Run']
],
[
'C7: a table right under a sentence',
'Here:\n| a | b |\n| --- | --- |\n| 1 | 2 |',
['Here:', 'a', 'b', '1', '2']
],
['C8: short separator rows', '| a | b |\n|:-|-:|\n| 1 | 2 |', ['a', 'b', '1', '2']],
[
'C9: a line break inside a table cell',
'| Flag | Note |\n| --- | --- |\n| -f | Skips checks<br>Use with care |',
['Flag', 'Note', '-f', 'Skips checks\nUse with care']
],
['C14: backslash escapes', '\\*not italic\\* a\\_b a \\| b \\#', ['*not italic* a_b a | b #']],
['C15: lone asterisks', '2 * 3 * 4 and *.ts and *.tsx', ['2 * 3 * 4 and *.ts and *.tsx']],
['C16: inline code holding a backtick', '``a`b``', ['a`b']],
[
'C18: blocks inside a quote',
'> - a\n>\n> ```sh\n> ls\n> ```\n> > inner',
['-', 'a', 'sh', 'ls', 'inner']
],
['C19: underlined headings and closing hashes', 'Title\n=====\n\n## Sub ##', ['Title', 'Sub']],
[
'a README header block reads as its heading',
'<h1 align="center">Orca</h1>\n\nText',
['Orca', 'Text']
]
])('%s', (_name, content, expected) => {
expect(texts(render(content))).toEqual(expected)
})
it('C12: opens a link with brackets in it, and one with a title, at their own address', () => {
const tree = render(
'[wiki](https://en.wikipedia.org/wiki/Foo_(bar)) and [docs](https://x.com/a "The docs")'
)
press(tree, 'wiki')
expect(openURL).toHaveBeenLastCalledWith('https://en.wikipedia.org/wiki/Foo_(bar)')
press(tree, 'docs')
expect(openURL).toHaveBeenLastCalledWith('https://x.com/a')
})
it('C13: opens an angle-bracket link without its brackets', () => {
const tree = render('Open <http://localhost:3000>.')
expect(texts(tree)).toEqual(['Open http://localhost:3000.'])
press(tree, 'http://localhost:3000')
expect(openURL).toHaveBeenLastCalledWith('http://localhost:3000')
})
it('C17: resolves a reference link and draws a footnote', () => {
const tree = render(
'See [the docs][1] and a note[^1].\n\n[1]: https://example.com/docs\n\n[^1]: The note.'
)
expect(texts(tree)).toEqual(['See the docs and a note[1].', '[1]', 'The note.'])
press(tree, 'the docs')
expect(openURL).toHaveBeenLastCalledWith('https://example.com/docs')
})
it('C21: closes open markup while a reply grows, and never links a half-written address', () => {
expect(texts(render('2 * 3 is six.\n\nRead **src/main/ses', true))).toEqual([
'2 * 3 is six.',
'Read src/main/ses'
])
const tree = render('See [config loader](https://exa', true)
expect(texts(tree)).toEqual(['See config loader'])
expect(tree.root.findAll((node) => typeof node.props.onPress === 'function')).toEqual([])
})
})
+1 -58
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
describe('isMobileMermaidLanguage', () => {
it('matches mermaid case-insensitively after trim', () => {
@@ -18,63 +17,7 @@ describe('isMobileMermaidLanguage', () => {
})
})
describe('parseMobileMarkdown', () => {
it('parses mermaid fences as code blocks with language mermaid', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true }
])
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
})
it('marks an unterminated fence as not closed while it streams', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false }
])
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({
closed: true
})
})
it('parses GFM tables into table blocks', () => {
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
{
type: 'table',
headers: ['Name', 'State'],
rows: [['Orca', 'Open']]
}
])
})
it('keeps an escaped pipe inside the cell that escaped it', () => {
expect(parseMobileMarkdown('| Cmd | Note |\n| --- | --- |\n| a \\| b | c |')).toEqual([
{
type: 'table',
headers: ['Cmd', 'Note'],
rows: [['a | b', 'c']]
}
])
})
it('ends a cell at the pipe following an escaped backslash', () => {
expect(parseMobileMarkdown('| A | B |\n| --- | --- |\n| x\\\\|y |')).toEqual([
{
type: 'table',
headers: ['A', 'B'],
rows: [['x\\', 'y']]
}
])
})
it('parses standalone HTTPS images without folding them into paragraphs', () => {
expect(parseMobileMarkdown('![Screenshot](https://example.com/screen.png)')).toEqual([
{
type: 'image',
alt: 'Screenshot',
url: 'https://example.com/screen.png'
}
])
})
describe('normalizeMobileMarkdownPreviewHtml', () => {
it('normalizes common README HTML into readable Markdown preview text', () => {
const normalized = normalizeMobileMarkdownPreviewHtml(`
<h1 align="center">
+27 -366
View File
@@ -1,33 +1,13 @@
import { openExternalLink } from '../platform/external-link'
import { createMarkdownInlineMatcher, type MarkdownInlineMatch } from './markdown-inline-matcher'
import { INLINE_TEXT_SELECTION } from './inline-text-selection'
import { MobileSelectableText } from './MobileSelectableText'
import {
Fragment,
createElement,
createContext,
memo,
useContext,
useMemo,
type ComponentType,
type ReactNode
} from 'react'
import { Pressable, ScrollView, Text as NativeText, View, type TextProps } from 'react-native'
import { memo, useContext, useMemo, type ReactNode } from 'react'
import { Text as NativeText, View } from 'react-native'
import { styles } from './mobile-markdown-styles'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
trimAutolinkTrailingPunctuation
} from './markdown-inline-token-rules'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { useMobileMarkdownBlocks } from './use-mobile-markdown-blocks'
import { renderBlocks, type MarkdownBlockContext } from './mobile-markdown-blocks'
import { MarkdownText } from './mobile-markdown-text'
import { MarkdownTextContext, type MarkdownTextSetup } from './mobile-markdown-text-setup'
import { useMobileMarkdownTree } from './use-mobile-markdown-tree'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
type Props = {
content?: string
@@ -47,188 +27,9 @@ type Props = {
/** Native-chat assistant prose only: renders `::orca-visual{...}` directive lines. Without it,
* a directive line is ordinary text. Must be referentially stable (this component is memoized). */
renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode
}
const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
type MarkdownTextSetup = {
TextComponent: ComponentType<TextProps>
/** Disable native selection only within the Android transcript. */
androidTranscript: boolean
onLongPress?: () => void
}
const MarkdownTextContext = createContext<MarkdownTextSetup>({
TextComponent: NativeText,
androidTranscript: false
})
function MarkdownText(props: TextProps): React.JSX.Element {
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
if (!androidTranscript) {
return createElement(TextComponent, props)
}
// Override selection without changing the nested spans' inherited behavior.
return createElement(TextComponent, {
...props,
...(props.selectable === true ? { selectable: false } : {}),
...(onLongPress && props.onPress ? { onLongPress } : {})
})
}
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
const route = routeMarkdownHref(href)
if (route.kind === 'web') {
// The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside
// the shell's WebView `openURL` resolves without opening anything.
openExternalLink(route.url)
return
}
if (route.kind === 'file' && onOpenFile) {
onOpenFile(route.pathText)
}
}
// Render a plain (non-token) text run, splitting out tappable file paths when
// onOpenFile is provided. Without it, paths stay plain text.
function renderTextRun(
text: string,
keyPrefix: string,
onOpenFile?: (pathText: string) => void
): ReactNode {
if (!onOpenFile) {
return text
}
const segments = detectFilePathSegments(text)
if (segments.length === 1 && segments[0]!.type === 'text') {
return text
}
return segments.map((segment, segmentIndex) => {
if (segment.type === 'file') {
return (
<MarkdownText
key={`${keyPrefix}:${segmentIndex}`}
style={styles.link}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</MarkdownText>
)
}
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
})
}
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern = createMarkdownInlineMatcher(
text,
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g,
true
)
let pendingStart = 0
let match: MarkdownInlineMatch | null
while ((match = pattern.exec())) {
const token = match[0]
// Intraword `_` runs (snake_case, dunder tails) are literal text per
// CommonMark; leaving them unflushed keeps surrounding file paths whole
// for detection in the eventual text run.
if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
// Resume after the opener so real tokens inside the rejected span are still scanned.
pattern.lastIndex = match.index + 1
continue
}
if (match.index > pendingStart) {
parts.push(
renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
)
}
pendingStart = pattern.lastIndex
const key = `${match.index}:${token}`
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(image[2]!, onOpenFile)}
>
{image[1] || 'image'}
</MarkdownText>
)
} else if (link) {
parts.push(
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(link[2]!, onOpenFile)}
>
{link[1]}
</MarkdownText>
)
} else if (/^https?:\/\//i.test(token)) {
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
<MarkdownText
key={key}
style={styles.link}
onPress={() => openMarkdownHref(url, onOpenFile)}
>
{url}
</MarkdownText>
)
if (trailing) {
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
}
} else if (token.startsWith('`')) {
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
parts.push(
<MarkdownText
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
>
{code}
</MarkdownText>
)
} else {
parts.push(
<MarkdownText key={key} style={styles.inlineCode}>
{code}
</MarkdownText>
)
}
} else if (token.startsWith('~~')) {
parts.push(
<MarkdownText key={key} style={styles.strike}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</MarkdownText>
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
<MarkdownText key={key} style={styles.bold}>
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</MarkdownText>
)
} else {
parts.push(
<MarkdownText key={key} style={styles.italic}>
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
</MarkdownText>
)
}
}
if (pendingStart < text.length) {
parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
}
return parts
/** The text is still being appended to: markup its end leaves open is closed, as on desktop,
* and a diagram stays code until the text stops. */
growing?: boolean
}
function MobileMarkdownContent({
@@ -237,18 +38,13 @@ function MobileMarkdownContent({
rangeSelectable = false,
textScale = 1,
onOpenFile,
renderVisual
renderVisual,
growing = false
}: Props) {
// Interactive children own their touches and must forward the row action.
const setup = useContext(MarkdownTextContext)
const rowLongPress = setup.androidTranscript ? setup.onLongPress : undefined
const text = content?.trim() ?? ''
const { blocks, directives } = useMobileMarkdownBlocks(text, renderVisual !== undefined)
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
const proseScale = scaled(13)
const listScale = scaled(14)
const { root, directives } = useMobileMarkdownTree(text, renderVisual !== undefined, growing)
if (!text) {
return fallback ? (
<MarkdownText selectable={rangeSelectable} style={styles.paragraph}>
@@ -256,159 +52,24 @@ function MobileMarkdownContent({
</MarkdownText>
) : null
}
const mermaidSourceOccurrences = new Map<string, number>()
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
const ctx: MarkdownBlockContext = {
growing,
onOpenFile,
listText: [styles.listText, scaled(14)],
rowLongPress: setup.androidTranscript ? setup.onLongPress : undefined,
directives,
renderVisual,
mermaidOccurrences: new Map()
}
// Native-chat range selection is set on each block; nested inline spans inherit it.
return (
<View style={styles.root}>
{blocks.map((block, index) => {
if (block.type === 'visual') {
const directive = directives[block.index]
return directive && renderVisual ? (
<Fragment key={`visual:${block.index}:${directive.file}`}>
{renderVisual(directive, block.index)}
</Fragment>
) : null
}
if (block.type === 'heading') {
return (
<MarkdownText
key={index}
selectable
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text, onOpenFile)}
</MarkdownText>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<MarkdownText selectable style={styles.quoteText}>
{renderInline(block.text, onOpenFile)}
</MarkdownText>
</View>
)
}
if (block.type === 'code') {
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
// Unclosed fences are still streaming: mounting the WebView per tick would
// reload its document up to 20x/sec, so they stay raw code until terminated.
if (isMobileMermaidLanguage(block.language) && block.closed) {
const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0
mermaidSourceOccurrences.set(block.text, occurrence + 1)
return (
<MermaidDiagram
key={`${block.text}:${occurrence}`}
source={block.text}
base={MERMAID_BASE}
/>
)
}
return (
<View key={index} style={styles.codeBlock}>
{block.language ? (
<NativeText style={styles.codeLanguage}>{block.language}</NativeText>
) : null}
<MarkdownText selectable style={styles.codeText}>
{block.text}
</MarkdownText>
</View>
)
}
if (block.type === 'image') {
return (
<Pressable
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
onLongPress={rowLongPress}
>
<NativeText style={styles.link}>{block.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
{block.url}
</NativeText>
</Pressable>
)
}
if (block.type === 'table') {
const visibleHeaders = block.headers.slice(0, MAX_TABLE_COLUMNS)
const visibleRows = block.rows.slice(0, MAX_TABLE_ROWS)
const hiddenRows = Math.max(0, block.rows.length - visibleRows.length)
const hiddenColumns = Math.max(0, block.headers.length - visibleHeaders.length)
return (
<ScrollView key={index} horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.table}>
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<MarkdownText
key={cellIndex}
selectable
style={[styles.tableCell, styles.tableHeader]}
>
{renderInline(header, onOpenFile)}
</MarkdownText>
))}
</View>
{visibleRows.map((row, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<MarkdownText key={cellIndex} selectable style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</MarkdownText>
))}
</View>
))}
{hiddenRows > 0 || hiddenColumns > 0 ? (
<NativeText style={styles.tableTruncated}>
{hiddenRows > 0 ? `${hiddenRows} more rows` : ''}
{hiddenRows > 0 && hiddenColumns > 0 ? ' · ' : ''}
{hiddenColumns > 0 ? `${hiddenColumns} more columns` : ''}
</NativeText>
) : null}
</View>
</ScrollView>
)
}
if (block.type === 'list') {
return (
<View key={index} style={styles.list}>
{block.items.map((item, itemIndex) => (
<View key={itemIndex} style={styles.listItem}>
<NativeText style={styles.listMarker}>
{item.checked == null
? block.ordered
? `${itemIndex + 1}.`
: '-'
: item.checked
? '[x]'
: '[ ]'}
</NativeText>
<MarkdownText selectable style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</MarkdownText>
</View>
))}
</View>
)
}
if (block.type === 'rule') {
return <View key={index} style={styles.rule} />
}
return (
<MarkdownText
key={index}
selectable={rangeSelectable}
style={[styles.paragraph, proseScale]}
>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line, onOpenFile)}
</Fragment>
))}
</MarkdownText>
)
{renderBlocks(root.children, ctx, {
style: [styles.paragraph, scaled(13)],
selectable: rangeSelectable
})}
</View>
)
@@ -0,0 +1,33 @@
import { createContext, useContext, useEffect, useRef } from 'react'
type BottomDrawerModalHostScope = {
/** False only while a self-showing host is down; its drawers render nothing until it is up. */
shown: boolean
/** Keeps a self-showing host up; null when the caller owns `visible`. */
hold: (() => () => void) | null
claimBack: (dismiss: () => void) => () => void
}
export const BottomDrawerModalHostContext = createContext<BottomDrawerModalHostScope | null>(null)
/** Non-null when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
* must therefore skip its own native Modal (the host owns the single Modal). */
export function useBottomDrawerModalHost(): BottomDrawerModalHostScope | null {
return useContext(BottomDrawerModalHostContext)
}
/** Back on the host's Modal reaches the host, not BackHandler; the newest claim takes it. */
export function useBottomDrawerModalHostBackClaim(dismiss: (() => void) | null): void {
const claimBack = useContext(BottomDrawerModalHostContext)?.claimBack
const latest = useRef(dismiss)
useEffect(() => {
latest.current = dismiss
})
const claimed = dismiss !== null
useEffect(() => {
if (!claimBack || !claimed) {
return
}
return claimBack(() => latest.current?.())
}, [claimBack, claimed])
}
@@ -0,0 +1,168 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
type Timing = { to: number; callback?: (finished: boolean) => void }
const animations = vi.hoisted((): { running: Timing[] } => ({ running: [] }))
vi.mock('../navigation/use-back-claim', () => ({ useBackClaim: () => {} }))
vi.mock('../platform/keyboard-occlusion', () => ({
currentSoftKeyboardHeight: () => 0,
subscribeSoftKeyboard: () => () => {}
}))
vi.mock('react-native', () => ({
Keyboard: { dismiss: () => {} },
Modal: 'Modal',
Platform: { OS: 'android', select: (options: { android?: unknown }) => options.android },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T,>(styles: T) => styles, absoluteFillObject: {} },
View: 'View',
useWindowDimensions: () => ({ width: 412, height: 900 })
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 24, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain: Record<string, unknown> = {}
for (const method of [
'activeOffsetY',
'simultaneousWithExternalGesture',
'onBegin',
'onUpdate',
'onEnd'
]) {
chain[method] = () => chain
}
return {
Gesture: { Pan: () => chain, Native: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('react-native-reanimated', () => ({
default: { View: 'AnimatedView', ScrollView: 'AnimatedScrollView' },
useSharedValue: (initial: number) => ({ value: initial }),
useAnimatedStyle: () => ({}),
useAnimatedScrollHandler: () => () => {},
withSpring: (to: number) => to,
withTiming: (to: number, _config?: unknown, callback?: (finished: boolean) => void) => {
animations.running.push({ to, callback })
return to
},
runOnJS: (fn: () => void) => fn,
interpolate: () => 0,
Extrapolation: { CLAMP: 'clamp' }
}))
import { BottomDrawer } from './BottomDrawer'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
function finishAnimations(): void {
const finishing = animations.running
animations.running = []
act(() => {
for (const animation of finishing) {
animation.callback?.(true)
}
})
}
type Layers = { detail: boolean; form: boolean }
const closeDetail = vi.fn()
const closeForm = vi.fn()
// Mirrors the Tasks surface: the Create Workspace form renders before the issue detail and is
// raised over it by zIndex, both inside one host.
function tasksSheets({ detail, form }: Layers) {
return (
<BottomDrawerModalHost>
<BottomDrawer visible={form} onClose={closeForm} zIndex={1100}>
{createElement('Sheet', { name: 'form' })}
</BottomDrawer>
<BottomDrawer visible={detail} onClose={closeDetail}>
{createElement('Sheet', { name: 'detail' })}
</BottomDrawer>
</BottomDrawerModalHost>
)
}
let rendered: ReactTestRenderer | null = null
function show(layers: Layers): void {
act(() => {
if (rendered) {
rendered.update(tasksSheets(layers))
} else {
rendered = create(tasksSheets(layers))
}
})
}
function nativeModals() {
return rendered?.root.findAll((node) => String(node.type) === 'Modal') ?? []
}
function sheetsInModal(): string[] {
return nativeModals().flatMap((modal) =>
modal.findAll((node) => String(node.type) === 'Sheet').map((sheet) => String(sheet.props.name))
)
}
function pressAndroidBack(): void {
act(() => nativeModals()[0]?.props.onRequestClose())
finishAnimations()
}
describe('BottomDrawerModalHost without a caller-owned visible', () => {
afterEach(() => {
act(() => rendered?.unmount())
rendered = null
animations.running = []
closeDetail.mockReset()
closeForm.mockReset()
})
// #21003: iOS refuses a native modal presented over a sibling one, so the form opened from an
// issue detail never appeared. Both sheets must live in the single native Modal.
it('opens a sheet over another inside the one native Modal', () => {
show({ detail: true, form: false })
finishAnimations()
show({ detail: true, form: true })
expect(nativeModals()).toHaveLength(1)
expect(sheetsInModal().sort()).toEqual(['detail', 'form'])
})
it('keeps the Modal through the last close animation, then leaves none to swallow taps', () => {
show({ detail: true, form: true })
finishAnimations()
show({ detail: true, form: false })
finishAnimations()
expect(sheetsInModal()).toEqual(['detail'])
show({ detail: false, form: false })
expect(sheetsInModal()).toEqual(['detail'])
finishAnimations()
expect(nativeModals()).toHaveLength(0)
})
it('hands Back to the sheet on top, then to the one under it', () => {
show({ detail: true, form: false })
finishAnimations()
show({ detail: true, form: true })
finishAnimations()
pressAndroidBack()
expect(closeForm).toHaveBeenCalledTimes(1)
expect(closeDetail).not.toHaveBeenCalled()
show({ detail: true, form: false })
finishAnimations()
pressAndroidBack()
expect(closeDetail).toHaveBeenCalledTimes(1)
})
})
@@ -1,28 +1,57 @@
import { createContext, useContext, type ReactNode } from 'react'
import { useCallback, useMemo, useRef, useState, type ReactNode } from 'react'
import { Modal } from 'react-native'
const BottomDrawerModalHostContext = createContext(false)
/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
* must therefore skip its own native Modal (the host owns the single Modal). */
export function useInsideBottomDrawerModalHost(): boolean {
return useContext(BottomDrawerModalHostContext)
}
import { BottomDrawerModalHostContext } from './bottom-drawer-modal-host-scope'
type Props = {
visible: boolean
onRequestClose: () => void
/** Omit to show the host while any drawer inside is requested, shown or closing. */
visible?: boolean
/** Omit to hand Back to the drawer that claimed it last. */
onRequestClose?: () => void
children: ReactNode
}
// Why: iOS cannot reliably dismiss one native modal and present another in the same
// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form
// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead
// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps
// in-window view changes instead, so no present/dismiss race can eat the transition.
// beat, and refuses outright to present one while a sibling is presented. Flows that
// swap or stack sibling drawer modals (e.g. the Create Workspace form → its pickers, or
// an issue detail → Create Workspace) dropped the incoming modal, leaving the sheet
// dead to taps. Hosting every drawer in ONE native Modal makes those swaps and stacks
// in-window view changes instead, ordered by zIndex.
export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) {
if (!visible) {
return null
const [holds, setHolds] = useState(0)
const backClaims = useRef<(() => void)[]>([])
const hold = useCallback(() => {
let held = true
setHolds((count) => count + 1)
return () => {
if (held) {
held = false
setHolds((count) => count - 1)
}
}
}, [])
const claimBack = useCallback((dismiss: () => void) => {
const claim = () => dismiss()
backClaims.current.push(claim)
return () => {
backClaims.current = backClaims.current.filter((entry) => entry !== claim)
}
}, [])
const selfShowing = visible === undefined
const shown = selfShowing ? holds > 0 : visible
const scope = useMemo(
() => ({ shown, hold: selfShowing ? hold : null, claimBack }),
[shown, selfShowing, hold, claimBack]
)
const content = (
<BottomDrawerModalHostContext.Provider value={scope}>
{children}
</BottomDrawerModalHostContext.Provider>
)
if (!shown) {
// Why: a self-showing host keeps its drawers mounted while down so a request can raise it.
// They remount inside the Modal, which is safe because none of them is showing anything yet.
return selfShowing ? content : null
}
return (
<Modal
@@ -30,11 +59,9 @@ export function BottomDrawerModalHost({ visible, onRequestClose, children }: Pro
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
onRequestClose={onRequestClose ?? (() => backClaims.current.at(-1)?.())}
>
<BottomDrawerModalHostContext.Provider value={true}>
{children}
</BottomDrawerModalHostContext.Provider>
{content}
</Modal>
)
}
@@ -1,5 +1,6 @@
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { MountedBottomDrawer, type MountedBottomDrawerProps } from './mounted-bottom-drawer'
import { useBottomDrawerModalHost } from './bottom-drawer-modal-host-scope'
// iOS cannot present a native Modal while another is still presented, even mid-close, so one
// drawer owns what is on screen and swaps sheets only after the previous Modal has unmounted.
@@ -70,8 +71,14 @@ export function KeyedBottomDrawer<T>({
)
}, [released])
const host = useBottomDrawerModalHost()
const holdHost = host?.hold
// Why: a self-showing host stays up from the request until this sheet's close has landed.
const wantsHost = sheet !== null || next.presented !== null || next.released !== null
useLayoutEffect(() => (holdHost && wantsHost ? holdHost() : undefined), [holdHost, wantsHost])
const presented = next.presented
if (presented === null) {
if (presented === null || host?.shown === false) {
return null
}
return (
@@ -1,19 +1,14 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { createMarkdownInlineMatcher } from './markdown-inline-matcher'
import { isIntrawordUnderscoreToken } from './markdown-inline-token-rules'
import { parseInline } from './pr-sidebar/markdown-blocks'
const ORIGINAL_CHAT =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
const CHAT_OTHER =
/(`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|https?:\/\/[^\s<]+)/g
const ORIGINAL_REVIEW =
/(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)|(\[[^\]]+\]\([^)]+\))/g
const REVIEW_OTHER = /(`[^`]+`)|(\*\*[^*]+\*\*)|(__[^_]+__)|(\*[^*]+\*)|(_[^_]+_)/g
function tokens(text: string, chat: boolean, original: boolean) {
const pattern = new RegExp(chat ? ORIGINAL_CHAT : ORIGINAL_REVIEW)
function tokens(text: string, original: boolean) {
const pattern = new RegExp(ORIGINAL_REVIEW)
const matcher = original
? {
get lastIndex() {
@@ -24,21 +19,17 @@ function tokens(text: string, chat: boolean, original: boolean) {
},
exec: () => pattern.exec(text)
}
: createMarkdownInlineMatcher(text, new RegExp(chat ? CHAT_OTHER : REVIEW_OTHER), chat)
: createMarkdownInlineMatcher(text, new RegExp(REVIEW_OTHER))
const result: Array<{ text: string; index: number; end: number }> = []
let match
while ((match = matcher.exec())) {
if (chat && isIntrawordUnderscoreToken(text, match.index, match[0])) {
matcher.lastIndex = match.index + 1
continue
}
result.push({ text: match[0], index: match.index, end: matcher.lastIndex })
}
return result
}
describe('mobile inline link scanning', () => {
it.each([false, true])('preserves original token order (chat=%s)', (chat) => {
it('preserves original token order', () => {
const fragments = [
'[',
']',
@@ -71,7 +62,7 @@ describe('mobile inline link scanning', () => {
{ length: 1 + (random() % 40) },
() => fragments[random() % fragments.length]
).join('')
expect(tokens(text, chat, false), text).toEqual(tokens(text, chat, true))
expect(tokens(text, false), text).toEqual(tokens(text, true))
}
})
@@ -3,8 +3,7 @@ export type MarkdownInlineMatch = { 0: string; index: number; end: number }
/** Merge a global non-link regex with links; search starts must advance between calls. */
export function createMarkdownInlineMatcher(
text: string,
nonLinkPattern: RegExp,
images = false
nonLinkPattern: RegExp
): { lastIndex: number; exec: () => MarkdownInlineMatch | null } {
let nextOther: MarkdownInlineMatch | null | undefined
let nextLink: MarkdownInlineMatch | null | undefined
@@ -26,8 +25,7 @@ export function createMarkdownInlineMatcher(
noMoreLabels = true
return null
}
const image = images && open > from && text[open - 1] === '!'
if ((image || labelEnd > open + 1) && text[labelEnd + 1] === '(') {
if (labelEnd > open + 1 && text[labelEnd + 1] === '(') {
if (destinationEnd < labelEnd + 2) {
destinationEnd = text.indexOf(')', labelEnd + 2)
}
@@ -36,8 +34,7 @@ export function createMarkdownInlineMatcher(
return null
}
if (destinationEnd > labelEnd + 2) {
const index = image ? open - 1 : open
return { 0: text.slice(index, destinationEnd + 1), index, end: destinationEnd + 1 }
return { 0: text.slice(open, destinationEnd + 1), index: open, end: destinationEnd + 1 }
}
}
// Every opener before this closing bracket shares the same invalid suffix.
@@ -1,73 +0,0 @@
import { describe, expect, it } from 'vitest'
import {
isIntrawordUnderscoreToken,
trimAutolinkTrailingPunctuation
} from './markdown-inline-token-rules'
describe('isIntrawordUnderscoreToken', () => {
it('rejects snake_case emphasis spans', () => {
const text = 'src/foo_bar.ts and src/baz_qux.ts'
const index = text.indexOf('_')
const token = text.slice(index, text.lastIndexOf('_') + 1)
expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true)
})
it('keeps standalone emphasis', () => {
expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false)
expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false)
})
it('rejects emphasis closed against a following word', () => {
expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true)
})
it('rejects dunder emphasis inside a path', () => {
expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true)
expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true)
})
it('ignores non-underscore tokens', () => {
expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false)
})
})
describe('trimAutolinkTrailingPunctuation', () => {
it('splits sentence punctuation off the URL', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({
url: 'https://x.com/a',
trailing: '.'
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({
url: 'https://x.com/a',
trailing: ','
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({
url: 'https://x.com/a',
trailing: '?!'
})
})
it('keeps balanced parens and strips unbalanced ones', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({
url: 'https://x.com/a_(b)',
trailing: ''
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({
url: 'https://x.com/a',
trailing: ').'
})
})
it('handles long unmatched closing-parenthesis tails', () => {
const url = 'https://x.com/a_(b)'
const trailing = ')'.repeat(4096)
expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing })
})
it('leaves clean URLs untouched', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({
url: 'https://x.com/a',
trailing: ''
})
})
})
@@ -1,55 +0,0 @@
// Post-checks for inline markdown tokens that a single-pass tokenizer regex
// cannot express on its own.
const INTRAWORD_FLANK_PATTERN = /[\w\\/]/
/**
* True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder
* tails). CommonMark treats intraword underscores as literal text; path
* separators count as flanks so dunder path segments also stay whole.
*/
export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean {
if (!token.startsWith('_')) {
return false
}
const prev = index > 0 ? text[index - 1]! : ''
const next = text[index + token.length] ?? ''
return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next)
}
/**
* Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."),
* keeping a trailing ')' only when the URL itself opened a paren.
*/
export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } {
let end = url.length
let parenthesisCountsReady = false
let openParentheses = 0
let closeParentheses = 0
while (end > 0) {
const char = url[end - 1]!
if ('.,;:!?'.includes(char)) {
end--
continue
}
if (char === ')') {
if (!parenthesisCountsReady) {
for (let index = 0; index < end; index++) {
if (url[index] === '(') {
openParentheses++
} else if (url[index] === ')') {
closeParentheses++
}
}
parenthesisCountsReady = true
}
if (closeParentheses > openParentheses) {
end--
closeParentheses--
continue
}
}
break
}
return { url: url.slice(0, end), trailing: url.slice(end) }
}
@@ -0,0 +1,249 @@
import type { Code, List, RootContent, Table } from 'mdast'
import { Fragment, type ReactNode } from 'react'
import {
Pressable,
ScrollView,
Text as NativeText,
View,
type StyleProp,
type TextStyle
} from 'react-native'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { mobileMarkdownTableColumnWidths } from './mobile-markdown-table-columns'
import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines'
import {
openMarkdownHref,
renderInline,
type MarkdownInlineContext
} from './mobile-markdown-inline'
import { styles } from './mobile-markdown-styles'
import { MarkdownText } from './mobile-markdown-text'
import { codeFenceClosed, plainText } from './mobile-markdown-tree'
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
/** Containers nested deeper than this draw as plain text, so a pathological nesting cannot
* exhaust native layout. */
const MAX_NESTING = 8
export type MarkdownBlockContext = MarkdownInlineContext & {
/** The text is still arriving: a diagram stays code until it stops. */
growing: boolean
listText: StyleProp<TextStyle>
/** Interactive children own their touches and must forward the row action. */
rowLongPress?: () => void
directives: readonly NativeChatVisualDirective[]
renderVisual?: (directive: NativeChatVisualDirective, index: number) => ReactNode
mermaidOccurrences: Map<string, number>
}
/** How a paragraph draws in the container holding it. */
export type ParagraphText = { style: StyleProp<TextStyle>; selectable: boolean }
function renderCode(node: Code, key: number, ctx: MarkdownBlockContext): ReactNode {
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
// Growing or unclosed fences are still streaming: mounting the WebView per tick would
// reload its document up to 20x/sec, so they stay raw code until done.
if (isMobileMermaidLanguage(node.lang ?? undefined) && !ctx.growing && codeFenceClosed(node)) {
const occurrence = ctx.mermaidOccurrences.get(node.value) ?? 0
ctx.mermaidOccurrences.set(node.value, occurrence + 1)
return (
<MermaidDiagram key={`${node.value}:${occurrence}`} source={node.value} base={MERMAID_BASE} />
)
}
return (
<View key={key} style={styles.codeBlock}>
{node.lang ? <NativeText style={styles.codeLanguage}>{node.lang}</NativeText> : null}
<MarkdownText selectable style={styles.codeText}>
{node.value}
</MarkdownText>
</View>
)
}
function renderList(node: List, key: number, ctx: MarkdownBlockContext, depth: number): ReactNode {
const itemText: ParagraphText = { style: ctx.listText, selectable: true }
return (
<View key={key} style={styles.list}>
{node.children.map((item, itemIndex) => (
<View key={itemIndex} style={styles.listItem}>
<NativeText style={styles.listMarker}>
{item.checked == null
? node.ordered
? `${(node.start ?? 1) + itemIndex}.`
: '-'
: item.checked
? '[x]'
: '[ ]'}
</NativeText>
<View style={styles.listItemBody}>
{renderBlocks(item.children, ctx, itemText, depth + 1)}
</View>
</View>
))}
</View>
)
}
function renderTable(node: Table, key: number, ctx: MarkdownBlockContext): ReactNode {
const rows = node.children.map((row) => row.children)
// Rows lay out independently, so a shared width per column is what lines them up.
const columnWidths = mobileMarkdownTableColumnWidths(
rows.map((cells) => cells.map((cell) => plainText(cell)))
)
return (
<ScrollView key={key} horizontal showsHorizontalScrollIndicator={false}>
<View style={styles.table}>
{rows.map((cells, rowIndex) => (
<View key={rowIndex} style={styles.tableRow}>
{columnWidths.map((width, cellIndex) => (
<MarkdownText
key={cellIndex}
selectable
style={[
styles.tableCell,
rowIndex === 0 ? styles.tableHeader : null,
{ width, textAlign: node.align?.[cellIndex] ?? 'left' }
]}
>
{renderInline(cells[cellIndex]?.children ?? [], ctx)}
</MarkdownText>
))}
</View>
))}
</View>
</ScrollView>
)
}
function renderBlock(
node: RootContent,
key: number,
ctx: MarkdownBlockContext,
text: ParagraphText,
depth: number
): ReactNode {
if (depth > MAX_NESTING && 'children' in node) {
return (
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
{plainText(node)}
</MarkdownText>
)
}
switch (node.type) {
case 'paragraph': {
const [only] = node.children
const visual =
node.children.length === 1 && only?.type === 'text'
? mobileMarkdownVisualPlaceholderIndex(only.value, ctx.directives.length)
: null
if (visual !== null) {
const directive = ctx.directives[visual]
return directive && ctx.renderVisual ? (
<Fragment key={`visual:${visual}:${directive.file}`}>
{ctx.renderVisual(directive, visual)}
</Fragment>
) : null
}
if (node.children.length === 1 && only?.type === 'image' && /^https?:\/\//i.test(only.url)) {
return (
<Pressable
key={key}
style={styles.imageFrame}
onPress={() => openMarkdownHref(only.url, ctx.onOpenFile)}
onLongPress={ctx.rowLongPress}
>
<NativeText style={styles.link}>{only.alt || 'Open image'}</NativeText>
<NativeText style={styles.imageCaption} numberOfLines={1}>
{only.url}
</NativeText>
</Pressable>
)
}
return (
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
{renderInline(node.children, ctx)}
</MarkdownText>
)
}
case 'heading':
return (
<MarkdownText
key={key}
selectable
style={[styles.heading, node.depth <= 2 ? styles.headingLarge : null]}
>
{renderInline(node.children, ctx)}
</MarkdownText>
)
case 'blockquote':
return (
<View key={key} style={styles.quote}>
{renderBlocks(
node.children,
ctx,
{ style: styles.quoteText, selectable: true },
depth + 1
)}
</View>
)
case 'code':
return renderCode(node, key, ctx)
case 'list':
return renderList(node, key, ctx, depth)
case 'table':
return renderTable(node, key, ctx)
case 'thematicBreak':
return <View key={key} style={styles.rule} />
case 'footnoteDefinition':
return (
<View key={key} style={styles.listItem}>
<NativeText style={styles.listMarker}>{`[${node.label ?? node.identifier}]`}</NativeText>
<View style={styles.listItemBody}>
{renderBlocks(node.children, ctx, { style: ctx.listText, selectable: true }, depth + 1)}
</View>
</View>
)
case 'html':
return (
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
{node.value}
</MarkdownText>
)
// Inline content never sits at block level in a parsed tree; drawn as text if it does.
case 'break':
case 'delete':
case 'emphasis':
case 'footnoteReference':
case 'image':
case 'imageReference':
case 'inlineCode':
case 'link':
case 'linkReference':
case 'strong':
case 'text':
return (
<MarkdownText key={key} selectable={text.selectable} style={text.style}>
{renderInline([node], ctx)}
</MarkdownText>
)
case 'definition':
case 'listItem':
case 'tableCell':
case 'tableRow':
case 'yaml':
return null
}
}
/** Block content as views; `text` is how paragraphs draw in the container holding them. */
export function renderBlocks(
nodes: readonly RootContent[],
ctx: MarkdownBlockContext,
text: ParagraphText,
depth = 0
): ReactNode[] {
return nodes.map((node, index) => renderBlock(node, index, ctx, text, depth))
}
@@ -0,0 +1,154 @@
import type { PhrasingContent } from 'mdast'
import { Fragment, type ReactNode } from 'react'
import type { TextProps } from 'react-native'
import { openExternalLink } from '../platform/external-link'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { routeMarkdownHref } from './markdown-href-routing'
import { styles } from './mobile-markdown-styles'
import { MarkdownText } from './mobile-markdown-text'
import { plainText } from './mobile-markdown-tree'
export type MarkdownInlineContext = {
/** When provided, detected file paths and file-target hrefs render as tappable and invoke
* this with the path text; without it, paths render as plain text. */
onOpenFile?: (pathText: string) => void
}
type SpanStyle = TextProps['style']
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
export function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
const route = routeMarkdownHref(href)
if (route.kind === 'web') {
// The seam, not react-native's `Linking`: this module is in the tasks page closure, and inside
// the shell's WebView `openURL` resolves without opening anything.
openExternalLink(route.url)
return
}
if (route.kind === 'file' && onOpenFile) {
onOpenFile(route.pathText)
}
}
// A plain text run, with tappable file paths split out when onOpenFile is provided.
function renderTextRun(
text: string,
key: string,
onOpenFile: ((pathText: string) => void) | undefined,
outer: SpanStyle
): ReactNode {
if (!onOpenFile) {
return text
}
const segments = detectFilePathSegments(text)
if (segments.length === 1 && segments[0]!.type === 'text') {
return text
}
return segments.map((segment, segmentIndex) =>
segment.type === 'file' ? (
<MarkdownText
key={`${key}:${segmentIndex}`}
style={[outer, styles.link]}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</MarkdownText>
) : (
<Fragment key={`${key}:${segmentIndex}`}>{segment.value}</Fragment>
)
)
}
function renderLink(
key: string,
label: string,
url: string,
ctx: MarkdownInlineContext,
outer: SpanStyle
): ReactNode {
return (
<MarkdownText
key={key}
style={[outer, styles.link]}
onPress={() => openMarkdownHref(url, ctx.onOpenFile)}
>
{label}
</MarkdownText>
)
}
function renderPhrasing(
node: PhrasingContent,
key: string,
ctx: MarkdownInlineContext,
outer: SpanStyle
): ReactNode {
switch (node.type) {
case 'text':
return <Fragment key={key}>{renderTextRun(node.value, key, ctx.onOpenFile, outer)}</Fragment>
case 'break':
return <Fragment key={key}>{'\n'}</Fragment>
case 'emphasis':
case 'strong':
case 'delete': {
const emphasis =
node.type === 'strong'
? styles.bold
: node.type === 'emphasis'
? styles.italic
: styles.strike
const spanStyle = [outer, emphasis]
return (
<MarkdownText key={key} style={spanStyle}>
{renderInline(node.children, ctx, spanStyle, `${key}.`)}
</MarkdownText>
)
}
case 'inlineCode':
return ctx.onOpenFile && isFilePathCodeSpan(node.value) ? (
<MarkdownText
key={key}
style={[outer, styles.inlineCode, styles.inlineCodeLink]}
onPress={() => ctx.onOpenFile?.(normalizeFilePath(node.value.trim()))}
>
{node.value}
</MarkdownText>
) : (
<MarkdownText key={key} style={[outer, styles.inlineCode]}>
{node.value}
</MarkdownText>
)
case 'link':
return renderLink(key, plainText(node), node.url, ctx, outer)
case 'image':
return renderLink(key, node.alt || 'image', node.url, ctx, outer)
case 'footnoteReference':
return (
<MarkdownText key={key} style={[outer, styles.footnoteReference]}>
{`[${node.label ?? node.identifier}]`}
</MarkdownText>
)
case 'html':
return <Fragment key={key}>{node.value}</Fragment>
// markdown-it resolves references itself, so these only carry their text.
case 'linkReference':
case 'imageReference':
return <Fragment key={key}>{plainText(node)}</Fragment>
}
}
/** Inline content as spans. iOS range-selectable spans are styled from the root alone, so `outer`
* restates the enclosing emphasis on every span inside it. */
export function renderInline(
nodes: readonly PhrasingContent[],
ctx: MarkdownInlineContext,
outer?: SpanStyle,
keyPrefix = ''
): ReactNode[] {
return nodes.map((node, index) => renderPhrasing(node, `${keyPrefix}${index}`, ctx, outer))
}
@@ -1,46 +0,0 @@
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
function parseWithDeadline(input: string) {
// A synchronous parser loop must fail without hanging the test worker.
return runInNewContext('parse(input)', { parse: parseMobileMarkdown, input }, { timeout: 250 })
}
describe('mobile Markdown parser progress', () => {
it.each(['```c++', '```c#', '``` ts', '```ts title="file.ts"', '````', '```!'])(
'retains unsupported fence %s as paragraph text',
(fence) => {
expect(parseWithDeadline(fence)).toEqual([{ type: 'paragraph', text: fence }])
expect(parseWithDeadline(`before\n${fence}\nafter`)).toEqual([
{ type: 'paragraph', text: `before\n${fence}\nafter` }
])
}
)
it.each(['# ', '## ', '###### ', '#\t'])('consumes incomplete heading %s', (heading) => {
expect(parseWithDeadline(`${heading}\nnext`)).toEqual([
{ type: 'paragraph', text: `${heading}\nnext` }
])
})
it('handles unsupported fences through the production preview normalization', () => {
const input = normalizeMobileMarkdownPreviewHtml('```c++\nint x = 1;')
expect(parseWithDeadline(input)).toEqual([{ type: 'paragraph', text: input }])
})
it('recognizes a supported fence after unsupported fence text', () => {
expect(parseWithDeadline('```c++\n```ts\nconst x = 1\n```')).toEqual([
{ type: 'paragraph', text: '```c++' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: true }
])
})
it('preserves supported fences and their streaming state', () => {
expect(parseWithDeadline('before\n```ts\nconst x = 1')).toEqual([
{ type: 'paragraph', text: 'before' },
{ type: 'code', text: 'const x = 1', language: 'ts', closed: false }
])
})
})
@@ -1,143 +0,0 @@
import { isTableSeparator, splitTableRow } from './rich-markdown/markdown-table-rows'
import { mobileMarkdownVisualPlaceholderIndex } from './mobile-markdown-visual-lines'
export type MobileMarkdownBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; level: number; text: string }
| { type: 'quote'; text: string }
| { type: 'code'; text: string; language?: string; closed: boolean }
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
| { type: 'image'; alt: string; url: string }
| { type: 'table'; headers: string[]; rows: string[][] }
| { type: 'rule' }
/** A native-chat visual directive; `index` is its position in the protected directive list. */
| { type: 'visual'; index: number }
const HEADING = /^(#{1,6})\s+(.+)$/
const CODE_FENCE = /^```([A-Za-z0-9_-]+)?\s*$/
/** `visualCount`: how many directive placeholders `protectMobileMarkdownVisualLines` left in
* `content`; zero (the default) leaves every line to the ordinary rules. */
export function parseMobileMarkdown(content: string, visualCount = 0): MobileMarkdownBlock[] {
const visualIndex = (line: string): number | null =>
visualCount > 0 ? mobileMarkdownVisualPlaceholderIndex(line, visualCount) : null
const lines = content.replace(/\r\n?/g, '\n').split('\n')
const blocks: MobileMarkdownBlock[] = []
let index = 0
while (index < lines.length) {
const line = lines[index] ?? ''
if (!line.trim()) {
index += 1
continue
}
const fence = line.match(CODE_FENCE)
if (fence) {
index += 1
const code: string[] = []
while (index < lines.length && !/^```\s*$/.test(lines[index] ?? '')) {
code.push(lines[index] ?? '')
index += 1
}
// closed=false means the fence is still streaming in (no terminator yet).
const closed = index < lines.length
if (closed) {
index += 1
}
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed })
continue
}
const visual = visualIndex(line)
if (visual !== null) {
blocks.push({ type: 'visual', index: visual })
index += 1
continue
}
if (/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
blocks.push({ type: 'rule' })
index += 1
continue
}
const standaloneImage = line.match(/^!\[([^\]]*)\]\((https?:\/\/[^)\s]+)(?:\s+"[^"]*")?\)\s*$/i)
if (standaloneImage) {
blocks.push({ type: 'image', alt: standaloneImage[1] ?? '', url: standaloneImage[2]! })
index += 1
continue
}
if (
line.includes('|') &&
index + 1 < lines.length &&
isTableSeparator(lines[index + 1] ?? '')
) {
const headers = splitTableRow(line)
index += 2
const rows: string[][] = []
while (index < lines.length && (lines[index] ?? '').includes('|') && lines[index]?.trim()) {
rows.push(splitTableRow(lines[index] ?? ''))
index += 1
}
blocks.push({ type: 'table', headers, rows })
continue
}
const heading = line.match(HEADING)
if (heading) {
blocks.push({ type: 'heading', level: heading[1]!.length, text: heading[2]!.trim() })
index += 1
continue
}
if (/^>\s?/.test(line)) {
const quote: string[] = []
while (index < lines.length && /^>\s?/.test(lines[index] ?? '')) {
quote.push((lines[index] ?? '').replace(/^>\s?/, ''))
index += 1
}
blocks.push({ type: 'quote', text: quote.join('\n').trim() })
continue
}
if (/^\s*(?:[-*+]|\d+[.)])\s+/.test(line)) {
const items: Array<{ text: string; checked?: boolean }> = []
let ordered = false
while (index < lines.length && /^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '')) {
const current = lines[index] ?? ''
const orderedMatch = current.match(/^\s*\d+[.)]\s+(.+)$/)
const unorderedMatch = current.match(/^\s*[-*+]\s+(.+)$/)
ordered ||= Boolean(orderedMatch)
const rawText = (orderedMatch?.[1] ?? unorderedMatch?.[1] ?? '').trim()
const task = rawText.match(/^\[([ xX])\]\s+(.+)$/)
items.push({
text: task?.[2] ?? rawText,
checked: task ? task[1]?.toLowerCase() === 'x' : undefined
})
index += 1
}
blocks.push({ type: 'list', ordered, items })
continue
}
const paragraph: string[] = []
while (
index < lines.length &&
lines[index]?.trim() &&
!CODE_FENCE.test(lines[index] ?? '') &&
!HEADING.test(lines[index] ?? '') &&
!/^>\s?/.test(lines[index] ?? '') &&
!/^\s*(?:[-*+]|\d+[.)])\s+/.test(lines[index] ?? '') &&
!/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index] ?? '') &&
visualIndex(lines[index] ?? '') === null
) {
paragraph.push(lines[index] ?? '')
index += 1
}
blocks.push({ type: 'paragraph', text: paragraph.join('\n').trim() })
}
return blocks
}
+11 -10
View File
@@ -49,7 +49,8 @@ export const styles = StyleSheet.create({
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
paddingLeft: spacing.sm,
gap: spacing.xs
},
quoteText: {
fontSize: 13,
@@ -101,8 +102,6 @@ export const styles = StyleSheet.create({
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
@@ -116,11 +115,6 @@ export const styles = StyleSheet.create({
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
@@ -130,19 +124,26 @@ export const styles = StyleSheet.create({
gap: spacing.sm
},
listMarker: {
width: 22,
// A floor, not a width: "10." would wrap inside a fixed 22.
minWidth: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
listItemBody: {
flex: 1,
minWidth: 0,
gap: spacing.xs
},
listText: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
footnoteReference: {
color: colors.textSecondary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
@@ -0,0 +1,38 @@
import { spacing } from '../theme/mobile-theme'
// Per-glyph advances at the 12px table font, measured on agent tables: 6.4 regular and 6.9 bold
// on average, and up to 8.7 for a bold word. A wrong guess wraps text; it cannot misalign columns.
const AVERAGE_GLYPH_WIDTH = 7
const WIDEST_GLYPH_WIDTH = 9
// Horizontal padding plus the right border of `styles.tableCell`.
const CELL_CHROME = spacing.sm * 2 + 1
const MIN_COLUMN_WIDTH = 48
const MAX_COLUMN_WIDTH = 220
const WHITESPACE = /\s/
function cellContentWidth(text: string): number {
let columns = 0
let word = 0
let longestWord = 0
for (const char of text) {
// CJK, kana, Hangul, fullwidth forms and emoji draw about two Latin glyphs wide.
const wide = char.codePointAt(0)! >= 0x2e80
columns += wide ? 2 : 1
// Lines break at spaces and around wide glyphs; only a longer unbroken run splits mid-word.
word = wide || WHITESPACE.test(char) ? 0 : word + 1
longestWord = Math.max(longestWord, word)
}
return Math.max(columns * AVERAGE_GLYPH_WIDTH, longestWord * WIDEST_GLYPH_WIDTH)
}
/** One width per header column, sized to the column's widest cell. `rows` holds each cell's
* drawn text, header row first. */
export function mobileMarkdownTableColumnWidths(rows: readonly string[][]): number[] {
return (rows[0] ?? []).map((_, column) => {
let widest = 0
for (const row of rows) {
widest = Math.max(widest, cellContentWidth(row[column] ?? ''))
}
return Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, widest + CELL_CHROME))
})
}
@@ -0,0 +1,75 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
import { styles } from './mobile-markdown-styles'
vi.mock('react-native', () => ({
Linking: { openURL: () => Promise.resolve() },
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
/** Each rendered table row's cell styles, header row first. */
function renderTableCellStyles(content: string): unknown[][] {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(createElement(MobileMarkdown, { content }))
})
const rows = renderer!.root.findAll((node) => node.props.style === styles.tableRow)
const cellStyles = rows.map((row) =>
row.children.map((cell) => (typeof cell === 'string' ? undefined : cell.props.style))
)
act(() => renderer!.unmount())
return cellStyles
}
function cellWidth(style: unknown): number {
for (const entry of [style].flat()) {
if (entry && typeof entry === 'object' && 'width' in entry && typeof entry.width === 'number') {
return entry.width
}
}
throw new Error('table cell has no width')
}
function markdownTable(headers: string[], rows: string[][]): string {
return [headers, headers.map(() => '---'), ...rows]
.map((row) => `| ${row.join(' | ')} |`)
.join('\n')
}
describe('MobileMarkdown tables', () => {
it('renders every row and column with one width per column', () => {
const headers = Array.from({ length: 10 }, (_, column) => `Column ${column}`)
const rows = Array.from({ length: 50 }, (_, row) =>
headers.map((_, column) => (row === 7 && column === 3 ? 'a much longer cell value' : 'x'))
)
const cellStyles = renderTableCellStyles(markdownTable(headers, rows))
expect(cellStyles.map((row) => row.length)).toEqual(Array.from({ length: 51 }, () => 10))
const widths = cellStyles.map((row) => row.map(cellWidth))
for (const row of widths) {
expect(row).toEqual(widths[0])
}
expect(widths[0]![3]).toBeGreaterThan(widths[0]![2]!)
})
it('sizes a column by the text it draws, not its markdown source', () => {
const [widths] = renderTableCellStyles(
markdownTable(
['A', 'B', 'C'],
[['docs', '[docs](https://example.com/a/very/long/path)', '文档文档']]
)
).map((row) => row.map(cellWidth))
expect(widths![1]).toBe(widths![0])
expect(widths![2]).toBeGreaterThan(widths![0]!)
})
})
@@ -0,0 +1,14 @@
import { createContext, type ComponentType } from 'react'
import { Text as NativeText, type TextProps } from 'react-native'
export type MarkdownTextSetup = {
TextComponent: ComponentType<TextProps>
/** Disable native selection only within the Android transcript. */
androidTranscript: boolean
onLongPress?: () => void
}
export const MarkdownTextContext = createContext<MarkdownTextSetup>({
TextComponent: NativeText,
androidTranscript: false
})
@@ -0,0 +1,16 @@
import { createElement, useContext } from 'react'
import type { TextProps } from 'react-native'
import { MarkdownTextContext } from './mobile-markdown-text-setup'
export function MarkdownText(props: TextProps): React.JSX.Element {
const { TextComponent, androidTranscript, onLongPress } = useContext(MarkdownTextContext)
if (!androidTranscript) {
return createElement(TextComponent, props)
}
// Override selection without changing the nested spans' inherited behavior.
return createElement(TextComponent, {
...props,
...(props.selectable === true ? { selectable: false } : {}),
...(onLongPress && props.onPress ? { onLongPress } : {})
})
}
@@ -0,0 +1,309 @@
import MarkdownIt from 'markdown-it'
import footnote from 'markdown-it-footnote'
import type {
AlignType,
Code,
Nodes,
Parent,
PhrasingContent,
Root,
RootContent,
Table
} from 'mdast'
import remend from 'remend'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
// CommonMark with GitHub's tables, strikethrough, autolinks and footnotes: the rules desktop's
// renderer follows, read by a parser fast enough for the phone's JavaScript engine.
const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote)
// Every href parses as a link, file: included; routeMarkdownHref decides what a tap opens.
markdown.validateLink = () => true
type Token = ReturnType<MarkdownIt['parse']>[number]
const PATH_FLANK = /[\w\\/]/
const TASK_MARKER = /^\[([ xX])\](?:\s+|$)/
const LINE_BREAK_TAG = /^<br\s*\/?>$/i
const HEADING_DEPTHS = [1, 2, 3, 4, 5, 6] as const
/** Parents that hold blocks; any other parent holds inline content. */
const BLOCK_PARENTS = new Set(['root', 'blockquote', 'listItem', 'footnoteDefinition'])
const CLOSING_TOKENS = new Set([
'paragraph_close',
'heading_close',
'blockquote_close',
'bullet_list_close',
'ordered_list_close',
'list_item_close',
'table_close',
'tr_close',
'th_close',
'td_close',
'footnote_close'
])
/** The text a node draws, as mdast-util-to-string reads it. */
export function plainText(node: Nodes): string {
if ('value' in node) {
return node.value
}
if (node.type === 'image') {
return node.alt ?? ''
}
return 'children' in node ? node.children.map(plainText).join('') : ''
}
function pushText(nodes: PhrasingContent[], value: string): void {
const previous = nodes.at(-1)
if (previous?.type === 'text') {
previous.value += value
} else {
nodes.push({ type: 'text', value })
}
}
/** Inline tokens up to `close`, and the index of that closing token. */
function phrasing(
tokens: readonly Token[],
start: number,
close?: string
): { nodes: PhrasingContent[]; next: number } {
const nodes: PhrasingContent[] = []
let index = start
while (index < tokens.length && tokens[index]!.type !== close) {
const token = tokens[index]!
index += 1
if (token.type === 'text') {
pushText(nodes, token.content)
} else if (token.type === 'softbreak') {
pushText(nodes, '\n')
} else if (token.type === 'hardbreak') {
nodes.push({ type: 'break' })
} else if (token.type === 'code_inline') {
nodes.push({ type: 'inlineCode', value: token.content })
} else if (token.type === 'html_inline') {
nodes.push({ type: 'html', value: token.content })
} else if (token.type === 'image') {
nodes.push({ type: 'image', url: token.attrGet('src') ?? '', alt: token.content || null })
} else if (token.type === 'footnote_ref') {
const label = String(token.meta?.label ?? '')
nodes.push({ type: 'footnoteReference', identifier: label, label })
} else if (token.nesting === 1) {
const inner = phrasing(tokens, index, token.type.replace(/_open$/, '_close'))
index = inner.next + 1
const previous = nodes.at(-1)
const before = previous?.type === 'text' ? previous.value.slice(-1) : ''
const after = tokens[index]?.type === 'text' ? (tokens[index]?.content[0] ?? '') : ''
if (token.type === 'link_open') {
nodes.push({ type: 'link', url: token.attrGet('href') ?? '', children: inner.nodes })
} else if (
// `_` emphasis against a path separator stays literal, so a dunder segment
// (`a/__tests__/x.ts`) keeps its path whole and tappable.
token.markup.startsWith('_') &&
(PATH_FLANK.test(before) || PATH_FLANK.test(after))
) {
const literal = inner.nodes.map(plainText).join('')
pushText(nodes, `${token.markup}${literal}${token.markup}`)
} else if (token.type === 'strong_open') {
nodes.push({ type: 'strong', children: inner.nodes })
} else if (token.type === 'em_open') {
nodes.push({ type: 'emphasis', children: inner.nodes })
} else if (token.type === 's_open') {
nodes.push({ type: 'delete', children: inner.nodes })
} else {
nodes.push(...inner.nodes)
}
}
}
return { nodes, next: index }
}
/** Whether a code block's closing fence has arrived; an unclosed one is still streaming in. */
export function codeFenceClosed(node: Code): boolean {
return !('closed' in node) || node.closed !== false
}
function codeNode(token: Token, lines: readonly string[]): Code & { closed: boolean } {
if (token.type !== 'fence') {
return { type: 'code', lang: null, value: token.content.replace(/\n$/, ''), closed: true }
}
const [start, end] = token.map ?? [0, 0]
// A fence inside a quote carries the quote's marker on its closing line.
const closing = end - start > 1 ? (lines[end - 1] ?? '').replace(/^[\s>]*/, '').trim() : ''
return {
type: 'code',
lang: token.info.trim().split(/\s+/)[0] || null,
value: token.content.replace(/\n$/, ''),
closed:
closing.length >= token.markup.length &&
[...closing].every((char) => char === token.markup[0])
}
}
function alignOf(token: Token): AlignType {
const align = /text-align:\s*(left|right|center)/.exec(token.attrGet('style') ?? '')?.[1]
return align === 'left' || align === 'right' || align === 'center' ? align : null
}
/** GitHub task list items: `[ ]` or `[x]` opening the item's first paragraph. */
function readTaskMarker(item: Parent & { checked?: boolean | null }): void {
const paragraph = item.children[0]
const text = paragraph?.type === 'paragraph' ? paragraph.children[0] : undefined
const marker = text?.type === 'text' ? TASK_MARKER.exec(text.value) : null
if (text?.type === 'text' && marker) {
item.checked = marker[1] !== ' '
text.value = text.value.slice(marker[0].length)
}
}
/** markdown-it's flat token stream as the mdast tree the renderer draws. */
function toTree(tokens: readonly Token[], source: string): Root {
const root: Root = { type: 'root', children: [] }
const lines = source.split('\n')
const stack: Parent[] = [root]
let table: Table | undefined
const open = (node: Parent & RootContent): void => {
stack.at(-1)!.children.push(node)
stack.push(node)
}
for (const token of tokens) {
const parent = stack.at(-1)!
switch (token.type) {
case 'paragraph_open':
open({ type: 'paragraph', children: [] })
break
case 'heading_open':
open({
type: 'heading',
depth: HEADING_DEPTHS.find((depth) => token.tag === `h${depth}`) ?? 6,
children: []
})
break
case 'blockquote_open':
open({ type: 'blockquote', children: [] })
break
case 'bullet_list_open':
case 'ordered_list_open':
open({
type: 'list',
ordered: token.type === 'ordered_list_open',
start: Number(token.attrGet('start') ?? 1),
children: []
})
break
case 'list_item_open':
open({ type: 'listItem', checked: null, children: [] })
break
case 'table_open':
table = { type: 'table', align: [], children: [] }
open(table)
break
case 'tr_open':
open({ type: 'tableRow', children: [] })
break
case 'th_open':
case 'td_open':
if (token.type === 'th_open') {
table?.align?.push(alignOf(token))
}
open({ type: 'tableCell', children: [] })
break
case 'footnote_open': {
const label = String(token.meta?.label ?? '')
open({ type: 'footnoteDefinition', identifier: label, label, children: [] })
break
}
case 'inline':
parent.children.push(...phrasing(token.children ?? [], 0).nodes)
break
case 'fence':
case 'code_block':
parent.children.push(codeNode(token, lines))
break
case 'hr':
parent.children.push({ type: 'thematicBreak' })
break
case 'html_block':
parent.children.push({ type: 'html', value: token.content })
break
default:
if (CLOSING_TOKENS.has(token.type)) {
const closed = stack.pop()
if (closed?.type === 'listItem') {
readTaskMarker(closed)
}
}
}
}
return root
}
/**
* Raw HTML the parser found, and only that: code is never an `html` node, so nothing inside a
* code block or span is touched. A block is read as the Markdown the preview cleanup makes of
* it (README headers and badges); an inline tag becomes the text the cleanup leaves, if any.
* `nested`: a block the cleanup produced, where leftover block HTML stays text.
*/
function withoutHtml(parent: Parent, nested: boolean): void {
const inline = !BLOCK_PARENTS.has(parent.type)
parent.children = parent.children.flatMap((child): RootContent[] => {
if (child.type !== 'html') {
if ('children' in child) {
withoutHtml(child, nested)
}
return [child]
}
if (inline && LINE_BREAK_TAG.test(child.value.trim())) {
return [{ type: 'break' }]
}
const text = normalizeMobileMarkdownPreviewHtml(child.value)
if (!text) {
return []
}
if (inline) {
return [{ type: 'text', value: text }]
}
if (nested) {
return [{ type: 'paragraph', children: [{ type: 'text', value: text }] }]
}
const blocks = toTree(markdown.parse(text, {}), text)
withoutHtml(blocks, true)
return blocks.children
})
}
/** The text with the markup its last top-level block leaves open closed, as desktop closes it.
* Only that block: a lone `*` earlier in the reply is no opener, and remend would close it. */
function withLastBlockClosed(source: string, tokens: readonly Token[]): string {
const lastBlock = tokens.findLast((token) => token.level === 0 && token.map)?.map?.[0] ?? 0
const lines = source.split('\n')
const tail = lines.slice(lastBlock).join('\n')
const closed = remend(tail, { linkMode: 'text-only' })
return closed === tail ? source : [...lines.slice(0, lastBlock), closed].join('\n')
}
/** `growing`: the text is still arriving, so markup its end leaves open is closed first, instead
* of flashing raw markers and half-written links. */
export function parseMobileMarkdownTree(source: string, growing: boolean): Root {
let tokens = markdown.parse(source, {})
let text = source
if (growing) {
text = withLastBlockClosed(source, tokens)
tokens = text === source ? tokens : markdown.parse(text, {})
}
const root = toTree(tokens, text)
withoutHtml(root, false)
return root
}
/** The 0-based lines markdown-it reads as code, at any depth. */
export function mobileMarkdownCodeLines(source: string): Set<number> {
const lines = new Set<number>()
for (const token of markdown.parse(source, {})) {
if ((token.type === 'fence' || token.type === 'code_block') && token.map) {
for (let line = token.map[0]; line < token.map[1]; line += 1) {
lines.add(line)
}
}
}
return lines
}
@@ -1,16 +1,31 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE } from '../../../src/shared/native-chat-visual-directive'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
import { parseMobileMarkdownTree, plainText } from './mobile-markdown-tree'
import {
mobileMarkdownVisualPlaceholderIndex,
protectMobileMarkdownVisualLines
} from './mobile-markdown-visual-lines'
/** The top-level blocks drawn, with a lone placeholder paragraph read as its visual. */
function topLevelBlocks(text: string, visualCount: number) {
return parseMobileMarkdownTree(text, false).children.map((node) => {
const visual =
node.type === 'paragraph'
? mobileMarkdownVisualPlaceholderIndex(plainText(node), visualCount)
: null
if (visual !== null) {
return { type: 'visual', index: visual }
}
return node.type === 'code'
? { type: 'code', text: node.value, language: node.lang }
: { type: node.type, text: plainText(node) }
})
}
/** The same steps MobileMarkdown runs when a transcript renders visuals. */
function blocksOf(content: string) {
const { text, directives } = protectMobileMarkdownVisualLines(content)
return {
directives,
blocks: parseMobileMarkdown(normalizeMobileMarkdownPreviewHtml(text), directives.length)
}
return { directives, blocks: topLevelBlocks(text, directives.length) }
}
describe('native-chat visual lines in mobile markdown', () => {
@@ -40,7 +55,7 @@ describe('native-chat visual lines in mobile markdown', () => {
const fenced = blocksOf('```md\n::orca-visual{file="a.html"}\n```')
expect(fenced.directives).toEqual([])
expect(fenced.blocks).toEqual([
{ type: 'code', text: '::orca-visual{file="a.html"}', language: 'md', closed: true }
{ type: 'code', text: '::orca-visual{file="a.html"}', language: 'md' }
])
expect(blocksOf('> ::orca-visual{file="a.html"}').directives).toEqual([])
expect(blocksOf('- ::orca-visual{file="a.html"}').directives).toEqual([])
@@ -83,7 +98,7 @@ describe('native-chat visual lines in mobile markdown', () => {
})
it('never treats a placeholder-looking line as a visual when none were protected', () => {
expect(parseMobileMarkdown('\uE000ORCA_VISUAL_0\uE000')).toEqual([
expect(topLevelBlocks('\uE000ORCA_VISUAL_0\uE000', 0)).toEqual([
{ type: 'paragraph', text: '\uE000ORCA_VISUAL_0\uE000' }
])
})
@@ -1,12 +1,10 @@
import {
NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER,
NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE,
parseNativeChatVisualDirectiveLine,
type NativeChatVisualDirective
} from '../../../src/shared/native-chat-visual-directive'
/** Same fence shapes the block parser opens and closes on, so a directive inside code stays code. */
const CODE_FENCE_OPEN = /^```([A-Za-z0-9_-]+)?\s*$/
const CODE_FENCE_CLOSE = /^```\s*$/
import { mobileMarkdownCodeLines } from './mobile-markdown-tree'
// Private-use delimiters, as the preview normalizer's own code placeholders use.
const PLACEHOLDER_PREFIX = '\uE000ORCA_VISUAL_'
const PLACEHOLDER = /^\uE000ORCA_VISUAL_(\d+)\uE000$/
@@ -22,29 +20,24 @@ function placeholderFor(index: number): string {
}
/**
* Lifts native-chat visual directive lines out of assistant prose before preview normalization,
* which decodes entities and strips tags and would otherwise rewrite a title. Only top-level lines
* outside fenced code count; quotes and list items never start with the marker. Past the per-message
* cap a directive stays literal text.
* Lifts native-chat visual directive lines out of assistant prose before it is parsed, which
* decodes entities and reads tags and would otherwise rewrite a title. Only lines outside code
* count, as the parser reads it; quotes and list items never start with the marker. Past the
* per-message cap a directive stays literal text.
*/
export function protectMobileMarkdownVisualLines(content: string): MobileMarkdownVisualLines {
// Text that already spells a placeholder would alias a real one; it renders no visuals at all.
if (content.includes(PLACEHOLDER_PREFIX)) {
if (
content.includes(PLACEHOLDER_PREFIX) ||
!content.includes(NATIVE_CHAT_VISUAL_DIRECTIVE_MARKER)
) {
return { text: content, directives: [] }
}
const lines = content.replace(/\r\n?/g, '\n').split('\n')
const normalized = content.replace(/\r\n?/g, '\n')
const code = mobileMarkdownCodeLines(normalized)
const directives: NativeChatVisualDirective[] = []
let inFence = false
const next = lines.map((line) => {
if (inFence) {
inFence = !CODE_FENCE_CLOSE.test(line)
return line
}
if (CODE_FENCE_OPEN.test(line)) {
inFence = true
return line
}
if (directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
const next = normalized.split('\n').map((line, index) => {
if (code.has(index) || directives.length >= NATIVE_CHAT_VISUAL_MAX_PER_MESSAGE) {
return line
}
const directive = parseNativeChatVisualDirectiveLine(line)
@@ -16,7 +16,10 @@ import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import {
useBottomDrawerModalHost,
useBottomDrawerModalHostBackClaim
} from './bottom-drawer-modal-host-scope'
import { useResponsiveLayout } from '../layout/responsive-layout'
import { useWindowSafeAreaInsets } from '../layout/side-safe-area'
import { useBackClaim } from '../navigation/use-back-claim'
@@ -73,7 +76,7 @@ export function MountedBottomDrawer({
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
const insideModalHost = useBottomDrawerModalHost() !== null
const fillHeight = fillAvailable
? resolveBottomDrawerFillHeight({
screenHeight,
@@ -202,6 +205,7 @@ export function MountedBottomDrawer({
}
: null
)
useBottomDrawerModalHostBackClaim(visible && interactive ? dismiss : null)
const scrollHandler = useAnimatedScrollHandler((event) => {
scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
@@ -1,28 +0,0 @@
import { useMemo } from 'react'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown, type MobileMarkdownBlock } from './mobile-markdown-parser'
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
const NO_DIRECTIVES: NativeChatVisualDirective[] = []
/** The blocks MobileMarkdown draws; with `visuals`, directive lines become `visual` blocks. */
export function useMobileMarkdownBlocks(
text: string,
visuals: boolean
): { blocks: MobileMarkdownBlock[]; directives: NativeChatVisualDirective[] } {
const visualLines = useMemo(
() => (visuals ? protectMobileMarkdownVisualLines(text) : null),
[text, visuals]
)
const previewText = useMemo(
() => normalizeMobileMarkdownPreviewHtml(visualLines?.text ?? text),
[visualLines, text]
)
const directives = visualLines?.directives ?? NO_DIRECTIVES
const blocks = useMemo(
() => parseMobileMarkdown(previewText, directives.length),
[previewText, directives.length]
)
return { blocks, directives }
}
@@ -0,0 +1,22 @@
import type { Root } from 'mdast'
import { useMemo } from 'react'
import type { NativeChatVisualDirective } from '../../../src/shared/native-chat-visual-directive'
import { parseMobileMarkdownTree } from './mobile-markdown-tree'
import { protectMobileMarkdownVisualLines } from './mobile-markdown-visual-lines'
const NO_DIRECTIVES: NativeChatVisualDirective[] = []
/** The tree MobileMarkdown draws; with `visuals`, directive lines become placeholders. */
export function useMobileMarkdownTree(
text: string,
visuals: boolean,
growing: boolean
): { root: Root; directives: NativeChatVisualDirective[] } {
const visualLines = useMemo(
() => (visuals ? protectMobileMarkdownVisualLines(text) : null),
[text, visuals]
)
const source = visualLines?.text ?? text
const root = useMemo(() => parseMobileMarkdownTree(source, growing), [source, growing])
return { root, directives: visualLines?.directives ?? NO_DIRECTIVES }
}
+18
View File
@@ -0,0 +1,18 @@
import type { SectionRepo } from '../worktree/workspace-list-types'
import { sectionRepoColor } from '../worktree/workspace-filter-repos'
import { readMobileRepoIcon, type MobileHostRepoIcon } from './host-screen-reply-schema'
/** The colour and icon a repo's header and rows draw. */
export type RepoLook = { color: string; icon: MobileHostRepoIcon | null }
/** Decoded once per catalog, so a row's props keep their identity across polls. */
export function buildRepoLookById(
repoById: ReadonlyMap<string, SectionRepo>
): ReadonlyMap<string, RepoLook> {
return new Map(
[...repoById].map(([id, repo]) => [
id,
{ color: sectionRepoColor(repo), icon: readMobileRepoIcon(repo.repoIcon) }
])
)
}
@@ -95,7 +95,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
<Text style={styles.filterSectionLabel}>Repositories</Text>
<View style={styles.filterGroup}>
{controller.sectionsResult.uniqueRepos.map((repo, i) => (
<View key={repo.id}>
<View key={repo.key}>
{i > 0 && <View style={styles.filterSeparator} />}
<Pressable
style={styles.filterRow}
@@ -30,6 +30,17 @@ const repoIconBranches = {
label: salvagedOptional('label', z.string())
})
} satisfies Readonly<Record<RepoIcon['type'], z.ZodType>>
const mobileRepoIconSchema = z.union([
repoIconBranches.lucide,
repoIconBranches.emoji,
repoIconBranches.image
])
/** A catalog repo's icon as MobileRepoIcon draws it; null for an arm this build cannot read. */
export function readMobileRepoIcon(value: unknown): MobileHostRepoIcon | null {
const parsed = mobileRepoIconSchema.safeParse(value)
return parsed.success ? parsed.data : null
}
// What the host screen reads to label its rows and to mirror the desktop's workspace view store.
// Checked against src/main/runtime/rpc/methods/repo.ts:29, ssh.ts:55, host-capabilities.ts:8 and
@@ -37,12 +48,12 @@ const repoIconBranches = {
// PersistedUIState's workspace-view subset in mobile/src/worktree/workspace-view-settings.ts:14.
/**
* The host's repo catalog, narrowed to what the label maps are built from.
* The host's repo catalog, narrowed to what the host-label maps and the repo cache are built from.
*
* `id` and `displayName` are required and a row without either drops: `displayName` is the key of
* four Maps and the argument `repoColor` hashes with `name.charCodeAt` — a row without one was a
* TypeError that took the whole metadata refresh with it — and `id` is the Map value the workspace
* rows resolve their host through.
* `id` and `displayName` are required and a row without either drops: `displayName` is what the
* new-workspace picker shows from the repo cache, and `id` is the Map key the workspace rows
* resolve their host through. `path` labels an older desktop's repo sections (the newer one serves
* project-sections-operations.ts instead).
*
* `badgeColor` sits behind main's own `||` fallback to `repoColor`, so a salvaged member draws the
* same swatch. `repoIcon` is declared as the three arms MobileRepoIcon renders, and an arm this
@@ -64,11 +75,9 @@ export const hostRepoCatalogSchema = z
z.looseObject({
id: z.string(),
displayName: z.string(),
path: salvagedOptional('path', z.string()),
badgeColor: salvagedOptional('badgeColor', z.string()),
repoIcon: salvagedOptional(
'repoIcon',
z.union([repoIconBranches.lucide, repoIconBranches.emoji, repoIconBranches.image])
),
repoIcon: salvagedOptional('repoIcon', mobileRepoIconSchema),
connectionId: salvagedOptional('connectionId', z.string().nullable()),
executionHostId: salvagedOptional('executionHostId', z.string().nullable())
})
@@ -184,6 +193,8 @@ export const hostViewSettingsSchema = z
*/
export const hostScreenUnreadReplySchema = z.unknown()
export type HostRepoCatalogRow = z.output<typeof hostRepoCatalogSchema>[number]
/** One decoded catalog icon: the members MobileRepoIcon reads, with the rest passed through. */
export type MobileHostRepoIcon = NonNullable<
z.output<typeof hostRepoCatalogSchema>[number]['repoIcon']
@@ -48,6 +48,7 @@ function screenState(clientRef: ClientRef, applied: boolean[]) {
[
'setActionError',
'setCatalogError',
'setDesktopRepos',
'setError',
'setHostLabelById',
'setHostHealthById',
@@ -57,9 +58,8 @@ function screenState(clientRef: ClientRef, applied: boolean[]) {
'setLastKnownWorktrees',
'setOptimisticActiveWorktreeIdentity',
'setPinnedIds',
'setRepoColorsByName',
'setRepoHostIdByRepoId',
'setRepoIconsByName',
'setServedProjectSections',
'setSleptIds',
'setWorktrees',
'setWorktreesLoaded'
@@ -60,7 +60,7 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
relayRecovery: { pairingRejected: false },
routeNotice: '',
router: { push: () => {} },
sectionsResult: { rawSections: [], sections: [], uniqueRepoColors: new Map() },
sectionsResult: { rawSections: [], sections: [], repoById: new Map() },
setDismissedNotice: () => {},
settings: { activeFilterCount: 0 },
state: {
@@ -69,7 +69,6 @@ function listWith(fields: { actionError: string; setActionError: (value: string)
catalogError: null,
collapsedGroups: new Set(),
groupMode: 'none',
repoIconsByName: new Map(),
search: '',
showSearch: false,
worktreesLoaded: true
@@ -68,7 +68,7 @@ function worktree(id: string, overrides: Partial<Worktree> = {}): Worktree {
}
const noop = () => {}
const stable = { ref: { current: null }, repoColors: new Map(), repoIcons: new Map() }
const stable = { ref: { current: null }, repoById: new Map() }
function listProps(worktrees: Worktree[]) {
// Each poll re-parses the payload, so rows are fresh objects even when nothing changed.
@@ -91,13 +91,12 @@ function listProps(worktrees: Worktree[]) {
now: 1_000,
reconnectAttempts: 0,
relayRecovery: { pairingRejected: false },
sectionsResult: { rawSections: sections, sections, uniqueRepoColors: stable.repoColors },
sectionsResult: { rawSections: sections, sections, repoById: stable.repoById },
settings: { activeFilterCount: 0, toggleWorktreeLineage: noop },
state: {
actionError: '',
setActionTarget: noop,
groupMode: 'none',
repoIconsByName: stable.repoIcons,
search: '',
showSearch: false,
worktreesLoaded: true
+11 -7
View File
@@ -1,3 +1,4 @@
import { useMemo } from 'react'
import { Platform, Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
import { ChevronDown, ChevronRight, Pin } from 'lucide-react-native'
import { AuthFailedBanner } from '../components/AuthFailedBanner'
@@ -13,6 +14,7 @@ import { HostWorkspaceListStates } from '../worktree/host-workspace-list-states'
import { getWorktreeStatus } from '../worktree/workspace-list-sections'
import { repoColor } from '../worktree/repo-color'
import { hostScreenStyles as styles } from './host-screen-styles'
import { buildRepoLookById } from './host-repo-looks'
import type { HostScreenController } from './use-host-screen-controller'
export function HostWorkspaceList({ controller }: { controller: HostScreenController }) {
@@ -40,7 +42,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
settings,
state
} = controller
const { rawSections, sections, uniqueRepoColors } = sectionsResult
const { rawSections, sections, repoById } = sectionsResult
const repoLookById = useMemo(() => buildRepoLookById(repoById), [repoById])
return (
<>
@@ -140,10 +143,11 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
const isCollapsed = state.collapsedGroups.has(section.key)
const rawSection = rawSections.find((s) => s.key === section.key)
const count = rawSection?.data.length ?? 0
const repoSectionColor =
state.groupMode === 'repo' ? uniqueRepoColors.get(section.title) : null
const repoSectionIcon =
state.groupMode === 'repo' ? state.repoIconsByName.get(section.title) : null
// Why: the desktop's header draws its anchor repo's colour and icon.
const repoSectionLook =
state.groupMode === 'repo' && section.repo ? repoLookById.get(section.repo.id) : null
const repoSectionColor = repoSectionLook?.color ?? null
const repoSectionIcon = repoSectionLook?.icon ?? null
return (
<Pressable
style={styles.sectionHeader}
@@ -187,8 +191,8 @@ export function HostWorkspaceList({ controller }: { controller: HostScreenContro
isReadOnly={isReadOnly}
now={now}
status={getWorktreeStatus(item)}
repoColor={uniqueRepoColors.get(item.repo) ?? repoColor(item.repo)}
repoIcon={state.repoIconsByName.get(item.repo) ?? null}
repoColor={repoLookById.get(item.repoId)?.color ?? repoColor(item.repo)}
repoIcon={repoLookById.get(item.repoId)?.icon ?? null}
hideRepo={state.groupMode === 'repo'}
onPress={actions.openWorktreeSession}
onLongPress={
@@ -0,0 +1,51 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { describe, expect, it, vi } from 'vitest'
const request = vi.hoisted(() => vi.fn())
vi.mock('../worktree/project-sections-operations', () => ({
projectSectionsRead: { request },
readServedProjectSections: () => ({ kind: 'answered', value: null })
}))
import type { RpcClient } from '../transport/rpc-client'
import { useHostProjectSections } from './use-host-project-sections'
function clientDouble(): RpcClient {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the hook only compares the client by identity and hands it to the mocked read.
return {} as RpcClient
}
describe('useHostProjectSections', () => {
it('fetches the catalog at once when the screen moves to another host client', async () => {
request.mockResolvedValue({ ok: true })
const clientRef: { current: RpcClient | null } = { current: null }
let refresh: (options?: { force?: boolean }) => Promise<void> = async () => {}
function Probe({ client, hostId }: { client: RpcClient; hostId: string }) {
refresh = useHostProjectSections({
client,
connState: 'connected',
hostId,
state: { clientRef, setServedProjectSections: () => {} }
})
return null
}
const first = clientDouble()
const second = clientDouble()
clientRef.current = first
let tree!: ReturnType<typeof create>
act(() => {
tree = create(createElement(Probe, { client: first, hostId: 'a' }))
})
await act(async () => refresh())
await act(async () => refresh())
expect(request).toHaveBeenCalledTimes(1)
clientRef.current = second
act(() => tree.update(createElement(Probe, { client: second, hostId: 'b' })))
await act(async () => refresh())
expect(request).toHaveBeenCalledTimes(2)
expect(request).toHaveBeenLastCalledWith(second)
})
})
@@ -0,0 +1,56 @@
import { useCallback, useRef } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
projectSectionsRead,
readServedProjectSections
} from '../worktree/project-sections-operations'
import type { HostScreenState } from './use-host-screen-state'
const PROJECT_SECTIONS_REFRESH_MS = 60_000
/**
* The desktop sidebar's Project catalog, refreshed with repo metadata but as its own read, so a
* slow or missing answer never holds the repo labels back.
*/
export function useHostProjectSections(args: {
client: RpcClient | null
connState: ConnectionState
hostId: string | undefined
state: Pick<HostScreenState, 'clientRef' | 'setServedProjectSections'>
}): (options?: { force?: boolean }) => Promise<void> {
const { client, connState, hostId, state } = args
const { clientRef, setServedProjectSections } = state
const inFlight = useRef(new WeakSet<RpcClient>())
// Per client, so a reused screen for another host fetches its catalog at once.
const fetchedAt = useRef(new WeakMap<RpcClient, number>())
return useCallback(
async (options: { force?: boolean } = {}) => {
if (!client || connState !== 'connected' || !hostId || inFlight.current.has(client)) {
return
}
if (
!options.force &&
Date.now() - (fetchedAt.current.get(client) ?? 0) < PROJECT_SECTIONS_REFRESH_MS
) {
return
}
inFlight.current.add(client)
try {
const reply = await projectSectionsRead.request(client).catch(() => null)
if (clientRef.current !== client) {
return
}
const sections = readServedProjectSections(reply)
if (sections.kind === 'answered') {
fetchedAt.current.set(client, Date.now())
setServedProjectSections(sections.value)
}
} finally {
inFlight.current.delete(client)
}
},
[client, clientRef, connState, hostId, setServedProjectSections]
)
}
@@ -5,12 +5,13 @@ import { setCachedRepos } from '../cache/repo-cache'
import type { RpcAcceptedResult } from '../transport/rpc-accepted-result'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState, RpcResponse } from '../transport/types'
import { repoColor } from '../worktree/repo-color'
import {
buildHostLabelById,
buildRepoHostIdByRepoId
} from '../worktree/worktree-host-context-labels'
import { buildSshHostHealthById } from '../worktree/worktree-host-health'
import type { SectionRepo } from '../worktree/workspace-list-types'
import type { HostRepoCatalogRow } from './host-screen-reply-schema'
import {
hostPlatformRead,
hostRepoCatalogRead,
@@ -41,6 +42,18 @@ function acceptedMetadata<Value>(
return verdict.accepted ? verdict.value : null
}
/** A desktop repo.list row as Project sections read it, for a desktop that serves no catalog. */
function desktopSectionRepo(repo: HostRepoCatalogRow): SectionRepo {
return {
id: repo.id,
displayName: repo.displayName,
path: repo.path ?? repo.id,
executionHostId: getRepoExecutionHostId(repo),
...(repo.badgeColor ? { badgeColor: repo.badgeColor } : {}),
...(repo.repoIcon ? { repoIcon: repo.repoIcon } : {})
}
}
function readHostSettingOverrides(result: unknown): unknown {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Preserve the established response shape at this boundary.
return (result as { hostSettingOverrides?: unknown } | null)?.hostSettingOverrides
@@ -60,11 +73,9 @@ export function useHostRepoMetadata(args: {
repoMetadataFetchedAtRef,
setHostLabelById,
setHostHealthById,
setDesktopRepos,
setHostPlatform,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName
setRepoHostIdByRepoId
} = state
const fetchRepoMetadata = useCallback(
@@ -101,22 +112,7 @@ export function useHostRepoMetadata(args: {
const catalog = repos.value
repoMetadataFetchedAtRef.current = Date.now()
setCachedRepos(requestHostId, catalog)
setRepoColorsByName(
new Map(
catalog.map((repo) => [
repo.displayName,
repo.badgeColor || repoColor(repo.displayName)
])
)
)
setRepoIconsByName(
new Map(
catalog.flatMap((repo) =>
repo.repoIcon ? [[repo.displayName, repo.repoIcon] as const] : []
)
)
)
setRepoIdsByName(new Map(catalog.map((repo) => [repo.displayName, repo.id])))
setDesktopRepos(catalog.map(desktopSectionRepo))
setRepoHostIdByRepoId(buildRepoHostIdByRepoId(catalog))
// Why: rows name their host when the list spans hosts or an SSH host may be unhealthy,
// so a local-only catalog never pays for the label lookups. Counted over repos, not the
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useLocalSearchParams, usePathname } from 'expo-router'
import { useRouteHandoff } from '../navigation/route-handoff'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -16,7 +16,9 @@ import {
} from '../transport/client-context-connection-metrics'
import { useWorkspaceSections } from '../worktree/use-workspace-sections'
import { useHostDisplayWorktrees } from './use-host-display-worktrees'
import { useHostRepoMetadata } from './use-host-repo-metadata'
import { buildProjectSectionCatalog } from '../worktree/project-section-catalog'
import { useHostProjectSections } from './use-host-project-sections'
import { useHostRepoMetadata, type FetchHostRepoMetadata } from './use-host-repo-metadata'
import { useHostScreenIdentity } from './use-host-screen-identity'
import { useHostScreenState } from './use-host-screen-state'
import { useHostViewSettings } from './use-host-view-settings'
@@ -72,7 +74,15 @@ export function useHostScreenController({
)
useHostScreenIdentity({ client, hostId, state })
const fetchRepoMetadata = useHostRepoMetadata({ client, connState, hostId, state })
const fetchRepoMetadataOnly = useHostRepoMetadata({ client, connState, hostId, state })
const fetchProjectSections = useHostProjectSections({ client, connState, hostId, state })
const fetchRepoMetadata = useCallback<FetchHostRepoMetadata>(
(options) => {
void fetchProjectSections(options)
return fetchRepoMetadataOnly(options)
},
[fetchProjectSections, fetchRepoMetadataOnly]
)
const catalog = useHostWorktreeCatalog({
client,
connState,
@@ -104,6 +114,15 @@ export function useHostScreenController({
const showNewWorktree = resolvedRouteActionState.showNewWorktree
const displayWorktrees = useHostDisplayWorktrees(connState, state, hostId)
const projectCatalog = useMemo(
() =>
buildProjectSectionCatalog(
state.servedProjectSections,
state.desktopRepos,
state.visibleHostIds
),
[state.servedProjectSections, state.desktopRepos, state.visibleHostIds]
)
const sectionsResult = useWorkspaceSections({
displayWorktrees,
sortMode: state.sortMode,
@@ -112,8 +131,8 @@ export function useHostScreenController({
groupMode: state.groupMode,
pinnedIds: state.pinnedIds,
showPinnedInGroups: state.showPinnedInGroups,
repoIdsByName: state.repoIdsByName,
repoColorsByName: state.repoColorsByName,
projectCatalog,
filterCatalogRepos: state.servedProjectSections?.repos ?? state.desktopRepos,
collapsedGroups: state.collapsedGroups,
workspaceStatuses: state.workspaceStatuses
})
@@ -18,15 +18,15 @@ export function useHostScreenIdentity(args: {
setCatalogError,
setError,
setHostLabelById,
setServedProjectSections,
setDesktopRepos,
setHostHealthById,
setHostName,
setHostPlatform,
setHostStoredDescriptor,
setLastKnownWorktrees,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setShowPinnedInGroups,
setWorktrees,
setWorktreesLoaded
@@ -59,10 +59,10 @@ export function useHostScreenIdentity(args: {
setHostName('')
setHostStoredDescriptor(null)
setError('')
setRepoColorsByName(new Map())
setRepoIconsByName(new Map())
setRepoHostIdByRepoId(new Map())
setHostLabelById(new Map())
setServedProjectSections(null)
setDesktopRepos([])
setHostHealthById(new Map())
setHostPlatform(null)
setShowPinnedInGroups(false)
+12 -11
View File
@@ -13,7 +13,8 @@ import type {
MobileViewState
} from '../worktree/workspace-view-settings'
import type { FilterState, Worktree } from '../worktree/workspace-list-sections'
import type { MobileHostRepoIcon } from './host-screen-reply-schema'
import type { ServedProjectSections } from '../worktree/project-sections-operations'
import type { SectionRepo } from '../worktree/workspace-list-types'
import { NO_SERVER_WORKSPACES, type ServerWorkspaces } from '../worktree/server-workspaces'
export function useHostScreenState(hostId: string | undefined, action: string | undefined) {
@@ -39,8 +40,6 @@ export function useHostScreenState(hostId: string | undefined, action: string |
const [optimisticActiveWorktreeIdentity, setOptimisticActiveWorktreeIdentity] = useState<
string | null
>(null)
const [repoColorsByName, setRepoColorsByName] = useState<Map<string, string>>(new Map())
const [repoIconsByName, setRepoIconsByName] = useState<Map<string, MobileHostRepoIcon>>(new Map())
const [hostName, setHostName] = useState('')
// The stored name identity, loaded with the name so the header can label an offline host.
const [hostStoredDescriptor, setHostStoredDescriptor] = useState<{
@@ -66,8 +65,12 @@ export function useHostScreenState(hostId: string | undefined, action: string |
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
DEFAULT_MOBILE_WORKSPACE_STATUSES
)
// displayName → repo id: filters key on repo id, but section headers/rows key on displayName, so bridge the two.
const [repoIdsByName, setRepoIdsByName] = useState<Map<string, string>>(new Map())
// The desktop sidebar's Project grouping inputs; null for a desktop that serves none.
const [servedProjectSections, setServedProjectSections] = useState<ServedProjectSections | null>(
null
)
// The desktop's own repos from repo.list: an older desktop's rows group by these.
const [desktopRepos, setDesktopRepos] = useState<readonly SectionRepo[]>([])
// Host-label inputs for rows: repo → host, SSH/override labels, and the host's own platform.
const [repoHostIdByRepoId, setRepoHostIdByRepoId] = useState<Map<string, ExecutionHostId>>(
new Map()
@@ -133,10 +136,9 @@ export function useHostScreenState(hostId: string | undefined, action: string |
newWorktreeModalVisibleRef,
optimisticActiveWorktreeIdentity,
pinnedIds,
repoColorsByName,
repoHostIdByRepoId,
repoIconsByName,
repoIdsByName,
servedProjectSections,
desktopRepos,
repoMetadataFetchedAtRef,
routeActionState,
search,
@@ -159,10 +161,9 @@ export function useHostScreenState(hostId: string | undefined, action: string |
setLastKnownWorktrees,
setOptimisticActiveWorktreeIdentity,
setPinnedIds,
setRepoColorsByName,
setRepoHostIdByRepoId,
setRepoIconsByName,
setRepoIdsByName,
setServedProjectSections,
setDesktopRepos,
setRouteActionState,
setSearch,
setServerNotice,
@@ -1,30 +1,20 @@
import type { ComponentProps, ReactNode } from 'react'
import { Pressable, View } from 'react-native'
// A held press selects text natively; claiming it keeps that release from counting as a tap.
function ignoreLongPress(): void {}
/** A message body that opens the actions sheet on long press (Android, which has no inline
* selection) or reveals its copy button on tap (platforms with inline selection). */
* selection); elsewhere a plain view, so a held press selects text natively. */
export function MobileNativeChatLongPressContent({
onLongPress,
onPress,
style,
children
}: {
onLongPress?: () => void
onPress?: () => void
style: ComponentProps<typeof View>['style']
children: ReactNode
}): React.JSX.Element {
return onLongPress || onPress ? (
return onLongPress ? (
// Not one accessible element: screen readers keep reaching the text and controls inside.
<Pressable
onLongPress={onLongPress ?? ignoreLongPress}
onPress={onPress}
accessible={false}
style={style}
>
<Pressable onLongPress={onLongPress} accessible={false} style={style}>
{children}
</Pressable>
) : (
@@ -76,7 +76,7 @@ describe('MobileNativeChatMessage on Android', () => {
expect(byType('MessageActionsSheet')).toHaveLength(0)
expect(
byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message')
).toHaveLength(0)
).toHaveLength(1)
const [bubble] = byType('Pressable')
expect(typeof bubble!.props.onLongPress).toBe('function')
@@ -155,7 +155,7 @@ describe('MobileNativeChatMessage on Android', () => {
}
expect(
byType('Pressable').filter((node) => node.props.accessibilityLabel === 'Copy message')
).toHaveLength(0)
).toHaveLength(1)
act(() => byType('Pressable')[0]!.props.onLongPress())
expect(byType('MessageActionsSheet')).toHaveLength(1)
})
@@ -69,18 +69,10 @@ describe('iOS whole-message copy', () => {
}
return node
}
function tapMessage() {
const body = renderer!.root
.findByType(MobileNativeChatLongPressContent)
.findAll((node) => String(node.type) === 'Pressable')[0]
act(() => body?.props.onPress?.())
}
// Every copy case starts from a tapped message, the only state that shows the button.
function render(value = message) {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message: value }))
})
tapMessage()
}
beforeEach(() => {
vi.clearAllMocks()
@@ -122,11 +114,8 @@ describe('iOS whole-message copy', () => {
for (const node of nodes('MobileMarkdown')) {
expect(node.props.onLongPress).toBeUndefined()
}
// A held press selects text natively: no actions sheet, and no copy toggle.
const press = body.findAll((node) => String(node.type) === 'Pressable')[0]
act(() => press?.props.onLongPress())
expect(nodes('MessageActionsSheet')).toHaveLength(0)
expect(copyButton()).toBeDefined()
// A held press selects text natively: the body claims no press of its own.
expect(body.findAll((node) => String(node.type) === 'Pressable')).toHaveLength(0)
await act(async () => copy.props.onPress())
expect(writeText.mock.calls).toEqual([[first + '\n\n' + last]])
expect(nodes('MessageActionsSheet')).toHaveLength(0)
@@ -141,25 +130,13 @@ describe('iOS whole-message copy', () => {
}
)
it('shows the copy button only after a tap on the message, and hides it on the next', () => {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message }))
})
const hasCopy = () =>
nodes('Pressable').some((node) => node.props.accessibilityLabel === 'Copy message')
const body = renderer!.root
.findByType(MobileNativeChatLongPressContent)
.findAll((node) => String(node.type) === 'Pressable')[0]
expect(hasCopy()).toBe(false)
expect(body?.props).toMatchObject({ accessible: false })
act(() => body?.props.onLongPress())
expect(hasCopy()).toBe(false)
tapMessage()
expect(hasCopy()).toBe(true)
tapMessage()
expect(hasCopy()).toBe(false)
})
it.each(['user', 'assistant'] as const)(
'shows the %s message copy button without a tap',
(role) => {
render({ ...message, role })
expect(copyButton()).toBeDefined()
}
)
it.each([
'Please repair this chart:\n::orca-visual{file="usage.html"}\nKeep this text.',
@@ -211,9 +211,6 @@ function MobileNativeChatMessageImpl({
// Keep the memoized Markdown context stable as the message streams.
const openActions = useCallback(() => setActionsOpen(true), [])
const onLongPress = INLINE_TEXT_SELECTION ? undefined : openActions
// Like desktop's hover-only copy: a tap on the message shows it, another tap hides it.
const [copyShown, setCopyShown] = useState(false)
const toggleCopy = useCallback(() => setCopyShown((shown) => !shown), [])
const renderVisual = useContext(MobileNativeChatVisualContext) ?? undefined
// Structured replies grow in place: only the last block of the newest row may still be typing.
const growingBlock =
@@ -262,8 +259,9 @@ function MobileNativeChatMessageImpl({
</>
)
}
// Always shown, so the row never shifts when copy appears.
const copyText =
INLINE_TEXT_SELECTION && (message.role === 'user' || message.role === 'assistant')
message.role === 'user' || message.role === 'assistant'
? nativeChatMessagePlainText(message)
: ''
return (
@@ -273,7 +271,6 @@ function MobileNativeChatMessageImpl({
<View style={[styles.row, isUser && styles.rowUser]}>
<Content
onLongPress={onLongPress}
onPress={copyText.length > 0 ? toggleCopy : undefined}
style={[
styles.content,
isUser && styles.userBubble,
@@ -322,7 +319,7 @@ function MobileNativeChatMessageImpl({
) : null}
</Content>
{unsentNotice ? <NativeText style={styles.unsentNotice}>{unsentNotice}</NativeText> : null}
{copyShown && copyText.length > 0 ? (
{copyText.length > 0 ? (
<MobileSourceCopyButton
key={message.id}
text={copyText}
+163 -154
View File
@@ -7,6 +7,7 @@ import {
AlertTriangle,
colors,
ActionSheetModal,
BottomDrawerModalHost,
GitBranch,
ConfirmModal
} from './mobile-tasks-dependencies'
@@ -180,202 +181,210 @@ export function MobileTasksLegacySurface({ model }: { model: ConnectionPresentat
{renderMobileTasksListSurface(model)}
{renderMobileTasksProviderPicker(model)}
{/* Why: Tasks stacks sheets (an issue detail under Create Workspace or a confirm), and iOS
refuses a native modal over a presented sibling, so they share one Modal, ordered by zIndex. */}
<BottomDrawerModalHost>
{renderMobileTasksProviderPicker(model)}
{renderMobileTasksRepoPicker(model)}
{renderMobileTasksRepoPicker(model)}
{renderMobileTasksGitHubIssueSourcePicker(model)}
{renderMobileTasksGitHubIssueSourcePicker(model)}
{renderMobileTasksGitHubViewPicker(model)}
{renderMobileTasksGitHubViewPicker(model)}
{renderMobileTasksGitHubPresetPicker(model)}
{renderMobileTasksGitHubPresetPicker(model)}
{renderMobileTasksPagePicker(model)}
{renderMobileTasksPagePicker(model)}
{renderMobileTasksGitHubProjectPicker(model)}
{renderMobileTasksGitHubProjectPicker(model)}
{renderMobileTasksGitHubProjectViewPicker(model)}
{renderMobileTasksGitHubProjectViewPicker(model)}
{renderMobileTasksGitHubProjectSortPicker(model)}
{renderMobileTasksGitHubProjectSortPicker(model)}
{renderMobileTasksGitHubProjectFieldsPicker(model)}
{renderMobileTasksGitHubProjectFieldsPicker(model)}
{renderMobileTasksGitLabViewPicker(model)}
{renderMobileTasksGitLabViewPicker(model)}
{renderMobileTasksGitLabFilterPicker(model)}
{renderMobileTasksGitLabFilterPicker(model)}
{renderMobileTasksLinearFilterPicker(model)}
{renderMobileTasksJiraFilterPicker(model)}
{renderMobileTasksJiraSitePicker(model)}
{renderMobileTasksLinearFilterPicker(model)}
{renderMobileTasksJiraFilterPicker(model)}
{renderMobileTasksJiraSitePicker(model)}
{renderMobileTasksLinearWorkspacePicker(model)}
{renderMobileTasksLinearWorkspacePicker(model)}
{renderMobileTasksLinearTeamPicker(model)}
{renderMobileTasksLinearTeamPicker(model)}
{renderMobileTasksLinearStatusPicker(model)}
{renderMobileTasksLinearStatusPicker(model)}
{renderMobileTasksLinearViewPicker(model)}
{renderMobileTasksLinearViewPicker(model)}
{renderMobileTasksLinearGroupPicker(model)}
{renderMobileTasksLinearGroupPicker(model)}
{renderMobileTasksLinearOrderPicker(model)}
{renderMobileTasksLinearOrderPicker(model)}
{renderMobileTasksLinearDisplayPicker(model)}
{renderMobileTasksLinearDisplayPicker(model)}
{renderMobileTasksSortPicker(model)}
{renderMobileTasksSortPicker(model)}
{renderMobileTasksCreateDrawer(model)}
{renderMobileTasksCreateDrawer(model)}
{renderMobileTasksCreateTargetPicker(model)}
{renderMobileTasksCreateTargetPicker(model)}
{renderMobileTasksLinearConnectDrawer(model)}
{renderMobileTasksLinearConnectDrawer(model)}
{renderMobileTasksWorkspaceCreateTargetPicker(model)}
{renderMobileTasksWorkspaceCreateTargetPicker(model)}
{renderMobileTasksWorkspaceCreateDrawer(model)}
{renderMobileTasksWorkspaceCreateDrawer(model)}
{renderMobileTasksWorkspaceCreateRepoPicker(model)}
{renderMobileTasksWorkspaceCreateRepoPicker(model)}
{renderMobileTasksWorkspaceAgentPicker(model)}
{renderMobileTasksWorkspaceAgentPicker(model)}
{renderMobileTasksWorkspaceBaseBranchPicker(model)}
{renderMobileTasksWorkspaceBaseBranchPicker(model)}
{renderMobileTasksWorkspaceSparsePicker(model)}
{renderMobileTasksWorkspaceSparsePicker(model)}
{renderMobileTasksWorkspaceSparseDrawer(model)}
{renderMobileTasksWorkspaceSparseDrawer(model)}
{renderMobileTasksSetupTrustDrawer(model)}
{renderMobileTasksSetupTrustDrawer(model)}
{renderMobileTasksOrcaYamlTrustDrawer(model)}
{renderMobileTasksOrcaYamlTrustDrawer(model)}
{renderMobileTasksProjectMissingRepoDrawer(model)}
{renderMobileTasksProjectMissingRepoDrawer(model)}
{renderMobileTasksProjectDetailDrawer(model)}
{renderMobileTasksProjectDetailDrawer(model)}
{renderMobileTasksItemDetailDrawer(model)}
{renderMobileTasksItemDetailDrawer(model)}
<ActionSheetModal
visible={taskUiReady && mergeMethodProjectRow != null}
title="Merge method"
message="Choose how this pull request should be merged."
actions={
mergeMethodProjectRow
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
label: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
}
}))
: []
}
onClose={() => setMergeMethodProjectRow(null)}
/>
<ActionSheetModal
visible={taskUiReady && mergeMethodTaskItem != null}
title="Merge method"
message={
mergeMethodTaskItem?.provider === 'gitlab'
? 'Choose how this merge request should be merged.'
: 'Choose how this pull request should be merged.'
}
actions={
mergeMethodTaskItem
? (mergeMethodTaskItem.provider === 'gitlab'
? (['merge', 'squash', 'rebase'] as const)
: (['squash', 'merge', 'rebase'] as const)
).map((method) => ({
label:
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
? 'Merge'
: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
const item = mergeMethodTaskItem
setPendingHostedMerge({ item, method })
}
}))
: []
}
onClose={() => setMergeMethodTaskItem(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedMerge != null}
title={
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
}
message={pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined}
confirmLabel={
pendingHostedMerge ? getHostedReviewMergeMethodLabel(pendingHostedMerge.method) : 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingHostedMerge) {
return
<ActionSheetModal
visible={taskUiReady && mergeMethodProjectRow != null}
title="Merge method"
message="Choose how this pull request should be merged."
actions={
mergeMethodProjectRow
? (['squash', 'merge', 'rebase'] as const).map((method) => ({
label: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method })
}
}))
: []
}
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
}}
onCancel={() => setPendingHostedMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingProjectGitHubMerge != null}
title="Merge Pull Request"
message={
pendingProjectGitHubMerge
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
: undefined
}
confirmLabel={
pendingProjectGitHubMerge
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
: 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingProjectGitHubMerge) {
return
onClose={() => setMergeMethodProjectRow(null)}
/>
<ActionSheetModal
visible={taskUiReady && mergeMethodTaskItem != null}
title="Merge method"
message={
mergeMethodTaskItem?.provider === 'gitlab'
? 'Choose how this merge request should be merged.'
: 'Choose how this pull request should be merged.'
}
void mergeProjectGitHubPullRequest(
pendingProjectGitHubMerge.row,
pendingProjectGitHubMerge.method
)
}}
onCancel={() => setPendingProjectGitHubMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedStateChange != null}
title={
pendingHostedStateChange
? getHostedStateConfirmTitle(pendingHostedStateChange)
: 'Update Item'
}
message={
pendingHostedStateChange
? getHostedStateConfirmMessage(pendingHostedStateChange)
: undefined
}
confirmLabel={
pendingHostedStateChange
? getHostedStateConfirmLabel(pendingHostedStateChange)
: 'Confirm'
}
destructive={pendingHostedStateChange?.nextState === 'closed'}
onConfirm={() => {
if (!taskUiReady || !pendingHostedStateChange) {
return
actions={
mergeMethodTaskItem
? (mergeMethodTaskItem.provider === 'gitlab'
? (['merge', 'squash', 'rebase'] as const)
: (['squash', 'merge', 'rebase'] as const)
).map((method) => ({
label:
mergeMethodTaskItem.provider === 'gitlab' && method === 'merge'
? 'Merge'
: getHostedReviewMergeMethodLabel(method),
icon: GitBranch,
onPress: () => {
const item = mergeMethodTaskItem
setPendingHostedMerge({ item, method })
}
}))
: []
}
if (pendingHostedStateChange.source === 'task') {
if (pendingHostedStateChange.item.provider === 'gitlab') {
void toggleGitLabStatus(pendingHostedStateChange.item)
onClose={() => setMergeMethodTaskItem(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedMerge != null}
title={
pendingHostedMerge?.item.provider === 'gitlab' ? 'Merge Request' : 'Merge Pull Request'
}
message={
pendingHostedMerge ? getHostedMergeConfirmMessage(pendingHostedMerge) : undefined
}
confirmLabel={
pendingHostedMerge
? getHostedReviewMergeMethodLabel(pendingHostedMerge.method)
: 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingHostedMerge) {
return
}
void toggleGitHubStatus(pendingHostedStateChange.item)
return
void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method)
}}
onCancel={() => setPendingHostedMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingProjectGitHubMerge != null}
title="Merge Pull Request"
message={
pendingProjectGitHubMerge
? getProjectGitHubMergeConfirmMessage(pendingProjectGitHubMerge)
: undefined
}
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
state: pendingHostedStateChange.nextState
})
}}
onCancel={() => setPendingHostedStateChange(null)}
/>
confirmLabel={
pendingProjectGitHubMerge
? getHostedReviewMergeMethodLabel(pendingProjectGitHubMerge.method)
: 'Merge'
}
onConfirm={() => {
if (!taskUiReady || !pendingProjectGitHubMerge) {
return
}
void mergeProjectGitHubPullRequest(
pendingProjectGitHubMerge.row,
pendingProjectGitHubMerge.method
)
}}
onCancel={() => setPendingProjectGitHubMerge(null)}
/>
<ConfirmModal
visible={taskUiReady && pendingHostedStateChange != null}
title={
pendingHostedStateChange
? getHostedStateConfirmTitle(pendingHostedStateChange)
: 'Update Item'
}
message={
pendingHostedStateChange
? getHostedStateConfirmMessage(pendingHostedStateChange)
: undefined
}
confirmLabel={
pendingHostedStateChange
? getHostedStateConfirmLabel(pendingHostedStateChange)
: 'Confirm'
}
destructive={pendingHostedStateChange?.nextState === 'closed'}
onConfirm={() => {
if (!taskUiReady || !pendingHostedStateChange) {
return
}
if (pendingHostedStateChange.source === 'task') {
if (pendingHostedStateChange.item.provider === 'gitlab') {
void toggleGitLabStatus(pendingHostedStateChange.item)
return
}
void toggleGitHubStatus(pendingHostedStateChange.item)
return
}
void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, {
state: pendingHostedStateChange.nextState
})
}}
onCancel={() => setPendingHostedStateChange(null)}
/>
</BottomDrawerModalHost>
</SafeAreaView>
)
}
@@ -65,6 +65,7 @@ export { classifyConnection } from '../transport/connection-health'
export { StatusDot } from '../components/StatusDot'
export { ActionSheetModal } from '../components/ActionSheetModal'
export { BottomDrawer } from '../components/BottomDrawer'
export { BottomDrawerModalHost } from '../components/bottom-drawer-modal-host'
export { ConfirmModal } from '../components/ConfirmModal'
export { MobileMarkdown } from '../components/MobileMarkdown'
export { MobileAgentIcon } from '../components/MobileAgentIcon'
@@ -22,7 +22,8 @@ import {
SHOW_MOBILE_PROJECT_METADATA_EDITORS,
githubProjectOptionColor,
canCreateWorkspaceFromProjectRow,
projectRowType
projectRowType,
TASK_SECONDARY_DRAWER_Z_INDEX
} from './mobile-tasks-legacy-foundation'
import { renderMobileTasksProjectFieldEditors } from './mobile-tasks-project-field-editors'
import {
@@ -45,6 +46,8 @@ export function renderMobileTasksProjectMissingRepoDrawer(model: ConnectionPrese
onClose={() => {
setProjectRepoNotInOrca(null)
}}
// Why: opens over the project detail, which renders after it in the shared host.
zIndex={TASK_SECONDARY_DRAWER_Z_INDEX}
>
{projectRepoNotInOrca ? (
<View>
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import { buildProjectSectionCatalog } from './project-section-catalog'
import type { ServedProjectSections } from './project-sections-operations'
const uploaded = { type: 'image', src: 'data:image/png;base64,AAAA' }
type ServedRepo = ServedProjectSections['repos'][number]
function repo(
id: string,
executionHostId?: ServedRepo['executionHostId'],
repoIcon?: unknown
): ServedRepo {
return {
id,
displayName: 'orca',
path: `/repos/${id}`,
badgeColor: undefined,
addedAt: undefined,
connectionId: undefined,
executionHostId,
...(repoIcon ? { repoIcon } : {})
}
}
describe('buildProjectSectionCatalog', () => {
it("restores an uploaded icon from the desktop's repo.list only for the same host's repo", () => {
const served: ServedProjectSections = {
repos: [repo('r1'), repo('r1', 'runtime:m4air')],
projects: [],
setups: [],
projectOrderBy: 'manual'
}
const catalog = buildProjectSectionCatalog(served, [repo('r1', undefined, uploaded)], null)
expect(catalog.repos.map((entry) => entry.repoIcon)).toEqual([uploaded, undefined])
})
})
@@ -0,0 +1,38 @@
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import { getLogicalRepoOrderRankById } from '../../../src/shared/project-section-order'
import type { ServedProjectSections } from './project-sections-operations'
import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections'
import type { SectionRepo } from './workspace-list-types'
/**
* Project grouping's inputs: the desktop sidebar's own catalog when it serves one, else (an older
* desktop) its own repos with no projects, so rows group by repo. Only shown hosts' repos open
* empty sections; the grouping itself spans every host, as the desktop's does.
*/
export function buildProjectSectionCatalog(
served: ServedProjectSections | null,
desktopRepos: readonly SectionRepo[],
visibleHostIds: ReadonlySet<ExecutionHostId> | null
): ProjectSectionCatalog {
const shown = (repos: readonly SectionRepo[]) =>
visibleHostIds
? repos.filter((repo) => visibleHostIds.has(getRepoExecutionHostId(repo)))
: repos
if (!served) {
return { ...NO_PROJECT_SECTION_CATALOG, repos: shown(desktopRepos) }
}
// Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them.
const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}`
const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon]))
const repos = served.repos.map((repo) => {
const icon = repo.repoIcon ? undefined : iconByKey.get(iconKey(repo))
return icon ? { ...repo, repoIcon: icon } : repo
})
return {
repos: shown(repos),
grouping: { projects: served.projects, projectHostSetups: served.setups },
// Why: served repos come in the sidebar's header order, cross-host manual order included.
repoOrder: getLogicalRepoOrderRankById(served.repos.map((repo) => repo.id)),
projectOrderBy: served.projectOrderBy
}
}
@@ -0,0 +1,104 @@
import { z } from 'zod'
import { MOBILE_PROJECT_SECTIONS_METHOD } from '../../../src/shared/mobile-project-sections-contract'
import { normalizeExecutionHostId } from '../../../src/shared/execution-host'
import type { ProjectHostSetupMethod } from '../../../src/shared/project-types'
import type { RepoKind } from '../../../src/shared/repo-types'
import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import { hostUnionArms, salvagedOptional, salvagingArray } from '../../../src/shared/zod-salvage'
import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc-operation'
import { rpcResultVariant } from '../transport/rpc-operation-result-reader'
import { isMobileMethodUnavailableError } from '../transport/mobile-method-unavailable'
import type { RpcResponse } from '../transport/types'
import type { SectionRepo } from './workspace-list-types'
const SETUP_METHOD_ARMS = hostUnionArms<ProjectHostSetupMethod>({
'legacy-repo': true,
'imported-existing-folder': true,
cloned: true,
provisioned: true
})
const REPO_KIND_ARMS = hostUnionArms<RepoKind>({ git: true, folder: true })
const PROJECT_ORDER_BY_ARMS = hostUnionArms<ProjectOrderBy>({ manual: true, recent: true })
// A host id the desktop spells; normalized, so a spelling this build cannot read drops.
const hostIdSchema = z.string().transform((value, ctx) => {
const hostId = normalizeExecutionHostId(value)
if (!hostId) {
ctx.addIssue({ code: 'custom', message: 'unknown host id' })
return z.NEVER
}
return hostId
})
const repoSchema = z.object({
id: z.string(),
displayName: z.string(),
path: z.string(),
badgeColor: salvagedOptional('badgeColor', z.string()),
addedAt: salvagedOptional('addedAt', z.number()),
connectionId: salvagedOptional('connectionId', z.string().nullable()),
executionHostId: salvagedOptional('executionHostId', hostIdSchema),
// Decoded where drawn (readMobileRepoIcon), so an arm this build lacks still keeps the row.
repoIcon: z.unknown().optional()
}) satisfies z.ZodType<SectionRepo, unknown>
const setupSchema = z.object({
id: z.string(),
projectId: z.string(),
hostId: hostIdSchema,
repoId: z.string(),
path: z.string(),
displayName: z.string(),
// Why the fallback: only 'provisioned' changes grouping, so a newer arm groups as a checkout.
setupMethod: z.enum(SETUP_METHOD_ARMS).catch('legacy-repo'),
kind: salvagedOptional('kind', z.enum(REPO_KIND_ARMS)),
connectionId: salvagedOptional('connectionId', z.string().nullable()),
executionHostId: salvagedOptional('executionHostId', hostIdSchema)
})
const catalogSchema = z.object({
repos: salvagingArray(repoSchema),
projects: salvagingArray(z.object({ id: z.string(), displayName: z.string() })),
setups: salvagingArray(setupSchema),
projectOrderBy: z.enum(PROJECT_ORDER_BY_ARMS).catch('manual')
})
/** The desktop sidebar's Project grouping inputs, as this phone reads them. */
export type ServedProjectSections = z.output<typeof catalogSchema>
/** The sidebar's Project grouping inputs; null until a desktop window has published them. */
export const projectSectionsRead = bindDeferredRpcOperation(
defineRpcOperation({
name: 'mobileRelay.projectSections-or-skip',
method: MOBILE_PROJECT_SECTIONS_METHOD,
acceptance: 'success-result-or-skip',
barrier: 'after-caller-barrier',
read: rpcResultVariant(
'project-sections',
z.object({ catalog: catalogSchema.nullable() }).transform(({ catalog }) => catalog)
)
})
)
/**
* The desktop's sidebar catalog: absent (null) on a desktop that predates the read, and left
* as it was after a transient failure, so a dropped reply never regroups the list.
*/
export function readServedProjectSections(
reply: RpcResponse | null
): { kind: 'answered'; value: ServedProjectSections | null } | { kind: 'kept' } {
if (!reply) {
return { kind: 'kept' }
}
if (!reply.ok) {
return isMobileMethodUnavailableError(reply.error.code, reply.error.message)
? { kind: 'answered', value: null }
: { kind: 'kept' }
}
try {
const verdict = projectSectionsRead.interpret(reply)
return verdict.accepted ? { kind: 'answered', value: verdict.value } : { kind: 'kept' }
} catch {
return { kind: 'kept' }
}
}
+21 -30
View File
@@ -2,18 +2,15 @@ import { useMemo } from 'react'
import type { WorkspaceStatusDefinition } from '../../../src/shared/worktree/types'
import type { MobileGroupMode, MobileSortMode } from './workspace-view-settings'
import {
buildSectionRepoMap,
buildSections,
type FilterState,
type ProjectSectionCatalog,
type Section,
type SectionRepo,
type Worktree
} from './workspace-list-sections'
import { repoColor } from './repo-color'
export type WorkspaceSectionRepo = {
name: string
id: string
color: string
}
import { buildFilterRepos, type WorkspaceSectionRepo } from './workspace-filter-repos'
export function useWorkspaceSections(args: {
displayWorktrees: Worktree[]
@@ -23,15 +20,17 @@ export function useWorkspaceSections(args: {
groupMode: MobileGroupMode
pinnedIds: Set<string>
showPinnedInGroups: boolean
repoIdsByName: Map<string, string>
repoColorsByName: Map<string, string>
projectCatalog: ProjectSectionCatalog
/** Every catalog repo on every host, which is what the desktop's filter lists. */
filterCatalogRepos: readonly SectionRepo[]
collapsedGroups: Set<string>
workspaceStatuses: readonly WorkspaceStatusDefinition[]
}): {
sections: Section[]
rawSections: Section[]
uniqueRepos: WorkspaceSectionRepo[]
uniqueRepoColors: Map<string, string>
/** Each row's repo by id, as the desktop's cards look theirs up for colour and icon. */
repoById: ReadonlyMap<string, SectionRepo>
} {
const {
displayWorktrees,
@@ -41,28 +40,20 @@ export function useWorkspaceSections(args: {
groupMode,
pinnedIds,
showPinnedInGroups,
repoIdsByName,
repoColorsByName,
projectCatalog,
filterCatalogRepos,
collapsedGroups,
workspaceStatuses
} = args
const uniqueRepos = useMemo(() => {
const repos = new Map<string, { id: string; color: string }>()
for (const w of displayWorktrees) {
if (!repos.has(w.repo)) {
repos.set(w.repo, {
id: repoIdsByName.get(w.repo) ?? w.repoId,
color: repoColorsByName.get(w.repo) ?? repoColor(w.repo)
})
}
}
return [...repos.entries()].map(([name, { id, color }]) => ({ name, id, color }))
}, [displayWorktrees, repoColorsByName, repoIdsByName])
const uniqueRepos = useMemo(
() => buildFilterRepos(filterCatalogRepos, displayWorktrees),
[filterCatalogRepos, displayWorktrees]
)
const uniqueRepoColors = useMemo(
() => new Map(uniqueRepos.map((repo) => [repo.name, repo.color])),
[uniqueRepos]
const repoById = useMemo(
() => buildSectionRepoMap(projectCatalog.repos, displayWorktrees),
[projectCatalog.repos, displayWorktrees]
)
const rawSections = useMemo(
@@ -74,7 +65,7 @@ export function useWorkspaceSections(args: {
search,
groupMode,
pinnedIds,
repoIdsByName,
projectCatalog,
workspaceStatuses,
collapsedGroups,
showPinnedInGroups
@@ -86,7 +77,7 @@ export function useWorkspaceSections(args: {
search,
groupMode,
pinnedIds,
repoIdsByName,
projectCatalog,
workspaceStatuses,
collapsedGroups,
showPinnedInGroups
@@ -102,5 +93,5 @@ export function useWorkspaceSections(args: {
[rawSections, collapsedGroups]
)
return { sections, rawSections, uniqueRepos, uniqueRepoColors }
return { sections, rawSections, uniqueRepos, repoById }
}

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