mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into bot/pr-bug-scan-6952-1783050355
# Conflicts: # src/main/ssh/ssh-relay-deploy-helpers.ts
This commit is contained in:
@@ -9,22 +9,8 @@ on:
|
||||
- 'mobile-android-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_patch_version:
|
||||
description: 'Bump the mobile marketing version patch number before release'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
release_version:
|
||||
description: 'Optional exact mobile marketing version override, e.g. 0.0.15'
|
||||
required: false
|
||||
type: string
|
||||
bump_android_version_code:
|
||||
description: 'Increment the Android versionCode before release'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
android_version_code:
|
||||
description: 'Optional exact Android versionCode override'
|
||||
description: 'Optional exact mobile marketing version assertion, e.g. 0.0.22'
|
||||
required: false
|
||||
type: string
|
||||
publish_github_release:
|
||||
@@ -69,9 +55,6 @@ jobs:
|
||||
id: release
|
||||
env:
|
||||
MOBILE_ANDROID_RELEASE_VERSION: ${{ github.event.inputs.release_version }}
|
||||
MOBILE_ANDROID_BUMP_PATCH_VERSION: ${{ github.event.inputs.bump_patch_version }}
|
||||
MOBILE_ANDROID_VERSION_CODE: ${{ github.event.inputs.android_version_code }}
|
||||
MOBILE_ANDROID_BUMP_VERSION_CODE: ${{ github.event.inputs.bump_android_version_code }}
|
||||
MOBILE_ANDROID_PUBLISH_RELEASE: ${{ github.event.inputs.publish_github_release }}
|
||||
run: node scripts/prepare-android-release.mjs
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ on:
|
||||
- ready_for_review
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
|
||||
@@ -66,6 +66,9 @@ jobs:
|
||||
- name: Check styled scrollbars
|
||||
run: pnpm check:styled-scrollbars
|
||||
|
||||
- name: Check reliability gate manifest
|
||||
run: pnpm run check:reliability-gates
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: Cut Release
|
||||
|
||||
# Why: single entry point for manually and scheduled cutting releases.
|
||||
# Why: single entry point for manually cutting releases.
|
||||
# Replaces the old local `pnpm release:*` scripts and the standalone scheduled
|
||||
# RC workflow so releases are always reproducible from CI and can never be
|
||||
# accidentally tagged against an uncommitted or non-main working tree.
|
||||
@@ -37,16 +37,10 @@ on:
|
||||
type: string
|
||||
default: main
|
||||
dry_run:
|
||||
description: Validate a scheduled-RC run without creating a tag
|
||||
description: Validate an RC release cut without creating a tag
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
schedule:
|
||||
# Why: GitHub scheduled workflows are not guaranteed to start exactly on
|
||||
# time and can be delayed or dropped around high-load periods. We retry
|
||||
# across the full target hour and dedupe per PT slot. Cron is UTC, so run
|
||||
# during both PST and PDT equivalents and let the PT-hour gate decide.
|
||||
- cron: '*/5 10,11,22,23 * * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -182,8 +176,8 @@ jobs:
|
||||
|
||||
- name: Publish complete release-cut RC drafts from prior runs
|
||||
id: publish_drafts
|
||||
# Why: this must run even when the current slot already cut a tag; a
|
||||
# later cron retry may be the first chance to unstick a complete RC draft.
|
||||
# Why: a manual RC dispatch should unstick any complete RC draft before
|
||||
# deciding whether to cut another tag.
|
||||
if: steps.window.outputs.allowed == 'true' && !(github.event_name == 'workflow_dispatch' && inputs.dry_run)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -60,6 +60,9 @@ pnpm-debug.log*
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Benchmark run output
|
||||
/tools/benchmarks/results/*.json
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
.tmp/
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.18](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.21](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,431 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import { parse, printParseErrorCode } from 'jsonc-parser'
|
||||
|
||||
const MANIFEST_PATH = path.join('config', 'reliability-gates.jsonc')
|
||||
const RED_GREEN_STATUSES = new Set(['missing', 'partial', 'complete', 'not-required'])
|
||||
const FLAKE_STATUSES = new Set(['not-started', 'unknown', 'soaking', 'stable', 'flaky'])
|
||||
const PROTECTION_LEVELS = new Set(['none', 'partial', 'active'])
|
||||
const EVIDENCE_RUN_RESULTS = new Set(['passed', 'failed', 'skipped'])
|
||||
const EVIDENCE_RUNNERS = new Set(['local', 'ci', 'soak', 'manual'])
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isNonEmptyString(value) {
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}
|
||||
|
||||
function hasNonEmptyStringArray(value) {
|
||||
return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString)
|
||||
}
|
||||
|
||||
function requireNonEmptyString(gate, field, failures) {
|
||||
if (!isNonEmptyString(gate[field])) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArray(gate, field, failures) {
|
||||
if (!hasNonEmptyStringArray(gate[field])) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be a non-empty string array`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireStringArrayAllowEmpty(gate, field, failures) {
|
||||
if (!Array.isArray(gate[field]) || !gate[field].every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: ${field} must be an array of strings`)
|
||||
}
|
||||
}
|
||||
|
||||
function requireNonNegativeNumber(record, field, owner, failures) {
|
||||
if (!Number.isFinite(record[field]) || record[field] < 0) {
|
||||
failures.push(`${owner}.${field} must be a non-negative number`)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePolicy(manifest, failures) {
|
||||
if (!isRecord(manifest.policy)) {
|
||||
failures.push('policy must be an object')
|
||||
return new Set()
|
||||
}
|
||||
if (!hasNonEmptyStringArray(manifest.policy.maturityLevels)) {
|
||||
failures.push('policy.maturityLevels must be a non-empty string array')
|
||||
}
|
||||
if (!isRecord(manifest.policy.blockingPromotion)) {
|
||||
failures.push('policy.blockingPromotion must be an object')
|
||||
} else {
|
||||
for (const field of ['minimumSoakRuns', 'minimumSoakDays', 'maximumUnexplainedFlakes']) {
|
||||
requireNonNegativeNumber(
|
||||
manifest.policy.blockingPromotion,
|
||||
field,
|
||||
'policy.blockingPromotion',
|
||||
failures
|
||||
)
|
||||
}
|
||||
}
|
||||
return new Set(
|
||||
Array.isArray(manifest.policy.maturityLevels)
|
||||
? manifest.policy.maturityLevels.filter(isNonEmptyString)
|
||||
: []
|
||||
)
|
||||
}
|
||||
|
||||
function validateRuntimeBudget(gate, failures) {
|
||||
if (!isRecord(gate.runtimeBudget)) {
|
||||
failures.push(`${gate.id}: runtimeBudget must be an object`)
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(gate.runtimeBudget.p95Seconds) || gate.runtimeBudget.p95Seconds <= 0) {
|
||||
failures.push(`${gate.id}: runtimeBudget.p95Seconds must be a positive number`)
|
||||
}
|
||||
if (!isNonEmptyString(gate.runtimeBudget.scope)) {
|
||||
failures.push(`${gate.id}: runtimeBudget.scope must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidence(gate, field, allowedStatuses, failures) {
|
||||
const evidence = gate[field]
|
||||
if (!isRecord(evidence)) {
|
||||
failures.push(`${gate.id}: ${field} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!allowedStatuses.has(evidence.status)) {
|
||||
failures.push(`${gate.id}: ${field}.status is invalid`)
|
||||
}
|
||||
if (!isNonEmptyString(evidence.evidence)) {
|
||||
failures.push(`${gate.id}: ${field}.evidence must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validatePerformanceBudget(gate, failures) {
|
||||
if (!isRecord(gate.performanceBudget)) {
|
||||
failures.push(`${gate.id}: performanceBudget must be an object`)
|
||||
return
|
||||
}
|
||||
if (typeof gate.performanceBudget.required !== 'boolean') {
|
||||
failures.push(`${gate.id}: performanceBudget.required must be boolean`)
|
||||
}
|
||||
if (!isNonEmptyString(gate.performanceBudget.evidence)) {
|
||||
failures.push(`${gate.id}: performanceBudget.evidence must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function commandUsesBrittleTestSelector(command) {
|
||||
return /(?:^|\s)(?:-t|--testNamePattern|--grep)(?:=|\s)/.test(command)
|
||||
}
|
||||
|
||||
function validateCommandCoverage(gate, failures) {
|
||||
if (!hasNonEmptyStringArray(gate.commands) || !hasNonEmptyStringArray(gate.testFiles)) {
|
||||
return
|
||||
}
|
||||
for (const testFile of gate.testFiles) {
|
||||
if (!gate.commands.some((command) => command.includes(testFile))) {
|
||||
failures.push(`${gate.id}: test file is not referenced by any gate command: ${testFile}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateProtection(gate, failures) {
|
||||
if (!PROTECTION_LEVELS.has(gate.protection)) {
|
||||
failures.push(`${gate.id}: protection must be one of none, partial, or active`)
|
||||
return
|
||||
}
|
||||
const hasCommands = hasNonEmptyStringArray(gate.commands)
|
||||
const hasTestFiles = hasNonEmptyStringArray(gate.testFiles)
|
||||
if (gate.protection === 'none' && (hasCommands || hasTestFiles)) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare commands or testFiles`)
|
||||
}
|
||||
if (gate.protection === 'partial' && (!hasCommands || !hasTestFiles)) {
|
||||
failures.push(`${gate.id}: protection partial gates must declare commands and testFiles`)
|
||||
}
|
||||
if (gate.protection === 'active') {
|
||||
if (gate.maturity !== 'blocking') {
|
||||
failures.push(`${gate.id}: protection active is reserved for blocking gates`)
|
||||
}
|
||||
if (!hasCommands || !hasTestFiles) {
|
||||
failures.push(`${gate.id}: protection active gates must declare commands and testFiles`)
|
||||
}
|
||||
if (gate.flakeHistory?.status !== 'stable') {
|
||||
failures.push(`${gate.id}: protection active gates must have stable flakeHistory`)
|
||||
}
|
||||
if (!hasCompleteRedGreenEvidence(gate)) {
|
||||
failures.push(`${gate.id}: protection active gates must have complete red/green evidence`)
|
||||
}
|
||||
}
|
||||
if (!hasCommands && gate.protection !== 'none') {
|
||||
failures.push(`${gate.id}: commandless gates must declare protection none`)
|
||||
}
|
||||
}
|
||||
|
||||
function isIsoDate(value) {
|
||||
return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
|
||||
}
|
||||
|
||||
// Why: cross-field validators run even after an earlier type check flagged a
|
||||
// malformed field, so coerce to an array before `.includes` to report the error
|
||||
// instead of throwing on hand-edited manifests.
|
||||
function asArray(value) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function hasCompleteRedGreenEvidence(gate) {
|
||||
return ['complete', 'not-required'].includes(gate.redGreenEvidence?.status)
|
||||
}
|
||||
|
||||
function validateEvidenceRun(gate, run, index, failures) {
|
||||
const owner = `${gate.id}: evidenceRuns[${index}]`
|
||||
if (!isRecord(run)) {
|
||||
failures.push(`${owner} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!isIsoDate(run.date)) {
|
||||
failures.push(`${owner}.date must be YYYY-MM-DD`)
|
||||
}
|
||||
if (!EVIDENCE_RUNNERS.has(run.runner)) {
|
||||
failures.push(`${owner}.runner must be one of local, ci, soak, or manual`)
|
||||
}
|
||||
if (!isNonEmptyString(run.platform)) {
|
||||
failures.push(`${owner}.platform must be a non-empty string`)
|
||||
}
|
||||
if (!EVIDENCE_RUN_RESULTS.has(run.result)) {
|
||||
failures.push(`${owner}.result must be one of passed, failed, or skipped`)
|
||||
}
|
||||
if (!isNonEmptyString(run.command)) {
|
||||
failures.push(`${owner}.command must be a non-empty string`)
|
||||
} else if (!asArray(gate.commands).includes(run.command)) {
|
||||
failures.push(`${owner}.command must match one of the gate commands`)
|
||||
}
|
||||
if (!Number.isFinite(run.durationSeconds) || run.durationSeconds < 0) {
|
||||
failures.push(`${owner}.durationSeconds must be a non-negative number`)
|
||||
}
|
||||
if (!isNonEmptyString(run.summary)) {
|
||||
failures.push(`${owner}.summary must be a non-empty string`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceRuns(gate, failures) {
|
||||
if (!Array.isArray(gate.evidenceRuns)) {
|
||||
failures.push(`${gate.id}: evidenceRuns must be an array`)
|
||||
return
|
||||
}
|
||||
gate.evidenceRuns.forEach((run, index) => validateEvidenceRun(gate, run, index, failures))
|
||||
if (
|
||||
['partial', 'active'].includes(gate.protection) &&
|
||||
!gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
|
||||
) {
|
||||
failures.push(`${gate.id}: protection ${gate.protection} gates need a passed evidence run`)
|
||||
}
|
||||
if (gate.protection === 'none' && gate.evidenceRuns.length > 0) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare evidenceRuns`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssertionRef(gate, ref, index, failures) {
|
||||
const owner = `${gate.id}: assertionRefs[${index}]`
|
||||
if (!isRecord(ref)) {
|
||||
failures.push(`${owner} must be an object`)
|
||||
return
|
||||
}
|
||||
if (!isNonEmptyString(ref.file)) {
|
||||
failures.push(`${owner}.file must be a non-empty string`)
|
||||
} else if (!asArray(gate.testFiles).includes(ref.file)) {
|
||||
failures.push(`${owner}.file must be one of the gate testFiles`)
|
||||
}
|
||||
if (!hasNonEmptyStringArray(ref.assertions)) {
|
||||
failures.push(`${owner}.assertions must be a non-empty string array`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateAssertionRefs(gate, failures) {
|
||||
if (!Array.isArray(gate.assertionRefs)) {
|
||||
failures.push(`${gate.id}: assertionRefs must be an array`)
|
||||
return
|
||||
}
|
||||
gate.assertionRefs.forEach((ref, index) => validateAssertionRef(gate, ref, index, failures))
|
||||
if (['partial', 'active'].includes(gate.protection) && gate.assertionRefs.length === 0) {
|
||||
failures.push(`${gate.id}: protection ${gate.protection} gates need assertionRefs`)
|
||||
}
|
||||
if (gate.protection === 'none' && gate.assertionRefs.length > 0) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare assertionRefs`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateCoveredScope(gate, failures) {
|
||||
requireStringArrayAllowEmpty(gate, 'coveredPlatforms', failures)
|
||||
requireStringArrayAllowEmpty(gate, 'coveredProviders', failures)
|
||||
requireNonEmptyString(gate, 'coverageNotes', failures)
|
||||
|
||||
if (!Array.isArray(gate.coveredPlatforms) || !Array.isArray(gate.coveredProviders)) {
|
||||
return
|
||||
}
|
||||
for (const platform of gate.coveredPlatforms) {
|
||||
if (!asArray(gate.platforms).includes(platform)) {
|
||||
failures.push(`${gate.id}: covered platform is outside risk scope: ${platform}`)
|
||||
}
|
||||
}
|
||||
for (const provider of gate.coveredProviders) {
|
||||
if (!asArray(gate.providers).includes(provider)) {
|
||||
failures.push(`${gate.id}: covered provider is outside risk scope: ${provider}`)
|
||||
}
|
||||
}
|
||||
if (
|
||||
['partial', 'active'].includes(gate.protection) &&
|
||||
gate.evidenceRuns.some((run) => isRecord(run) && run.result === 'passed')
|
||||
) {
|
||||
const evidencePlatforms = new Set(
|
||||
gate.evidenceRuns
|
||||
.filter((run) => isRecord(run) && run.result === 'passed')
|
||||
.map((run) => run.platform)
|
||||
)
|
||||
for (const platform of evidencePlatforms) {
|
||||
if (!gate.coveredPlatforms.includes(platform)) {
|
||||
failures.push(
|
||||
`${gate.id}: coveredPlatforms must include passed evidence platform ${platform}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
gate.protection === 'none' &&
|
||||
(gate.coveredPlatforms.length > 0 || gate.coveredProviders.length > 0)
|
||||
) {
|
||||
failures.push(`${gate.id}: protection none gates must not declare covered scope`)
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExists(root, filePath) {
|
||||
try {
|
||||
const stat = await fs.stat(path.join(root, filePath))
|
||||
return stat.isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateGate(gate, maturities, root) {
|
||||
const failures = []
|
||||
if (!isRecord(gate)) {
|
||||
return ['gate entry must be an object']
|
||||
}
|
||||
for (const field of ['id', 'title', 'owner', 'layer', 'invariant', 'oracle', 'demotionRule']) {
|
||||
requireNonEmptyString(gate, field, failures)
|
||||
}
|
||||
if (!maturities.has(gate.maturity)) {
|
||||
failures.push(`${gate.id ?? '<unknown>'}: maturity is invalid`)
|
||||
}
|
||||
for (const field of [
|
||||
'surfaces',
|
||||
'platforms',
|
||||
'providers',
|
||||
'motivatingLinks',
|
||||
'promotionCriteria'
|
||||
]) {
|
||||
requireStringArray(gate, field, failures)
|
||||
}
|
||||
if (!Array.isArray(gate.commands) || !gate.commands.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: commands must be an array of strings`)
|
||||
} else if (gate.commands.some(commandUsesBrittleTestSelector)) {
|
||||
failures.push(
|
||||
`${gate.id}: commands must not rely on title selectors (-t, --grep, or --testNamePattern)`
|
||||
)
|
||||
}
|
||||
if (!Array.isArray(gate.testFiles) || !gate.testFiles.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: testFiles must be an array of strings`)
|
||||
} else {
|
||||
for (const testFile of gate.testFiles) {
|
||||
if (!(await fileExists(root, testFile))) {
|
||||
failures.push(`${gate.id}: test file does not exist: ${testFile}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
validateCommandCoverage(gate, failures)
|
||||
if (['soak', 'blocking'].includes(gate.maturity)) {
|
||||
if (!hasNonEmptyStringArray(gate.commands)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one command`)
|
||||
}
|
||||
if (!hasNonEmptyStringArray(gate.testFiles)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must declare at least one test file`)
|
||||
}
|
||||
if (!['soaking', 'stable'].includes(gate.flakeHistory?.status)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must have soaking or stable flakeHistory`)
|
||||
}
|
||||
if (!hasCompleteRedGreenEvidence(gate)) {
|
||||
failures.push(`${gate.id}: ${gate.maturity} gates must have complete red/green evidence`)
|
||||
}
|
||||
}
|
||||
validateRuntimeBudget(gate, failures)
|
||||
validateEvidence(gate, 'flakeHistory', FLAKE_STATUSES, failures)
|
||||
validateEvidence(gate, 'redGreenEvidence', RED_GREEN_STATUSES, failures)
|
||||
validateProtection(gate, failures)
|
||||
validateEvidenceRuns(gate, failures)
|
||||
validateAssertionRefs(gate, failures)
|
||||
validateCoveredScope(gate, failures)
|
||||
validatePerformanceBudget(gate, failures)
|
||||
if (!Array.isArray(gate.knownGaps) || !gate.knownGaps.every(isNonEmptyString)) {
|
||||
failures.push(`${gate.id}: knownGaps must be an array of strings`)
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export async function main(root = process.cwd()) {
|
||||
const manifestPath = path.join(root, MANIFEST_PATH)
|
||||
let raw
|
||||
try {
|
||||
raw = await fs.readFile(manifestPath, 'utf8')
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
console.error(`${MANIFEST_PATH}: unable to read manifest (${message})`)
|
||||
return 1
|
||||
}
|
||||
const parseErrors = []
|
||||
const manifest = parse(raw, parseErrors, { allowTrailingComma: true })
|
||||
if (parseErrors.length > 0) {
|
||||
for (const error of parseErrors) {
|
||||
console.error(
|
||||
`${MANIFEST_PATH}: JSONC parse error ${printParseErrorCode(error.error)} at offset ${error.offset}`
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
const failures = []
|
||||
if (!isRecord(manifest)) {
|
||||
failures.push('manifest must be an object')
|
||||
} else {
|
||||
if (manifest.schemaVersion !== 1) {
|
||||
failures.push('schemaVersion must be 1')
|
||||
}
|
||||
const maturities = validatePolicy(manifest, failures)
|
||||
if (!Array.isArray(manifest.gates) || manifest.gates.length === 0) {
|
||||
failures.push('gates must be a non-empty array')
|
||||
} else {
|
||||
const seenIds = new Set()
|
||||
for (const gate of manifest.gates) {
|
||||
if (isRecord(gate) && isNonEmptyString(gate.id)) {
|
||||
if (seenIds.has(gate.id)) {
|
||||
failures.push(`${gate.id}: duplicate gate id`)
|
||||
}
|
||||
seenIds.add(gate.id)
|
||||
}
|
||||
failures.push(...(await validateGate(gate, maturities, root)))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
console.error(`Reliability gate manifest check failed with ${failures.length} issue(s):`)
|
||||
for (const failure of failures) {
|
||||
console.error(`- ${failure}`)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
console.log(`Reliability gate manifest check passed for ${manifest.gates.length} gate(s).`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
process.exit(await main())
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { main } from './check-reliability-gates.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempRoot(manifest) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
|
||||
tempDirs.push(root)
|
||||
const configDir = path.join(root, 'config')
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
writeFileSync(path.join(configDir, 'reliability-gates.jsonc'), manifest, 'utf8')
|
||||
writeFileSync(path.join(root, 'some.test.ts'), '', 'utf8')
|
||||
return root
|
||||
}
|
||||
|
||||
function validManifest(overrides = {}) {
|
||||
return JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
policy: {
|
||||
maturityLevels: ['experimental', 'soak', 'blocking', 'accepted-gap', 'deprecated'],
|
||||
blockingPromotion: {
|
||||
minimumSoakRuns: 100,
|
||||
minimumSoakDays: 14,
|
||||
maximumUnexplainedFlakes: 0
|
||||
}
|
||||
},
|
||||
gates: [
|
||||
{
|
||||
id: 'terminal-session.snapshot-freshness',
|
||||
title: 'Stale liveness snapshots cannot close newer PTY bindings',
|
||||
maturity: 'soak',
|
||||
protection: 'partial',
|
||||
owner: 'terminal-runtime',
|
||||
layer: 'renderer-unit',
|
||||
surfaces: ['terminal lifecycle'],
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
providers: ['local', 'daemon'],
|
||||
coveredPlatforms: ['macos'],
|
||||
coveredProviders: ['local'],
|
||||
coverageNotes: 'One local macOS unit run covers the local decision-layer fixture only.',
|
||||
motivatingLinks: ['https://github.com/stablyai/orca/issues/6773'],
|
||||
invariant: 'A stale snapshot cannot close a newer binding.',
|
||||
oracle: 'The test rejects reconciliation when the binding is newer than the snapshot.',
|
||||
commands: ['pnpm exec vitest run some.test.ts'],
|
||||
testFiles: ['some.test.ts'],
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'some.test.ts',
|
||||
assertions: ['stale liveness snapshots do not close newer PTY bindings']
|
||||
}
|
||||
],
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command: 'pnpm exec vitest run some.test.ts',
|
||||
result: 'passed',
|
||||
durationSeconds: 1.2,
|
||||
summary: '1 file passed.'
|
||||
}
|
||||
],
|
||||
runtimeBudget: {
|
||||
p95Seconds: 10,
|
||||
scope: 'local unit test'
|
||||
},
|
||||
flakeHistory: {
|
||||
status: 'soaking',
|
||||
evidence: 'Soak history exists.'
|
||||
},
|
||||
redGreenEvidence: {
|
||||
status: 'complete',
|
||||
evidence: 'Fails when the guard is removed and passes with the fix.'
|
||||
},
|
||||
performanceBudget: {
|
||||
required: true,
|
||||
evidence: 'Perf measurement is required before blocking promotion.'
|
||||
},
|
||||
promotionCriteria: ['Collect soak history.'],
|
||||
knownGaps: ['Needs an Electron survival test.'],
|
||||
demotionRule: 'Demote on unexplained flakes.',
|
||||
...overrides
|
||||
}
|
||||
]
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('check-reliability-gates', () => {
|
||||
it('accepts the checked-in manifest', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await expect(main(process.cwd())).resolves.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects soak gates without executable commands', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ commands: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Reliability gate manifest check failed')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must declare at least one command')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects soak gates without test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ testFiles: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must declare at least one test file')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects command-backed gates that select tests by title', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({ maturity: 'experimental', commands: ['pnpm vitest -t flaky-title'] })
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('commands must not rely on title selectors')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects gates without a protection level', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
delete manifest.gates[0].protection
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection must be one of none, partial, or active')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects commandless gates unless they declare protection none', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'partial',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('commandless gates must declare protection none')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects command-backed gates marked as no protection', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'none' }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare commands or testFiles')
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves active protection for stable blocking gates', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', protection: 'active' }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection active is reserved for blocking gates')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects partial gates without a passed evidence run', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', evidenceRuns: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection partial gates need a passed evidence run')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects evidence runs whose command is not declared by the gate', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command: 'pnpm exec vitest run other.test.ts',
|
||||
result: 'passed',
|
||||
durationSeconds: 1,
|
||||
summary: 'Wrong command.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('evidenceRuns[0].command must match one of the gate commands')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects protection none gates with evidence runs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare evidenceRuns')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects partial gates without assertion refs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(validManifest({ maturity: 'experimental', assertionRefs: [] }))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection partial gates need assertionRefs')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects assertion refs outside declared test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'other.test.ts',
|
||||
assertions: ['important invariant']
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('assertionRefs[0].file must be one of the gate testFiles')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects protection none gates with assertion refs', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
evidenceRuns: [],
|
||||
assertionRefs: [
|
||||
{
|
||||
file: 'some.test.ts',
|
||||
assertions: ['planning entry must not look tested']
|
||||
}
|
||||
],
|
||||
coveredPlatforms: [],
|
||||
coveredProviders: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare assertionRefs')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects covered scope outside the risk scope', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
coveredPlatforms: ['ios'],
|
||||
coveredProviders: ['ssh']
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('covered platform is outside risk scope: ios')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('covered provider is outside risk scope: ssh')
|
||||
)
|
||||
})
|
||||
|
||||
it('requires coveredPlatforms to include passed evidence platforms', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
coveredPlatforms: ['linux']
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('coveredPlatforms must include passed evidence platform macos')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects covered scope on protection none gates', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
protection: 'none',
|
||||
commands: [],
|
||||
testFiles: [],
|
||||
evidenceRuns: [],
|
||||
coveredPlatforms: ['macos'],
|
||||
coveredProviders: [],
|
||||
flakeHistory: { status: 'not-started', evidence: 'Planning entry only.' },
|
||||
redGreenEvidence: { status: 'missing', evidence: 'No executable proof yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('protection none gates must not declare covered scope')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects declared test files that do not exist', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({ maturity: 'experimental', testFiles: ['missing.test.ts'] })
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test file does not exist: missing.test.ts')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects executable gates whose commands do not reference declared test files', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const command = 'pnpm exec vitest run'
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
maturity: 'experimental',
|
||||
commands: [command],
|
||||
evidenceRuns: [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
runner: 'local',
|
||||
platform: 'macos',
|
||||
command,
|
||||
result: 'passed',
|
||||
durationSeconds: 1,
|
||||
summary: 'Command did not name the declared file.'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test file is not referenced by any gate command: some.test.ts')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects soak gates without mature flake and red/green evidence', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot(
|
||||
validManifest({
|
||||
flakeHistory: { status: 'unknown', evidence: 'No soak yet.' },
|
||||
redGreenEvidence: { status: 'partial', evidence: 'No saved artifact yet.' }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must have soaking or stable flakeHistory')
|
||||
)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('soak gates must have complete red/green evidence')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed JSONC', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = makeTempRoot('{ "schemaVersion": 1, } trailing')
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('JSONC parse error'))
|
||||
})
|
||||
|
||||
it('rejects missing manifests with a structured error', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'orca-reliability-gates-'))
|
||||
tempDirs.push(root)
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('unable to read manifest'))
|
||||
})
|
||||
|
||||
it('uses policy maturity levels as the source of truth', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
manifest.policy.maturityLevels = ['experimental']
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('maturity is invalid'))
|
||||
})
|
||||
|
||||
it('rejects malformed blocking promotion policy', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const manifest = JSON.parse(validManifest())
|
||||
manifest.policy.blockingPromotion.maximumUnexplainedFlakes = -1
|
||||
const root = makeTempRoot(JSON.stringify(manifest, null, 2))
|
||||
|
||||
await expect(main(root)).resolves.toBe(1)
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'policy.blockingPromotion.maximumUnexplainedFlakes must be a non-negative number'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,55 @@ const API_VERSION = '2022-11-28'
|
||||
const MAX_RELEASE_BODY_LENGTH = 120_000
|
||||
const TRUNCATION_NOTICE =
|
||||
'\n\n---\nRelease notes were truncated because GitHub release bodies are limited to 125,000 characters.'
|
||||
const DESKTOP_RELEASE_TAG_PATTERN = /^v(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?$/
|
||||
|
||||
export function parseDesktopReleaseTag(tag) {
|
||||
const match = DESKTOP_RELEASE_TAG_PATTERN.exec(tag)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
tag,
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
rc: match[4] === undefined ? null : Number(match[4])
|
||||
}
|
||||
}
|
||||
|
||||
function compareDesktopReleaseTags(a, b) {
|
||||
const versionDiff = a.major - b.major || a.minor - b.minor || a.patch - b.patch
|
||||
if (versionDiff !== 0) {
|
||||
return versionDiff
|
||||
}
|
||||
if (a.rc === b.rc) {
|
||||
return 0
|
||||
}
|
||||
if (a.rc === null) {
|
||||
return 1
|
||||
}
|
||||
if (b.rc === null) {
|
||||
return -1
|
||||
}
|
||||
return a.rc - b.rc
|
||||
}
|
||||
|
||||
export function latestPreviousPublishedDesktopReleaseTag(releases, tag) {
|
||||
const current = parseDesktopReleaseTag(tag)
|
||||
if (!current) {
|
||||
return ''
|
||||
}
|
||||
const previousReleases = releases
|
||||
.filter((release) => release?.draft === false && typeof release.tag_name === 'string')
|
||||
.map((release) => parseDesktopReleaseTag(release.tag_name))
|
||||
.filter((candidate) => candidate && candidate.tag !== current.tag)
|
||||
.filter((candidate) => compareDesktopReleaseTags(candidate, current) < 0)
|
||||
// Why: public changelogs should be bounded by releases users could see;
|
||||
// stable releases summarize since the prior stable, not the latest RC.
|
||||
.filter((candidate) => current.rc !== null || candidate.rc === null)
|
||||
.sort(compareDesktopReleaseTags)
|
||||
return previousReleases.at(-1)?.tag ?? ''
|
||||
}
|
||||
|
||||
function githubHeaders(token) {
|
||||
return {
|
||||
@@ -30,6 +79,25 @@ async function githubJson(fetchImpl, url, token, options = {}) {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function fetchRepoReleases(repo, token, fetchImpl) {
|
||||
const releases = []
|
||||
for (let page = 1; ; page += 1) {
|
||||
const pageReleases = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases?per_page=100&page=${page}`,
|
||||
token
|
||||
)
|
||||
if (!Array.isArray(pageReleases)) {
|
||||
throw new Error(`GitHub releases response page ${page} for ${repo} was not an array`)
|
||||
}
|
||||
releases.push(...pageReleases)
|
||||
if (pageReleases.length < 100) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return releases
|
||||
}
|
||||
|
||||
export function truncateReleaseBody(body, maxLength = MAX_RELEASE_BODY_LENGTH) {
|
||||
if (body.length <= maxLength) {
|
||||
return body
|
||||
@@ -60,16 +128,25 @@ export async function createDraftRelease({
|
||||
throw new Error('token is required')
|
||||
}
|
||||
|
||||
const previousTag = latestPreviousPublishedDesktopReleaseTag(
|
||||
await fetchRepoReleases(repo, token, fetchImpl),
|
||||
tag
|
||||
)
|
||||
const generateNotesBody = {
|
||||
tag_name: tag,
|
||||
target_commitish: tag,
|
||||
...(previousTag ? { previous_tag_name: previousTag } : {})
|
||||
}
|
||||
|
||||
// Why: GitHub's generate-notes baseline ignores draft releases, so pass the
|
||||
// previous public changelog boundary explicitly.
|
||||
const releaseNotes = await githubJson(
|
||||
fetchImpl,
|
||||
`https://api.github.com/repos/${repo}/releases/generate-notes`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: tag,
|
||||
target_commitish: tag
|
||||
})
|
||||
body: JSON.stringify(generateNotesBody)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createDraftRelease, truncateReleaseBody } from './create-draft-release.mjs'
|
||||
import {
|
||||
createDraftRelease,
|
||||
latestPreviousPublishedDesktopReleaseTag,
|
||||
parseDesktopReleaseTag,
|
||||
truncateReleaseBody
|
||||
} from './create-draft-release.mjs'
|
||||
|
||||
function release(tag, options = {}) {
|
||||
return {
|
||||
draft: false,
|
||||
tag_name: tag,
|
||||
...options
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body, init = {}) {
|
||||
return {
|
||||
@@ -24,10 +37,102 @@ describe('truncateReleaseBody', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDesktopReleaseTag', () => {
|
||||
it('parses stable and rc desktop release tags only', () => {
|
||||
expect(parseDesktopReleaseTag('v1.4.36')).toMatchObject({
|
||||
tag: 'v1.4.36',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: null
|
||||
})
|
||||
expect(parseDesktopReleaseTag('v1.4.36-rc.2')).toMatchObject({
|
||||
tag: 'v1.4.36-rc.2',
|
||||
major: 1,
|
||||
minor: 4,
|
||||
patch: 36,
|
||||
rc: 2
|
||||
})
|
||||
expect(parseDesktopReleaseTag('mobile-v0.0.12')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('latestPreviousPublishedDesktopReleaseTag', () => {
|
||||
it('bounds stable notes to the previous stable release when rcs exist', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0'), release('v1.4.36')],
|
||||
'v1.4.36'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('does not collapse a stable changelog to its rc-to-stable version bump', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[
|
||||
release('v1.4.120'),
|
||||
release('v1.4.121-rc.0'),
|
||||
release('v1.4.121-rc.6'),
|
||||
release('v1.4.121')
|
||||
],
|
||||
'v1.4.121'
|
||||
)
|
||||
).toBe('v1.4.120')
|
||||
})
|
||||
|
||||
it('bounds the first rc notes to the previous stable release', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0'), release('mobile-v0.0.12')],
|
||||
'v1.4.36-rc.0'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('bounds later rc notes to the prior rc', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.36-rc.0'), release('v1.4.36-rc.1')],
|
||||
'v1.4.36-rc.1'
|
||||
)
|
||||
).toBe('v1.4.36-rc.0')
|
||||
})
|
||||
|
||||
it('ignores draft releases as public changelog boundaries', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36-rc.0', { draft: true }), release('v1.4.36-rc.1')],
|
||||
'v1.4.36-rc.1'
|
||||
)
|
||||
).toBe('v1.4.35')
|
||||
})
|
||||
|
||||
it('returns empty string for the first desktop release when no earlier tag exists', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.36'), release('mobile-v0.0.12')],
|
||||
'v1.4.36'
|
||||
)
|
||||
).toBe('')
|
||||
expect(latestPreviousPublishedDesktopReleaseTag([], 'v1.4.36')).toBe('')
|
||||
})
|
||||
|
||||
it('returns empty string when the current tag is not a desktop release tag', () => {
|
||||
expect(
|
||||
latestPreviousPublishedDesktopReleaseTag(
|
||||
[release('v1.4.35'), release('v1.4.36')],
|
||||
'mobile-v0.0.12'
|
||||
)
|
||||
).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createDraftRelease', () => {
|
||||
it('creates a draft release with bounded generated notes', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.35'), release('v1.4.36')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'a'.repeat(130_000) }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
@@ -41,17 +146,23 @@ describe('createDraftRelease', () => {
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases/generate-notes',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
tag_name: 'v1.4.36',
|
||||
target_commitish: 'v1.4.36'
|
||||
target_commitish: 'v1.4.36',
|
||||
previous_tag_name: 'v1.4.35'
|
||||
})
|
||||
})
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'https://api.github.com/repos/stablyai/orca/releases',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -59,7 +170,7 @@ describe('createDraftRelease', () => {
|
||||
})
|
||||
)
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody).toMatchObject({
|
||||
tag_name: 'v1.4.36',
|
||||
name: 'v1.4.36',
|
||||
@@ -73,6 +184,7 @@ describe('createDraftRelease', () => {
|
||||
it('marks rc tags as prereleases', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('v1.4.36-rc.1')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36-rc.1', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36-rc.1', draft: true }))
|
||||
|
||||
@@ -84,7 +196,58 @@ describe('createDraftRelease', () => {
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
const createBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(createBody.prerelease).toBe(true)
|
||||
})
|
||||
|
||||
it('omits previous_tag_name for the first desktop release so notes fall back to the GitHub default', async () => {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.36'), release('mobile-v0.0.12')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[1][1].body)
|
||||
expect(generateNotesBody).toEqual({ tag_name: 'v1.4.36', target_commitish: 'v1.4.36' })
|
||||
expect(generateNotesBody).not.toHaveProperty('previous_tag_name')
|
||||
})
|
||||
|
||||
it('paginates through every release page before choosing the previous release', async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => release(`mobile-v0.0.${index}`))
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(firstPage))
|
||||
.mockResolvedValueOnce(jsonResponse([release('v1.4.35')]))
|
||||
.mockResolvedValueOnce(jsonResponse({ name: 'v1.4.36', body: 'notes' }))
|
||||
.mockResolvedValueOnce(jsonResponse({ tag_name: 'v1.4.36', draft: true }))
|
||||
|
||||
await createDraftRelease({
|
||||
repo: 'stablyai/orca',
|
||||
tag: 'v1.4.36',
|
||||
token: 'token',
|
||||
fetchImpl,
|
||||
log: vi.fn()
|
||||
})
|
||||
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=1',
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'https://api.github.com/repos/stablyai/orca/releases?per_page=100&page=2',
|
||||
expect.any(Object)
|
||||
)
|
||||
const generateNotesBody = JSON.parse(fetchImpl.mock.calls[2][1].body)
|
||||
expect(generateNotesBody.previous_tag_name).toBe('v1.4.35')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import {
|
||||
createWslFixture,
|
||||
listWslDistros,
|
||||
removeWslFixture
|
||||
} from './windows-apphang-repro/wsl-workspace-fixture.mjs'
|
||||
import { runGpuMode } from './windows-apphang-repro/terminal-activation-scenario.mjs'
|
||||
import { summarizeResult } from './windows-apphang-repro/apphang-report-summary.mjs'
|
||||
|
||||
const defaultCycles = 14
|
||||
const defaultOutputLines = 1_600
|
||||
|
||||
function parseArgs() {
|
||||
const args = {
|
||||
cycles: defaultCycles,
|
||||
distro: null,
|
||||
expect: 'none',
|
||||
gpuModes: ['on'],
|
||||
keep: false,
|
||||
outputLines: defaultOutputLines,
|
||||
reportPath: null,
|
||||
sourceControl: true,
|
||||
deadPtyReactivate: true
|
||||
}
|
||||
|
||||
for (const arg of process.argv.slice(2)) {
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
printHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
if (arg === '--keep') {
|
||||
args.keep = true
|
||||
continue
|
||||
}
|
||||
if (arg === '--no-source-control') {
|
||||
args.sourceControl = false
|
||||
continue
|
||||
}
|
||||
if (arg === '--no-dead-pty-reactivate') {
|
||||
args.deadPtyReactivate = false
|
||||
continue
|
||||
}
|
||||
const [name, value] = arg.split('=', 2)
|
||||
if (name === '--cycles') {
|
||||
args.cycles = parsePositiveInt(name, value)
|
||||
continue
|
||||
}
|
||||
if (name === '--distro') {
|
||||
args.distro = value?.trim() || null
|
||||
continue
|
||||
}
|
||||
if (name === '--expect') {
|
||||
if (!['none', 'repro', 'pass'].includes(value)) {
|
||||
throw new Error(`Unsupported --expect=${value}. Use none, repro, or pass.`)
|
||||
}
|
||||
args.expect = value
|
||||
continue
|
||||
}
|
||||
if (name === '--gpu') {
|
||||
const modes = (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
if (modes.length === 0 || modes.some((mode) => !['on', 'off', 'auto'].includes(mode))) {
|
||||
throw new Error(`Unsupported --gpu=${value}. Use on, off, auto, or a comma-list.`)
|
||||
}
|
||||
args.gpuModes = modes
|
||||
continue
|
||||
}
|
||||
if (name === '--output-lines') {
|
||||
args.outputLines = parsePositiveInt(name, value)
|
||||
continue
|
||||
}
|
||||
if (name === '--report') {
|
||||
args.reportPath = value?.trim() || null
|
||||
if (!args.reportPath) {
|
||||
throw new Error('--report requires a file path.')
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage:
|
||||
node config/scripts/repro-windows-apphang-terminal-activation.mjs [options]
|
||||
|
||||
Options:
|
||||
--expect=none|repro|pass none prints measurements, repro exits 0 only when hang evidence is observed,
|
||||
pass exits 1 if hang evidence is observed. Default: none.
|
||||
--gpu=on|off|auto[,mode...] Terminal GPU setting(s) to run. Default: on.
|
||||
--cycles=N Activation/output cycles per GPU mode. Default: ${defaultCycles}.
|
||||
--output-lines=N Lines emitted by each terminal stress command. Default: ${defaultOutputLines}.
|
||||
--report=PATH Write full JSON evidence to PATH and print a compact summary to stdout.
|
||||
--distro=NAME WSL distro to use. Default: first non docker-desktop distro.
|
||||
--no-source-control Do not open Source Control during the stress loop.
|
||||
--no-dead-pty-reactivate Do not kill PTYs and revisit workspaces after initial activation.
|
||||
--keep Keep disposable WSL/userData fixtures after the run.`)
|
||||
}
|
||||
|
||||
function parsePositiveInt(name, value) {
|
||||
const parsed = Number.parseInt(value ?? '', 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || String(parsed) !== value) {
|
||||
throw new Error(`${name} requires a positive integer.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This repro harness is intentionally Windows-only.')
|
||||
}
|
||||
const args = parseArgs()
|
||||
const distros = listWslDistros()
|
||||
const distro = args.distro ?? distros[0]
|
||||
if (!distro) {
|
||||
throw new Error('No user WSL distro found. Install/enable WSL or pass --distro=NAME.')
|
||||
}
|
||||
console.log(
|
||||
`[apphang-repro] issue=https://github.com/stablyai/orca/issues/6874 distro=${distro} gpuModes=${args.gpuModes.join(',')} cycles=${args.cycles}`
|
||||
)
|
||||
const fixture = createWslFixture(distro)
|
||||
console.log(
|
||||
`[apphang-repro] fixture repo=${fixture.repoUncPath} plain=${fixture.plainUncPath} base=${fixture.baseLinuxPath}`
|
||||
)
|
||||
|
||||
const results = []
|
||||
try {
|
||||
for (const gpuMode of args.gpuModes) {
|
||||
results.push(await runGpuMode(gpuMode, args, fixture))
|
||||
if (args.expect === 'repro' && results.at(-1)?.reproduced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!args.keep) {
|
||||
removeWslFixture(fixture)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
issue: 'https://github.com/stablyai/orca/issues/6874',
|
||||
expect: args.expect,
|
||||
distro,
|
||||
fixture: args.keep ? fixture : { removed: true, baseLinuxPath: fixture.baseLinuxPath },
|
||||
summary: results.map(summarizeResult),
|
||||
results
|
||||
}
|
||||
if (args.reportPath) {
|
||||
const reportPath = path.resolve(args.reportPath)
|
||||
mkdirSync(path.dirname(reportPath), { recursive: true })
|
||||
writeFileSync(reportPath, `${JSON.stringify(payload, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ reportPath, summary: payload.summary }, null, 2))
|
||||
} else {
|
||||
console.log(JSON.stringify(payload, null, 2))
|
||||
}
|
||||
|
||||
// Why: a broken harness run (CDP/setup/selector failure) proves nothing —
|
||||
// it must fail both --expect=repro and --expect=pass rather than being
|
||||
// miscounted as hang evidence or as a clean pass.
|
||||
const harnessErrors = results.filter((result) => result.harnessError)
|
||||
if (harnessErrors.length > 0) {
|
||||
const harnessErrorLines = harnessErrors
|
||||
.map((result) => ` gpu=${result.gpuMode}: ${result.harnessError}`)
|
||||
.join('\n')
|
||||
console.error(
|
||||
`[apphang-repro] Harness failed in ${harnessErrors.length} run(s); result inconclusive:\n${harnessErrorLines}`
|
||||
)
|
||||
if (args.expect !== 'none') {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
const reproduced = results.some((result) => result.reproduced)
|
||||
if (args.expect === 'repro' && !reproduced) {
|
||||
console.error(
|
||||
'[apphang-repro] Expected to reproduce the hang, but no hang evidence was observed.'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
if (args.expect === 'pass' && reproduced) {
|
||||
console.error('[apphang-repro] Expected a clean pass, but hang evidence was observed.')
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export function summarizeTrace(userDataDir) {
|
||||
const tracePath = path.join(userDataDir, 'logs', 'main.trace.ndjson')
|
||||
if (!existsSync(tracePath)) {
|
||||
return { tracePath, exists: false }
|
||||
}
|
||||
const lines = readFileSync(tracePath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
|
||||
const parsed = []
|
||||
for (const line of lines.slice(-400)) {
|
||||
try {
|
||||
parsed.push(JSON.parse(line))
|
||||
} catch {
|
||||
parsed.push({ raw: line })
|
||||
}
|
||||
}
|
||||
const interesting = parsed.filter((entry) => {
|
||||
const name = String(entry.name ?? entry.event ?? entry.type ?? '')
|
||||
const text = JSON.stringify(entry)
|
||||
return (
|
||||
name.includes('git.exec') ||
|
||||
name.includes('renderer_memory') ||
|
||||
name.includes('sidebar_worktree_activate') ||
|
||||
text.includes('git.exec remote') ||
|
||||
text.includes('git.exec worktree') ||
|
||||
text.includes('sidebar_worktree_activate')
|
||||
)
|
||||
})
|
||||
return {
|
||||
tracePath,
|
||||
exists: true,
|
||||
totalLines: lines.length,
|
||||
interestingTail: interesting.slice(-40)
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeAppLogs(logs) {
|
||||
const webglContextWarnings = logs.filter((entry) =>
|
||||
entry.line.includes('Too many active WebGL contexts')
|
||||
)
|
||||
const webglContextLosses = logs.filter((entry) =>
|
||||
entry.line.toLowerCase().includes('webgl context lost')
|
||||
)
|
||||
const gpuProcessEvents = logs.filter((entry) => /gpu|d3d|angle/i.test(entry.line))
|
||||
return {
|
||||
lineCount: logs.length,
|
||||
webglContextWarningCount: webglContextWarnings.length,
|
||||
webglContextWarningsTail: webglContextWarnings.slice(-10),
|
||||
webglContextLossCount: webglContextLosses.length,
|
||||
webglContextLossesTail: webglContextLosses.slice(-10),
|
||||
gpuProcessEventCount: gpuProcessEvents.length,
|
||||
gpuProcessEventsTail: gpuProcessEvents.slice(-20)
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeResult(result) {
|
||||
const samples = result.samples.filter((sample) => sample && typeof sample === 'object')
|
||||
const maxActivationMs = Math.max(0, ...samples.map((sample) => sample.activationMs ?? 0))
|
||||
const maxPtyWaitMs = Math.max(0, ...samples.map((sample) => sample.ptyWaitMs ?? 0))
|
||||
const maxMainIpcMs = Math.max(0, ...samples.map((sample) => sample.mainIpcMs ?? 0))
|
||||
const maxRendererDriftMs = Math.max(0, ...samples.map((sample) => sample.rendererMaxDriftMs ?? 0))
|
||||
return {
|
||||
gpuMode: result.gpuMode,
|
||||
reproduced: result.reproduced,
|
||||
reproductionReason: result.reproductionReason,
|
||||
harnessError: result.harnessError ?? null,
|
||||
elapsedMs: result.elapsedMs,
|
||||
sampleCount: samples.length,
|
||||
maxActivationMs,
|
||||
maxPtyWaitMs,
|
||||
maxMainIpcMs,
|
||||
maxRendererDriftMs,
|
||||
finalWebglContextCounts: result.finalDiagnostics?.webglContextCounts ?? null,
|
||||
appLogSummary: result.appLogSummary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import { chromium } from '@playwright/test'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
appShutdownTimeoutMs,
|
||||
cdpPollTimeoutMs,
|
||||
delay,
|
||||
pollUntil,
|
||||
rendererActionTimeoutMs,
|
||||
runWithTimeout
|
||||
} from './repro-timing.mjs'
|
||||
import { createCompletedOnboardingProfile } from './wsl-workspace-fixture.mjs'
|
||||
|
||||
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
|
||||
|
||||
function isPortFree(port) {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', () => {
|
||||
try {
|
||||
server.close()
|
||||
} catch {}
|
||||
resolve(false)
|
||||
})
|
||||
server.once('listening', () => server.close(() => resolve(true)))
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
export async function pickFreePort() {
|
||||
for (let port = 9533; port < 9633; port += 1) {
|
||||
if (await isPortFree(port)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
throw new Error('Could not find a free CDP port in 9533..9632.')
|
||||
}
|
||||
|
||||
export function createGpuUserDataDirectory(gpuMode) {
|
||||
const userDataDir = mkdtempSync(path.join(os.tmpdir(), `orca-apphang-${gpuMode}-userdata-`))
|
||||
createCompletedOnboardingProfile(userDataDir)
|
||||
return userDataDir
|
||||
}
|
||||
|
||||
export function launchDevApp({ cdpPort, userDataDir }) {
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
Object.assign(env, {
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
ELECTRON_ENABLE_STACK_DUMPING: '1',
|
||||
NODE_ENV: 'development',
|
||||
ORCA_DEV_USER_DATA_PATH: userDataDir,
|
||||
ORCA_SKIP_DEV_WEB_PREPARE: '1',
|
||||
ORCA_STARTUP_DIAGNOSTICS: '1',
|
||||
REMOTE_DEBUGGING_PORT: String(cdpPort),
|
||||
VITE_EXPOSE_STORE: 'true'
|
||||
})
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.join('config', 'scripts', 'run-electron-vite-dev.mjs')],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
)
|
||||
const logs = []
|
||||
const collect = (source) => (chunk) => {
|
||||
const text = chunk.toString()
|
||||
for (const line of text.split(/\r?\n/).filter(Boolean)) {
|
||||
logs.push({ source, line, at: Date.now() })
|
||||
console.log(`[apphang-repro:${source}] ${line}`)
|
||||
}
|
||||
}
|
||||
child.stdout?.on('data', collect('stdout'))
|
||||
child.stderr?.on('data', collect('stderr'))
|
||||
return { child, logs }
|
||||
}
|
||||
|
||||
export async function stopDevApp(child) {
|
||||
if (!child?.pid || child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, appShutdownTimeoutMs)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
if (process.platform === 'win32') {
|
||||
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true
|
||||
})
|
||||
killer.once('exit', () => undefined)
|
||||
return
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForCdp(port) {
|
||||
const url = `http://127.0.0.1:${port}/json`
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < cdpPollTimeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (response.ok) {
|
||||
const targets = await response.json()
|
||||
if (Array.isArray(targets) && targets.some((target) => target.type)) {
|
||||
return targets
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await delay(500)
|
||||
}
|
||||
throw new Error(`Timed out waiting for CDP targets on ${url}`)
|
||||
}
|
||||
|
||||
async function getMainPage(browser) {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < cdpPollTimeoutMs) {
|
||||
for (const context of browser.contexts()) {
|
||||
const pages = context.pages()
|
||||
const page =
|
||||
pages.find((candidate) =>
|
||||
/^https?:\/\/127\.0\.0\.1:|^https?:\/\/localhost:/.test(candidate.url())
|
||||
) ?? pages[0]
|
||||
if (page) {
|
||||
return page
|
||||
}
|
||||
}
|
||||
await delay(250)
|
||||
}
|
||||
throw new Error('Timed out waiting for the Electron renderer page.')
|
||||
}
|
||||
|
||||
export async function connectToApp(cdpPort) {
|
||||
await waitForCdp(cdpPort)
|
||||
const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`)
|
||||
const page = await getMainPage(browser)
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 })
|
||||
return { browser, page }
|
||||
}
|
||||
|
||||
export async function installRendererProbe(page) {
|
||||
await page.evaluate(() => {
|
||||
if (globalThis.__orcaApphangProbe) {
|
||||
return
|
||||
}
|
||||
const probe = {
|
||||
intervalMs: 50,
|
||||
last: performance.now(),
|
||||
maxDriftMs: 0,
|
||||
samples: 0,
|
||||
startedAt: performance.now(),
|
||||
lastTickAt: performance.now()
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now()
|
||||
const drift = Math.max(0, now - probe.last - probe.intervalMs)
|
||||
probe.maxDriftMs = Math.max(probe.maxDriftMs, drift)
|
||||
probe.last = now
|
||||
probe.lastTickAt = now
|
||||
probe.samples += 1
|
||||
}, probe.intervalMs)
|
||||
globalThis.__orcaApphangProbe = { probe, timer }
|
||||
})
|
||||
}
|
||||
|
||||
export async function waitForStoreReady(page) {
|
||||
await pollUntil(
|
||||
'renderer store exposure',
|
||||
() => page.evaluate(() => Boolean(window.__store && window.api)),
|
||||
Boolean,
|
||||
30_000
|
||||
)
|
||||
await pollUntil(
|
||||
'workspace session hydration',
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const state = window.__store?.getState?.()
|
||||
return Boolean(state?.workspaceSessionReady && state?.hydrationSucceeded)
|
||||
}),
|
||||
Boolean,
|
||||
45_000
|
||||
)
|
||||
}
|
||||
|
||||
export async function collectRendererDiagnostics(page) {
|
||||
if (!page) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return await runWithTimeout(
|
||||
'renderer diagnostics',
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const timed = (label, promise) =>
|
||||
Promise.race([
|
||||
Promise.resolve(promise).then(
|
||||
(value) => ({ value }),
|
||||
(error) => ({ error: error instanceof Error ? error.message : String(error) })
|
||||
),
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve({ error: `Timed out collecting ${label}` }), 1_000)
|
||||
)
|
||||
])
|
||||
const readWebglIdentity = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let gl = null
|
||||
try {
|
||||
gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl')
|
||||
if (!gl) {
|
||||
return { available: false, vendor: null, renderer: null }
|
||||
}
|
||||
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info')
|
||||
if (!debugInfo) {
|
||||
return { available: true, vendor: null, renderer: null }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
vendor: String(gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) ?? '') || null,
|
||||
renderer: String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) ?? '') || null
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
gl?.getExtension('WEBGL_lose_context')?.loseContext()
|
||||
} catch {}
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
}
|
||||
const allPaneManagersDiagnostics = Array.from(
|
||||
window.__paneManagers?.entries?.() ?? []
|
||||
).map(([managerTabId, paneManager]) => ({
|
||||
tabId: managerTabId,
|
||||
diagnostics: paneManager?.getRenderingDiagnostics?.() ?? []
|
||||
}))
|
||||
const webglContextCounts = allPaneManagersDiagnostics.reduce(
|
||||
(acc, entry) => {
|
||||
acc.managerCount += 1
|
||||
acc.paneCount += entry.diagnostics.length
|
||||
for (const diagnostic of entry.diagnostics) {
|
||||
if (diagnostic.hasWebgl) {
|
||||
acc.attachedWebglCount += 1
|
||||
}
|
||||
if (diagnostic.webglAttachmentDeferred) {
|
||||
acc.deferredWebglCount += 1
|
||||
}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{ attachedWebglCount: 0, deferredWebglCount: 0, managerCount: 0, paneCount: 0 }
|
||||
)
|
||||
const state = window.__store?.getState?.()
|
||||
const worktreeId = state?.activeWorktreeId ?? null
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
const buffer = activePane?.terminal?.buffer?.active ?? null
|
||||
return {
|
||||
hasStore: Boolean(window.__store),
|
||||
workspaceSessionReady: state?.workspaceSessionReady ?? null,
|
||||
hydrationSucceeded: state?.hydrationSucceeded ?? null,
|
||||
activeView: state?.activeView ?? null,
|
||||
activeRepoId: state?.activeRepoId ?? null,
|
||||
activeWorktreeId: worktreeId,
|
||||
activeTabType: state?.activeTabType ?? null,
|
||||
activeTabId: tabId,
|
||||
terminalGpuAcceleration: state?.settings?.terminalGpuAcceleration ?? null,
|
||||
repoCount: state?.repos?.length ?? null,
|
||||
worktreeCountsByRepo: Object.fromEntries(
|
||||
Object.entries(state?.worktreesByRepo ?? {}).map(([repoId, worktrees]) => [
|
||||
repoId,
|
||||
worktrees.length
|
||||
])
|
||||
),
|
||||
ptyIdsByTabId: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : [],
|
||||
paneManagerCount: window.__paneManagers?.size ?? null,
|
||||
activePane: activePane
|
||||
? {
|
||||
id: activePane.id ?? null,
|
||||
leafId: activePane.leafId ?? null,
|
||||
ptyId: activePane.container?.dataset?.ptyId ?? null,
|
||||
cols: activePane.terminal?.cols ?? null,
|
||||
rows: activePane.terminal?.rows ?? null,
|
||||
buffer: buffer
|
||||
? {
|
||||
baseY: buffer.baseY,
|
||||
viewportY: buffer.viewportY,
|
||||
cursorY: buffer.cursorY,
|
||||
length: buffer.length
|
||||
}
|
||||
: null
|
||||
}
|
||||
: null,
|
||||
renderingDiagnostics: manager?.getRenderingDiagnostics?.() ?? null,
|
||||
allPaneManagersDiagnostics,
|
||||
webglContextCounts,
|
||||
webglIdentity: readWebglIdentity(),
|
||||
rendererProbe: globalThis.__orcaApphangProbe?.probe ?? null,
|
||||
ptySessions: await timed('PTY sessions', window.api?.pty?.listSessions?.()),
|
||||
rendererDeliveryDebug: await timed(
|
||||
'renderer delivery debug',
|
||||
window.api?.pty?.getRendererDeliveryDebugSnapshot?.()
|
||||
)
|
||||
}
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const cdpPollTimeoutMs = 90_000
|
||||
export const rendererActionTimeoutMs = 5_000
|
||||
export const activationTimeoutMs = 15_000
|
||||
export const ptyWaitTimeoutMs = 20_000
|
||||
export const terminalMarkerTimeoutMs = 45_000
|
||||
export const setupTimeoutMs = 90_000
|
||||
export const appShutdownTimeoutMs = 8_000
|
||||
export const severeRendererDriftMs = 2_000
|
||||
export const severeActivationMs = 5_000
|
||||
export const severeMainIpcMs = 2_000
|
||||
export const severePtyWaitMs = 10_000
|
||||
|
||||
export function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function runWithTimeout(label, action, timeoutMs) {
|
||||
const actionPromise = Promise.resolve().then(action)
|
||||
actionPromise.catch(() => undefined)
|
||||
const result = await Promise.race([
|
||||
actionPromise.then(
|
||||
(value) => ({ timedOut: false, value }),
|
||||
(error) => ({ timedOut: false, error })
|
||||
),
|
||||
delay(timeoutMs).then(() => ({ timedOut: true }))
|
||||
])
|
||||
if (result.timedOut) {
|
||||
throw new Error(`Timed out during ${label} after ${timeoutMs}ms.`)
|
||||
}
|
||||
if ('error' in result) {
|
||||
throw result.error
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
export async function pollUntil(label, read, predicate, timeoutMs, intervalMs = 100) {
|
||||
const startedAt = Date.now()
|
||||
let lastValue = null
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
lastValue = await runWithTimeout(label, read, rendererActionTimeoutMs)
|
||||
if (predicate(lastValue)) {
|
||||
return lastValue
|
||||
}
|
||||
await delay(intervalMs)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}; last value: ${JSON.stringify(lastValue)}`)
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
import {
|
||||
collectRendererDiagnostics,
|
||||
connectToApp,
|
||||
createGpuUserDataDirectory,
|
||||
installRendererProbe,
|
||||
launchDevApp,
|
||||
pickFreePort,
|
||||
stopDevApp,
|
||||
waitForStoreReady
|
||||
} from './electron-dev-session.mjs'
|
||||
import { summarizeAppLogs, summarizeTrace } from './apphang-report-summary.mjs'
|
||||
import {
|
||||
activationTimeoutMs,
|
||||
pollUntil,
|
||||
ptyWaitTimeoutMs,
|
||||
rendererActionTimeoutMs,
|
||||
runWithTimeout,
|
||||
setupTimeoutMs,
|
||||
severeActivationMs,
|
||||
severeMainIpcMs,
|
||||
severePtyWaitMs,
|
||||
severeRendererDriftMs,
|
||||
terminalMarkerTimeoutMs
|
||||
} from './repro-timing.mjs'
|
||||
import { safeRemoveLocalDirectory } from './wsl-workspace-fixture.mjs'
|
||||
|
||||
class ReproductionObservedError extends Error {
|
||||
constructor(message, evidence) {
|
||||
super(message)
|
||||
this.name = 'ReproductionObservedError'
|
||||
this.evidence = evidence
|
||||
}
|
||||
}
|
||||
|
||||
async function setupAppFixture(page, fixture, gpuMode, sourceControl) {
|
||||
return await runWithTimeout(
|
||||
'fixture registration in Orca',
|
||||
() =>
|
||||
page.evaluate(
|
||||
async ({ repoPath, plainPath, importedWorktreePaths, mode, openSourceControl }) => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is unavailable.')
|
||||
}
|
||||
const state = store.getState()
|
||||
await state.fetchSettings?.()
|
||||
await store.getState().updateSettings({ terminalGpuAcceleration: mode })
|
||||
|
||||
const addResult = await window.api.repos.add({ path: repoPath, kind: 'git' })
|
||||
if ('error' in addResult) {
|
||||
throw new Error(addResult.error)
|
||||
}
|
||||
await store.getState().fetchRepos()
|
||||
let nextState = store.getState()
|
||||
const repo =
|
||||
nextState.repos.find((candidate) => candidate.path === repoPath) ?? addResult.repo
|
||||
await nextState.updateRepo(repo.id, {
|
||||
externalWorktreeVisibility: 'show',
|
||||
externalWorktreeVisibilityPromptDismissedAt: Date.now(),
|
||||
importedExternalWorktreePaths: importedWorktreePaths,
|
||||
externalWorktreeInboxBaselinePaths: importedWorktreePaths
|
||||
})
|
||||
await store.getState().fetchWorktrees(repo.id, { requireAuthoritative: true })
|
||||
|
||||
const plainRepo = await store.getState().addNonGitFolder(plainPath)
|
||||
if (!plainRepo) {
|
||||
throw new Error('addNonGitFolder returned null.')
|
||||
}
|
||||
await store.getState().fetchWorktrees(plainRepo.id, { requireAuthoritative: true })
|
||||
|
||||
nextState = store.getState()
|
||||
nextState.setSidebarOpen(true)
|
||||
nextState.setGroupBy('none')
|
||||
nextState.setSortBy('recent')
|
||||
nextState.setShowActiveOnly(false)
|
||||
nextState.setActiveView('terminal')
|
||||
if (openSourceControl) {
|
||||
nextState.setRightSidebarOpen(true)
|
||||
nextState.setRightSidebarTab('source-control')
|
||||
}
|
||||
|
||||
const gitWorktrees = nextState.worktreesByRepo[repo.id] ?? []
|
||||
const plainWorktrees = nextState.worktreesByRepo[plainRepo.id] ?? []
|
||||
return {
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
plainRepoId: plainRepo.id,
|
||||
gitWorktrees: gitWorktrees.map((worktree) => ({
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName,
|
||||
branch: worktree.branch,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
})),
|
||||
plainWorktrees: plainWorktrees.map((worktree) => ({
|
||||
id: worktree.id,
|
||||
path: worktree.path,
|
||||
displayName: worktree.displayName,
|
||||
branch: worktree.branch,
|
||||
isMainWorktree: worktree.isMainWorktree
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
repoPath: fixture.repoUncPath,
|
||||
plainPath: fixture.plainUncPath,
|
||||
importedWorktreePaths: fixture.worktreeUncPaths,
|
||||
mode: gpuMode,
|
||||
openSourceControl: sourceControl
|
||||
}
|
||||
),
|
||||
setupTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function clickWorktreeCard(page, worktreeId) {
|
||||
const rect = await runWithTimeout(
|
||||
`locate worktree card ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const rows = Array.from(document.querySelectorAll('[data-worktree-id]'))
|
||||
const row = rows.find((candidate) => candidate.getAttribute('data-worktree-id') === id)
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
row.scrollIntoView({ block: 'center', inline: 'nearest' })
|
||||
const surface = row.querySelector('[data-worktree-card-surface="true"]') ?? row
|
||||
const bounds = surface.getBoundingClientRect()
|
||||
if (bounds.width <= 0 || bounds.height <= 0) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
x: bounds.left + bounds.width / 2,
|
||||
y: bounds.top + bounds.height / 2,
|
||||
width: bounds.width,
|
||||
height: bounds.height
|
||||
}
|
||||
}, worktreeId),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
if (!rect) {
|
||||
throw new Error(`Could not find rendered worktree card for ${worktreeId}`)
|
||||
}
|
||||
await runWithTimeout(
|
||||
`click worktree card ${worktreeId}`,
|
||||
() => page.mouse.click(rect.x, rect.y),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActiveWorktree(page, worktreeId) {
|
||||
return await pollUntil(
|
||||
`active worktree ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const state = window.__store?.getState?.()
|
||||
return {
|
||||
activeWorktreeId: state?.activeWorktreeId ?? null,
|
||||
activeTabId: state?.activeTabId ?? null,
|
||||
activeTabType: state?.activeTabType ?? null,
|
||||
tabs: state?.tabsByWorktree?.[id]?.map((tab) => tab.id) ?? []
|
||||
}
|
||||
}, worktreeId),
|
||||
(value) => value?.activeWorktreeId === worktreeId && value.activeTabType === 'terminal',
|
||||
activationTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActivePty(page, worktreeId) {
|
||||
const startedAt = Date.now()
|
||||
const value = await pollUntil(
|
||||
`active PTY for ${worktreeId}`,
|
||||
() =>
|
||||
page.evaluate((id) => {
|
||||
const state = window.__store?.getState?.()
|
||||
const tabId =
|
||||
state?.activeWorktreeId === id && state.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: (state?.activeTabIdByWorktree?.[id] ?? null)
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
const ptyId = pane?.container?.dataset?.ptyId ?? null
|
||||
return {
|
||||
tabId,
|
||||
ptyId,
|
||||
hasManager: Boolean(manager),
|
||||
paneCount: manager?.getPanes?.()?.length ?? 0,
|
||||
ptyIdsByTab: tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
|
||||
}
|
||||
}, worktreeId),
|
||||
(value) => Boolean(value?.ptyId),
|
||||
ptyWaitTimeoutMs
|
||||
)
|
||||
return { ...value, waitMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
function makeOutputCommand(marker, outputLines) {
|
||||
const payload = 'x'.repeat(180)
|
||||
return `printf '${marker}_START\\n'; i=1; while [ "$i" -le ${outputLines} ]; do printf '${marker}_%05d ${payload}\\n' "$i"; i=$((i+1)); done; printf '${marker}_DONE\\n'\r`
|
||||
}
|
||||
|
||||
async function getTerminalContent(page, charLimit = 20_000) {
|
||||
return await runWithTimeout(
|
||||
'terminal content',
|
||||
() =>
|
||||
page.evaluate((limit) => {
|
||||
const state = window.__store?.getState?.()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()?.[0] ?? null
|
||||
return (pane?.serializeAddon?.serialize?.() ?? '').slice(-limit)
|
||||
}, charLimit),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function sendOutputStress(page, ptyId, marker, outputLines) {
|
||||
await runWithTimeout(
|
||||
`write terminal stress ${marker}`,
|
||||
() =>
|
||||
page.evaluate(
|
||||
({ id, command }) => {
|
||||
window.api.pty.write(id, command)
|
||||
},
|
||||
{ id: ptyId, command: makeOutputCommand(marker, outputLines) }
|
||||
),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
await pollUntil(
|
||||
`terminal stress marker ${marker}`,
|
||||
async () => (await getTerminalContent(page)).includes(`${marker}_DONE`),
|
||||
Boolean,
|
||||
terminalMarkerTimeoutMs,
|
||||
200
|
||||
)
|
||||
}
|
||||
|
||||
async function pingMainIpc(page) {
|
||||
const startedAt = Date.now()
|
||||
await runWithTimeout(
|
||||
'main IPC ping',
|
||||
() => page.evaluate(() => window.api.pty.listSessions()),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
return Date.now() - startedAt
|
||||
}
|
||||
|
||||
async function readRendererProbe(page) {
|
||||
return await runWithTimeout(
|
||||
'renderer probe read',
|
||||
() => page.evaluate(() => globalThis.__orcaApphangProbe?.probe ?? null),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function killActivePty(page) {
|
||||
return await runWithTimeout(
|
||||
'kill active PTY',
|
||||
() =>
|
||||
page.evaluate(async () => {
|
||||
const state = window.__store?.getState?.()
|
||||
const tabId = state?.activeTabId ?? null
|
||||
const ptyIds = tabId ? (state?.ptyIdsByTabId?.[tabId] ?? []) : []
|
||||
const ptyId = ptyIds[0] ?? null
|
||||
if (!ptyId) {
|
||||
return null
|
||||
}
|
||||
await window.api.pty.kill(ptyId)
|
||||
return ptyId
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function resetRendererProbe(page) {
|
||||
// Why: probe drift is cumulative since installation; without a per-cycle
|
||||
// reset, one startup/setup stall gets re-reported as a hang in every
|
||||
// later cycle's rendererMaxDriftMs check.
|
||||
await runWithTimeout(
|
||||
'renderer probe reset',
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const probe = globalThis.__orcaApphangProbe?.probe
|
||||
if (!probe) {
|
||||
return
|
||||
}
|
||||
const now = performance.now()
|
||||
probe.last = now
|
||||
probe.lastTickAt = now
|
||||
probe.startedAt = now
|
||||
probe.maxDriftMs = 0
|
||||
probe.samples = 0
|
||||
}),
|
||||
rendererActionTimeoutMs
|
||||
)
|
||||
}
|
||||
|
||||
async function runActivationCycle(page, target, args) {
|
||||
await resetRendererProbe(page)
|
||||
const marker = `ORCA_APPHANG_${target.index}_${Date.now()}`
|
||||
const cycleStartedAt = Date.now()
|
||||
const activationStartedAt = Date.now()
|
||||
await clickWorktreeCard(page, target.id)
|
||||
const activation = await waitForActiveWorktree(page, target.id)
|
||||
const activationMs = Date.now() - activationStartedAt
|
||||
const activePty = await waitForActivePty(page, target.id)
|
||||
const diagnosticsBeforeOutput = await collectRendererDiagnostics(page)
|
||||
const outputStartedAt = Date.now()
|
||||
await sendOutputStress(page, activePty.ptyId, marker, args.outputLines)
|
||||
const outputMs = Date.now() - outputStartedAt
|
||||
const mainIpcMs = await pingMainIpc(page)
|
||||
const rendererProbe = await readRendererProbe(page)
|
||||
const diagnosticsAfterOutput = await collectRendererDiagnostics(page)
|
||||
const elapsedMs = Date.now() - cycleStartedAt
|
||||
const sample = {
|
||||
index: target.index,
|
||||
kind: target.kind,
|
||||
worktreeId: target.id,
|
||||
displayName: target.displayName,
|
||||
elapsedMs,
|
||||
activationMs,
|
||||
outputMs,
|
||||
activation,
|
||||
ptyWaitMs: activePty.waitMs,
|
||||
ptyId: activePty.ptyId,
|
||||
tabId: activePty.tabId,
|
||||
mainIpcMs,
|
||||
rendererMaxDriftMs: rendererProbe?.maxDriftMs ?? null,
|
||||
renderingDiagnostics: diagnosticsAfterOutput?.renderingDiagnostics ?? null,
|
||||
webglIdentity: diagnosticsAfterOutput?.webglIdentity ?? null,
|
||||
diagnosticsBeforeOutput,
|
||||
diagnosticsAfterOutput
|
||||
}
|
||||
|
||||
const reproducedReasons = []
|
||||
if (activationMs > severeActivationMs) {
|
||||
reproducedReasons.push(`worktree activation took ${activationMs}ms`)
|
||||
}
|
||||
if (activePty.waitMs > severePtyWaitMs) {
|
||||
reproducedReasons.push(`PTY binding took ${activePty.waitMs}ms`)
|
||||
}
|
||||
if (mainIpcMs > severeMainIpcMs) {
|
||||
reproducedReasons.push(`main IPC ping took ${mainIpcMs}ms`)
|
||||
}
|
||||
if ((rendererProbe?.maxDriftMs ?? 0) > severeRendererDriftMs) {
|
||||
reproducedReasons.push(`renderer timer drift reached ${Math.round(rendererProbe.maxDriftMs)}ms`)
|
||||
}
|
||||
if (reproducedReasons.length > 0) {
|
||||
throw new ReproductionObservedError(reproducedReasons.join('; '), sample)
|
||||
}
|
||||
return sample
|
||||
}
|
||||
|
||||
async function runDeadPtyReactivation(page, targets, args) {
|
||||
const samples = []
|
||||
for (const target of targets) {
|
||||
await clickWorktreeCard(page, target.id)
|
||||
await waitForActiveWorktree(page, target.id)
|
||||
await waitForActivePty(page, target.id)
|
||||
const killedPtyId = await killActivePty(page)
|
||||
samples.push({ worktreeId: target.id, killedPtyId })
|
||||
}
|
||||
for (const target of targets) {
|
||||
samples.push(
|
||||
await runActivationCycle(page, { ...target, kind: `${target.kind}:dead-pty` }, args)
|
||||
)
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
function selectTargets(setupResult, cycles) {
|
||||
const gitWorktrees = setupResult.gitWorktrees
|
||||
.filter((worktree) => worktree.path)
|
||||
.sort((a, b) => Number(b.isMainWorktree) - Number(a.isMainWorktree))
|
||||
.slice(0, 5)
|
||||
.map((worktree) => ({ ...worktree, kind: 'git-wsl' }))
|
||||
const plainWorktrees = setupResult.plainWorktrees.map((worktree) => ({
|
||||
...worktree,
|
||||
kind: 'plain-folder-wsl'
|
||||
}))
|
||||
const baseTargets = [...gitWorktrees, ...plainWorktrees]
|
||||
if (baseTargets.length === 0) {
|
||||
throw new Error('No worktrees were discovered for the repro fixture.')
|
||||
}
|
||||
return Array.from({ length: cycles }, (_, index) => ({
|
||||
...baseTargets[index % baseTargets.length],
|
||||
index: index + 1
|
||||
}))
|
||||
}
|
||||
|
||||
export async function runGpuMode(gpuMode, args, fixture) {
|
||||
const cdpPort = await pickFreePort()
|
||||
const userDataDir = createGpuUserDataDirectory(gpuMode)
|
||||
const launched = launchDevApp({ cdpPort, userDataDir })
|
||||
let browser = null
|
||||
let page = null
|
||||
const startedAt = Date.now()
|
||||
const result = {
|
||||
gpuMode,
|
||||
reproduced: false,
|
||||
reproductionReason: null,
|
||||
harnessError: null,
|
||||
startedAt,
|
||||
elapsedMs: null,
|
||||
cdpPort,
|
||||
userDataDir,
|
||||
appPid: launched.child.pid ?? null,
|
||||
setup: null,
|
||||
samples: [],
|
||||
finalDiagnostics: null,
|
||||
trace: null,
|
||||
appLogSummary: null,
|
||||
appLogsTail: null,
|
||||
cleanupErrors: []
|
||||
}
|
||||
|
||||
try {
|
||||
const connected = await connectToApp(cdpPort)
|
||||
browser = connected.browser
|
||||
page = connected.page
|
||||
await waitForStoreReady(page)
|
||||
await installRendererProbe(page)
|
||||
const identity = await runWithTimeout(
|
||||
'app identity',
|
||||
() => page.evaluate(() => window.api.app.getIdentity?.()),
|
||||
rendererActionTimeoutMs
|
||||
).catch(() => null)
|
||||
console.log(
|
||||
`[apphang-repro] connected gpu=${gpuMode} pid=${result.appPid} identity=${JSON.stringify(identity)}`
|
||||
)
|
||||
result.setup = await setupAppFixture(page, fixture, gpuMode, args.sourceControl)
|
||||
const targets = selectTargets(result.setup, args.cycles)
|
||||
console.log(
|
||||
`[apphang-repro] targets gpu=${gpuMode}: ${targets
|
||||
.map((target) => `${target.kind}:${target.displayName}`)
|
||||
.join(', ')}`
|
||||
)
|
||||
for (const target of targets) {
|
||||
console.log(`[apphang-repro] cycle=${target.index} gpu=${gpuMode} kind=${target.kind}`)
|
||||
result.samples.push(await runActivationCycle(page, target, args))
|
||||
}
|
||||
if (args.deadPtyReactivate) {
|
||||
const uniqueTargets = []
|
||||
const seen = new Set()
|
||||
for (const target of targets) {
|
||||
if (!seen.has(target.id)) {
|
||||
seen.add(target.id)
|
||||
uniqueTargets.push(target)
|
||||
}
|
||||
}
|
||||
console.log(`[apphang-repro] dead-pty-reactivation gpu=${gpuMode}`)
|
||||
result.samples.push(...(await runDeadPtyReactivation(page, uniqueTargets, args)))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ReproductionObservedError) {
|
||||
result.reproduced = true
|
||||
result.reproductionReason = error.message
|
||||
result.samples.push(error.evidence)
|
||||
} else {
|
||||
// Why: setup/CDP/selector failures are inconclusive, not hang evidence.
|
||||
// Conflating them with `reproduced` lets --expect=repro pass on a broken
|
||||
// harness; callers treat harnessError as a hard failure instead.
|
||||
result.harnessError = error instanceof Error ? error.stack || error.message : String(error)
|
||||
}
|
||||
} finally {
|
||||
result.elapsedMs = Date.now() - startedAt
|
||||
result.finalDiagnostics = await collectRendererDiagnostics(page)
|
||||
result.trace = summarizeTrace(userDataDir)
|
||||
result.appLogSummary = summarizeAppLogs(launched.logs)
|
||||
result.appLogsTail = launched.logs.slice(-120)
|
||||
if (browser) {
|
||||
await browser.close().catch(() => undefined)
|
||||
}
|
||||
await stopDevApp(launched.child)
|
||||
if (!args.keep) {
|
||||
safeRemoveLocalDirectory(userDataDir, result.cleanupErrors)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const rootDir = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)))
|
||||
|
||||
function runWsl(distro, script, options = {}) {
|
||||
return execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-se'], {
|
||||
cwd: rootDir,
|
||||
encoding: 'utf8',
|
||||
input: script,
|
||||
timeout: options.timeoutMs ?? 120_000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
}
|
||||
|
||||
export function listWslDistros() {
|
||||
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 10_000
|
||||
})
|
||||
return output
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^\*\s*/, ''))
|
||||
.filter((line) => line && !line.toLowerCase().startsWith('docker-desktop'))
|
||||
}
|
||||
|
||||
function linuxPathToWslUnc(distro, linuxPath) {
|
||||
if (!linuxPath.startsWith('/')) {
|
||||
throw new Error(`Expected absolute Linux path, got ${linuxPath}`)
|
||||
}
|
||||
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
|
||||
}
|
||||
|
||||
export function createWslFixture(distro) {
|
||||
const script = String.raw`
|
||||
set -euo pipefail
|
||||
base="$(mktemp -d /tmp/orca-apphang-repro.XXXXXX)"
|
||||
repo="$base/repo"
|
||||
mkdir -p "$repo"
|
||||
cd "$repo"
|
||||
git init -q
|
||||
git config user.email apphang-repro@test.local
|
||||
git config user.name "AppHang Repro"
|
||||
mkdir -p src docs
|
||||
printf '# Orca Windows AppHang repro\n' > README.md
|
||||
for n in $(seq 1 25); do
|
||||
printf 'line %03d %s\n' "$n" "abcdefghijklmnopqrstuvwxyz0123456789" >> src/payload.txt
|
||||
done
|
||||
git add -A
|
||||
git commit -q -m "Initial repro fixture"
|
||||
for n in 1 2 3 4; do
|
||||
git branch "repro-wt-$n"
|
||||
git worktree add -q "$base/wt-$n" "repro-wt-$n"
|
||||
mkdir -p "$base/wt-$n/docs"
|
||||
printf 'worktree %s\n' "$n" > "$base/wt-$n/docs/wt.txt"
|
||||
done
|
||||
plain="$base/plain-folder"
|
||||
mkdir -p "$plain/subdir"
|
||||
printf 'plain folder for Orca AppHang repro\n' > "$plain/README.txt"
|
||||
printf '%s\n' "$base" "$repo" "$base/wt-1" "$base/wt-2" "$base/wt-3" "$base/wt-4" "$plain"
|
||||
`
|
||||
const lines = runWsl(distro, script, { timeoutMs: 120_000 }).trim().split(/\r?\n/).filter(Boolean)
|
||||
if (lines.length < 7) {
|
||||
throw new Error(`WSL fixture creation returned unexpected output: ${JSON.stringify(lines)}`)
|
||||
}
|
||||
const [base, repo, ...rest] = lines
|
||||
const plain = rest.at(-1)
|
||||
const worktrees = rest.slice(0, -1)
|
||||
return {
|
||||
distro,
|
||||
baseLinuxPath: base,
|
||||
repoLinuxPath: repo,
|
||||
plainLinuxPath: plain,
|
||||
worktreeLinuxPaths: worktrees,
|
||||
repoUncPath: linuxPathToWslUnc(distro, repo),
|
||||
plainUncPath: linuxPathToWslUnc(distro, plain),
|
||||
worktreeUncPaths: worktrees.map((entry) => linuxPathToWslUnc(distro, entry))
|
||||
}
|
||||
}
|
||||
|
||||
export function removeWslFixture(fixture) {
|
||||
if (!fixture?.baseLinuxPath) {
|
||||
return
|
||||
}
|
||||
const quoted = fixture.baseLinuxPath.replaceAll("'", "'\\''")
|
||||
runWsl(fixture.distro, `rm -rf '${quoted}'`, { timeoutMs: 30_000 })
|
||||
}
|
||||
|
||||
export function createCompletedOnboardingProfile(userDataDir) {
|
||||
mkdirSync(userDataDir, { recursive: true })
|
||||
const profile = {
|
||||
settings: {
|
||||
telemetry: {
|
||||
// Why: synthetic harness/benchmark activity must not send telemetry.
|
||||
// Explicit false with existedBeforeTelemetryRelease=false also keeps
|
||||
// the first-launch consent surface from blocking automation.
|
||||
optedIn: false,
|
||||
installId: '00000000-0000-4000-8000-000000000000',
|
||||
existedBeforeTelemetryRelease: false
|
||||
}
|
||||
},
|
||||
onboarding: {
|
||||
flowVersion: 4,
|
||||
closedAt: 1,
|
||||
outcome: 'completed',
|
||||
lastCompletedStep: 5
|
||||
},
|
||||
ui: {
|
||||
contextualToursAutoEligible: false,
|
||||
contextualToursSeenIds: [
|
||||
'workspace-board',
|
||||
'browser',
|
||||
'tasks',
|
||||
'automations',
|
||||
'workspace-creation'
|
||||
],
|
||||
featureTipsSeenIds: [],
|
||||
featureInteractions: {},
|
||||
projectOrderManualDefaultNoticeDismissed: true
|
||||
}
|
||||
}
|
||||
writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function safeRemoveLocalDirectory(dir, cleanupErrors) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
|
||||
} catch (error) {
|
||||
cleanupErrors.push(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@
|
||||
"../src/main/claude/hook-settings.ts",
|
||||
"../src/main/claude/hook-service.ts",
|
||||
"../src/main/codex/codex-config-mirror.ts",
|
||||
"../src/main/codex/codex-config-path-reference-rewrite.ts",
|
||||
"../src/main/codex/codex-home-paths.ts",
|
||||
"../src/main/codex/config-toml-line-scan.ts",
|
||||
"../src/main/codex/config-toml-trust.ts",
|
||||
"../src/main/codex/hook-service.ts",
|
||||
"../src/main/codex-accounts/fs-utils.ts",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.3m">
|
||||
<title>downloads: 3.3m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 3.4m">
|
||||
<title>downloads: 3.4m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.3m</text>
|
||||
<text x="93.5" y="14">3.3m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">3.4m</text>
|
||||
<text x="93.5" y="14">3.4m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 434 KiB |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.18/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.21/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
# Reliability Gates Implementation Plan
|
||||
|
||||
Date: 2026-07-02
|
||||
|
||||
Source context: [`docs/reference/reliability-pain-points-2026-06-30.md`](./reliability-pain-points-2026-06-30.md).
|
||||
|
||||
## Current Status
|
||||
|
||||
Last updated: 2026-07-03.
|
||||
|
||||
The first repo-side reliability batch is useful, but it should be treated as a first-layer regression net, not a finished terminal reliability program. Before final merge claims, rebase or retarget the stack onto fresh `origin/main` and verify it still includes the protections that landed there after the branch was cut.
|
||||
|
||||
Stack topology facts the rest of this plan depends on (verified 2026-07-02):
|
||||
|
||||
- `brennanb2025/fix-terminal-reliability` is simultaneously the integration worktree and the head branch of #7001, and it is the base branch of all five child PRs (#7004-#7008). Committing broad product changes to it would turn #7001 into the single large terminal PR this plan forbids and would silently change every child PR's diff.
|
||||
- The committed branch contains only the manifest, checker, checker tests, and these docs (a 5-gate manifest as committed). Everything else this plan describes — the expanded manifest, product hardening, and new or updated tests across roughly 39 modified files plus 3 untracked files — exists only as uncommitted working-tree changes in this worktree. No evidence run below is reproducible from any pushed ref until the split branches are committed and pushed.
|
||||
- The branch's merge-base with `origin/main` is roughly 97 commits stale. Fresh main renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` in #6949, so the current `xterm-addon.boundary-containment` manifest entry and its documented command break on rebase; see Fresh-Eyes Corrections.
|
||||
- The manifest is the single source of truth for gate counts and per-gate status. Where this document records counts they are dated snapshots; `pnpm run check:reliability-gates` prints the current gate total.
|
||||
|
||||
Known Orca PR stack:
|
||||
|
||||
- [#7001](https://github.com/stablyai/orca/pull/7001), `Add reliability gate manifest`: merged on 2026-07-03. The manifest, checker, and plan docs are on main; [#7295](https://github.com/stablyai/orca/pull/7295) hardens the checker policy and registers the merged #7133/#7148/#7173/#7192 regression tests as partial gates with fresh main-side evidence.
|
||||
- [#7005](https://github.com/stablyai/orca/pull/7005), `Add terminal snapshot freshness contract gate`: depends on #7001 and protects the stale-liveness/newborn-PTY class behind `terminal-session.snapshot-freshness`.
|
||||
- [#7008](https://github.com/stablyai/orca/pull/7008), `Expand provider session replay ownership gate`: depends on #7001 and protects the agent/provider ownership class behind `agent-session.provider-ownership`.
|
||||
- [#7004](https://github.com/stablyai/orca/pull/7004), `Contain xterm addon failures to terminal panes`: depends on #7001. The current branch has a first executable xterm containment slice for addon load, link-provider, search, and WebGL failure containment; it still needs live typed input/output survival before promotion.
|
||||
- [#7006](https://github.com/stablyai/orca/pull/7006), `Prove visible terminal size convergence`: depends on #7001. The current branch now has a first deterministic renderer/main contract slice for post-spawn reconcile, split-right 0x0 recovery, applied `pty:getSize`, and resume-time size reassertion; it still needs live shell, SSH/remote, and Windows ConPTY geometry proof before promotion.
|
||||
- [#7007](https://github.com/stablyai/orca/pull/7007), `Add persisted session upgrade fixture gate`: depends on #7001. The current manifest entry is still commandless in this branch, so treat it as a registered startup-upgrade gap until immutable fixture commands are wired.
|
||||
|
||||
Merged related product hardening:
|
||||
|
||||
- [#7054](https://github.com/stablyai/orca/pull/7054), `Prevent hidden terminal TUI overlap`: merged on 2026-07-01 with `verify`, Wayland terminal input, golden Linux E2E, golden mac E2E, and community tracking checks passing. This fixes the hidden regular-terminal TUI overlap class by scheduling bounded post-parse WebGL atlas recovery for risky hidden TUI redraws while keeping hidden bytes on the background write path.
|
||||
- [#7133](https://github.com/stablyai/orca/pull/7133), `Fix stale terminal frames on worktree return`: merged on 2026-07-02 as a high-priority regression fix, root-caused on a live repro. Hidden-output restore skipped the destructive clear for alternate-screen snapshots, `?1049h` is a no-op on a pane already on the alt screen, and serialized frames skip blank cells, so the pre-hide frame bled through the final frame until a select or resize. The fix writes an alt-only `\x1b[?1049h\x1b[2J\x1b[H` preamble before alt-screen snapshot data (normal-buffer scrollback untouched, still no `3J`) and hardens the WebGL reveal path: dispose-on-bail with a single-addon invariant, refresh-always atlas reset, and a settled-frame pane-scoped repaint on tab reveal, worktree resume, and window wake. It supersedes the reverted #7058 approach and closes the alt-screen hole that #7054's hidden-output skip machinery made load-bearing. Its deterministic tests — the alt-only-clear-before-snapshot assertions in `pty-connection.test.ts`, plus `pane-webgl-renderer.test.ts`, `pane-reveal-repaint.test.ts`, and `terminal-visibility-resume.test.ts` — are the seed assertions for `terminal-output.scrollback-restore` and the WebGL side of `xterm-addon.boundary-containment` once the stack rebases.
|
||||
|
||||
- [#7148](https://github.com/stablyai/orca/pull/7148), `Match main terminal mirror character widths to the renderer`: merged on 2026-07-02, hours after #7133. Root cause of the residual post-#7133 bottom-row tears: the renderer xterm measures with Unicode 11 + ZWJ joining while the daemon headless mirror used default-width tables, so every positioned write after an emoji lands shifted and the tears are baked into the very mirror that restores replay — #7133's clear made restores faithful to the mirror and exposed it. The fix moves the unicode provider to `src/shared/terminal-unicode-provider.ts` and activates it in the headless emulator. Its red-green width test, `src/main/daemon/headless-emulator-unicode-width.test.ts` on main, is the seed assertion for the new `terminal-mirror.parser-parity` gate, and the shared provider plus `pane-terminal-options.ts` are the shared-construction seams that gate must assert on. #7148 also touches `pane-lifecycle.ts`, which both this worktree and #7004's branch modify — another confirmed rebase-conflict site.
|
||||
- [#7173](https://github.com/stablyai/orca/pull/7173), `Fix Codex pane output tearing after app occlusion`: merged on 2026-07-03. A delayed background-origin Codex chunk carrying a stateful query could take the hidden live-write path after a newer snapshot restore had already rebuilt the renderer, overwriting newer state; an adversarial review pass added seq-restart guards so revived sessions (hibernation wake, kill plus cold-restore under the same PTY id) cannot silently freeze hidden output against a stale seq high-water mark. Its three red-proven tests in `pty-connection.test.ts` on main — reference-parse buffer equality over the ordered raw stream plus both revival guards — are exactly the exactness-oracle shape this plan prescribes and seed the hidden/live interleaving slice of `terminal-output.scrollback-restore`.
|
||||
- [#7192](https://github.com/stablyai/orca/pull/7192), `Keep runtime mirror sized with desktop terminal resizes`: merged on 2026-07-03. Desktop `pty:resize` updated the PTY and renderer but never the runtime mirror, so the mirror parsed all subsequent bytes at its birth width, soft-wrapped TUI repaints into a corrupted ladder, and baked that corruption into every restore snapshot; the fix fans accepted desktop resizes to the mirror and serializes mirror reflow with queued output writes on the per-PTY write chain. This exposed a plan gap now fixed: the runtime mirror is a geometry authority in its own right, and renderer-vs-mirror convergence checks cannot see corruption upstream of the mirror. Its red-proven tests in `orca-runtime.test.ts` and `pty.test.ts` on main seed the mirror-geometry slice of `terminal-geometry.visible-convergence`.
|
||||
|
||||
Factory and skill status:
|
||||
|
||||
- [orca-internal #491](https://github.com/stablyai/orca-internal/pull/491), `Require reliability gates in factory reviews`: merged on 2026-07-01. This updates the factory review flow so terminal/session/provider/startup/release changes must name the relevant reliability class, invariant, gate or accepted gap, and validation evidence.
|
||||
- [orca-internal #493](https://github.com/stablyai/orca-internal/pull/493), `Add Playwright reliability test skill`: as last reviewed, this still needed rebase/regeneration before merge because the reliability plan depends on Playwright tests being evidence-based, low-flake, and tied to named invariants.
|
||||
|
||||
Remaining work:
|
||||
|
||||
- Rebase or retarget the reliability stack on fresh `origin/main` before making final merge claims. Current main already contains terminal fixes that the stack must absorb, including hidden TUI overlap, input-lag, scrollback replay, Windows ConPTY keyboard reset, resume width flicker, remote mirror polling, and the #7133 stale alt-screen frame fixes. As part of the rebase, mechanically verify every declared `testFiles` entry in the manifest exists on fresh `origin/main`; the #6949 WebGL rename is a confirmed break. #7133 (merged 2026-07-02) touches `pty-connection.{ts,test.ts}` and `use-terminal-pane-global-effects.test.ts`, which this worktree also modifies, and `pane-webgl-renderer.{ts,test.ts}`, which #7004's branch also edits — expect real conflicts there. #7148 (merged 2026-07-02) additionally touches `pane-lifecycle.ts` and `headless-emulator.ts`, so the worktree's and #7004's pane-lifecycle edits must absorb its shared unicode-provider wiring. #7173 (merged 2026-07-03) rewrites the hidden live-write path in `pty-connection.{ts,test.ts}` and #7192 (merged 2026-07-03) touches `pty.test.ts` — both carry heavy worktree modifications, so the conflict surface keeps widening the longer the rebase waits. This worktree's replay/SSH/backpressure changes and #7004's containment changes must be rebased over #7133's alt-clear preamble, #7148's width fix, #7173's seq filtering, #7192's mirror resize fan-out, and the reveal-repaint hardening without weakening any side.
|
||||
- Materialize the split: commit each uncommitted working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset diverged child branches from the worktree versions of their files, and push. Only manifest, checker, and doc changes may be committed to `brennanb2025/fix-terminal-reliability` itself.
|
||||
- Wire the aggregated non-blocking `reliability-gates` CI job (see Evidence Provenance And CI Wiring) so runtime and flake history accrue with machine provenance instead of hand-entered evidence.
|
||||
- Split the current integration branch into focused PRs before final review and merge. Do not land this as one large terminal reliability PR; each PR should have its own invariant, evidence command, residual gaps, dependency note, and follow-up relationship to the rest of the stack.
|
||||
- #7001 merged on 2026-07-03. Merge the gate-specific PRs only after resetting each from the worktree slice per the file assignment.
|
||||
- Before merging each child PR, recheck it against the latest #7001/main because the shared manifest file can create normal stack drift or conflicts even when the PR currently reports clean.
|
||||
- After merge, run the registered gate commands in soak and record runtime/flake evidence before promoting any gate to blocking.
|
||||
- Update the factory skills again after the repo-side gate commands and CI jobs are stable, so agents can invoke exact commands instead of only requiring reliability evidence.
|
||||
- Keep #7054's accepted gap visible: deterministic Claude-like TUI streams validated the renderer path, but live Claude Code was not part of that PR's validation. #7133 has since closed the alt-screen snapshot-restore hole in the same hidden-output machinery, and its buffer-merge root cause is exactly the class the `terminal-output.scrollback-restore` gate must make hard to reintroduce; the live-Claude validation gap remains.
|
||||
|
||||
## PR Split Plan
|
||||
|
||||
The current `brennanb2025/fix-terminal-reliability` branch is an integration branch, not the desired final review shape. It should be split into smaller PRs so reviewers can verify one reliability class at a time, bisect failures, and avoid merging a broad terminal lifecycle change without understanding which protection each file belongs to.
|
||||
|
||||
Branch and manifest ownership rules:
|
||||
|
||||
1. `brennanb2025/fix-terminal-reliability` is #7001's head branch. Only manifest, checker, checker-test, and plan-doc changes may be committed to it. Split items 2-6 go to their own branches, stacked on this branch and retargeted to `main` after #7001 merges.
|
||||
2. #7001 may only declare gates whose `testFiles` all exist on `origin/main`. The checker's declared-file rule makes this self-enforcing: a manifest entry whose test files land in a later PR cannot pass CI inside #7001.
|
||||
3. Every later PR owns its own manifest entries. It adds or upgrades the gates it implements — entry, `evidenceRuns`, and `assertionRefs` — in the same PR as the tests and hardening they reference. The manifest file is shared, but each entry has exactly one owning PR, and manifest conflicts are resolved by entry ownership, never by hand-merging divergent copies of an entry.
|
||||
4. Commandless registered-gap entries (`protection: "none"`, no test files) may ride in #7001 because they reference nothing that can drift.
|
||||
5. The integration worktree is the single authoritative copy of every overlapping file. Child branches that have diverged (for example, `resume-sleeping-agent-session.ts` on #7008's branch is ~156 diff lines behind this worktree) must be reset from the worktree slice, not reconciled by hand.
|
||||
|
||||
Recommended split:
|
||||
|
||||
1. Reliability gate manifest and plan docs.
|
||||
This PR owns `config/reliability-gates.jsonc`, the manifest checker hardening, checker tests, and this plan document. It should merge first because later PRs reference gate ids, evidence metadata, and promotion rules from the manifest. It should not include terminal product behavior changes.
|
||||
|
||||
2. Provider ownership and session replay safety.
|
||||
This PR owns the agent/provider identity invariant: activation, restore, sleep, hibernate, dedupe, clearing, and reconnect paths must not replay a provider session that is already owned, queued, pending, retained, or live in the workspace. It should include the provider-session ownership tests, repeat-activation lower-layer proofs, automatic resume claim coverage, same-session/wrong-session hook coverage, and any narrowly required product hardening.
|
||||
|
||||
3. Startup hydration, provider fanout, and targeted liveness.
|
||||
This PR owns startup-adjacent PTY registry hydration, targeted `pty.hasPty(id)` liveness, no-hot `pty:listSessions()` paths, Resource Manager polling scoping, SSH/provider unknown-liveness semantics, degraded-daemon fail-closed behavior, and related tests. It should depend on the manifest PR and should explicitly say that live SSH, WSL, remote-runtime, and production startup reconcile remain follow-up gates unless implemented in the same PR.
|
||||
|
||||
4. Terminal lifecycle observability.
|
||||
This PR owns compact anomaly breadcrumbs and bounded lifecycle traces for terminal lifecycle diagnosis. It should make failures easier to debug without claiming to prevent them. It should prove that diagnostics are compact, deduped or bounded, privacy-conscious, and included in the right local crash/diagnostic surfaces. Diagnostics-bundle export and live forbidden-transition artifacts should remain visible follow-ups until proven.
|
||||
|
||||
5. PTY output, IPC, and performance hardening.
|
||||
This PR owns daemon stream batching, main-to-renderer pending output caps, ACK/backpressure behavior, replay queue bounds, runtime/mobile stream byte budgets, and related perf-sensitive contracts. It needs the hardest review because small behavior changes here can cause input latency, hidden-output, memory, or ordering regressions. It should include explicit runtime budgets and should not promote any perf gate to blocking without soak evidence.
|
||||
|
||||
6. Live local PTY and E2E reliability slice.
|
||||
This PR owns the first live Electron/local PTY liveness gate. It should stay small: spawn a real terminal, prove one active PTY, type through focused xterm, verify process-visible output, hide/restore the workspace without duplicating or losing the PTY, resize and verify applied size, then exit and verify cleanup. It should remain `experimental` until CI/runtime history proves it is not flaky. Tab switch, app restart, SSH, WSL, Windows ConPTY, and scrollback restore should be explicit follow-ups unless included with equally strong oracles.
|
||||
|
||||
Mapping to the open stack and file assignment:
|
||||
|
||||
The six split items and the five open child PRs overlap; this mapping is authoritative. Every working-tree file must land in exactly one PR below. Four files contain changes from multiple reliability classes and must be split by hunk, with the earlier PR in the merge order owning its hunks first: `config/reliability-gates.jsonc` (per-entry ownership), `src/main/ipc/pty.ts` and its test, `src/renderer/src/components/terminal-pane/pty-connection.ts` and its test, and `src/renderer/src/store/slices/terminals.ts`.
|
||||
|
||||
1. #7001 (split item 1, manifest and plan docs): `config/scripts/check-reliability-gates.mjs`, `config/scripts/check-reliability-gates.test.mjs`, `docs/reference/reliability-gates-implementation-plan.md`, and the manifest restricted to gates whose test files exist on `origin/main` plus commandless gap entries.
|
||||
2. #7008 (split item 2, provider ownership and replay safety): `src/renderer/src/lib/resume-sleeping-agent-session.ts` and test, plus its ownership slice of `terminals.ts`. Reset the branch from the worktree slice; keep the PR number for review continuity.
|
||||
3. #7005 (snapshot freshness and targeted renderer liveness): `terminal-dead-session-reconcile.ts` and test, `use-terminal-pane-lifecycle.ts` and test, `use-terminal-pane-global-effects.test.ts`, and the `pty.hasPty` preload/API plumbing (`src/preload/api-types.ts`, `src/preload/index.ts`, `src/renderer/src/web/web-preload-api.ts`, the `hasPty` slice of `src/main/ipc/pty.ts`).
|
||||
4. New PR (split item 3, startup hydration, provider contracts, no-hot listing): `hydrate-local-pty-registry.ts` and test, `daemon-pty-adapter.ts` and test, `daemon-pty-router.ts` and test, `degraded-daemon-pty-provider.ts` and test, `pty-session-id.test.ts`, `src/shared/pty-session-id-format.ts`, `src/main/providers/ssh-pty-provider.ts`, `ResourceUsageStatusSegment.tsx`, `resource-manager-session-polling.test.ts` (currently untracked), `store-session-cascades.test.ts`, and the SSH/liveness slices of `pty.ts`, `pty-connection.ts`, and `terminals.ts`.
|
||||
5. New PR (split item 4, lifecycle observability): `terminal-lifecycle-diagnostics.ts` and `terminal-lifecycle-diagnostics.test.ts` (currently untracked).
|
||||
6. New PR (split item 5, PTY output/IPC/perf hardening): `daemon-stream-data-batcher.ts` and test, `terminal-output-batching.test.ts`, `terminal-subscribe-buffer.test.ts`, and the pending-output-cap and replay/ACK slices of `pty.ts` and `pty-connection.ts`.
|
||||
7. #7004 (xterm addon containment, outside the six split items): keep as its own PR; reset `pane-lifecycle.ts` and test from the worktree versions, and adopt fresh main's renamed WebGL test files during rebase.
|
||||
8. #7006 (visible geometry, outside the six split items): keep as its own PR; any geometry hunks in `pty-connection.ts` belong to it.
|
||||
9. #7007 (persisted upgrade fixtures): the gate is commandless with `protection: "none"`, so its manifest entry rides in #7001 under rule 4. Keep #7007 open only if it will deliver the actual fixture corpus and command; otherwise close it as absorbed.
|
||||
10. New PR (split item 6, live local PTY E2E slice): `tests/e2e/terminal-live-pty-liveness.spec.ts` (currently untracked).
|
||||
|
||||
Merge order: #7001, then #7008, then #7005, then startup hydration/provider contracts, then lifecycle observability, then PTY output/IPC/perf hardening, then #7004, then #7006, then the live E2E slice. #7007 can merge any time after #7001 or be closed. The stack stays flat on #7001; each child rebases onto the merged state immediately before its own merge, and manifest conflicts are resolved by entry ownership.
|
||||
|
||||
If one later PR reveals a missing invariant in an earlier PR, update the earlier manifest entry or PR description before promotion rather than silently broadening the later PR's claim. The split is part of the reliability plan because reviewability, dependency clarity, and targeted rollback are themselves reliability controls.
|
||||
|
||||
Each split PR should follow Brennan's PR process before it is considered ready: write a scoped design doc, run delegated design review, implement from the reviewed doc, verify completeness against the original request and reliability gate, audit headline behavior, run `perf`, run the code-review/fix loop, run merge-confidence validation, open an unmerged PR, verify pushed state, and stabilize CodeRabbit/check findings. The PR text should describe that process as "Brennan's PR process"; do not mention the internal skill name in PR-facing text.
|
||||
|
||||
## PR Proof Contract
|
||||
|
||||
Every split PR must include a short proof section in its PR description before it can be treated as ready. This section should use direct evidence, not confidence language. Avoid absolute claims such as "this guarantees terminal reliability" or "this cannot regress performance." The valid claim is narrower: "this PR is safe to merge because the affected invariant, performance budget, and residual gaps were checked with the evidence below."
|
||||
|
||||
Required PR description sections:
|
||||
|
||||
- **Reliability invariant:** the class-level rule this PR protects and the recent issue, PR, or accepted gap that motivated it.
|
||||
- **Material impact:** why this will meaningfully reduce escaped regressions, including which known bug class becomes harder to reintroduce.
|
||||
- **Product change type:** docs/tooling, test-only gate, product hardening, diagnostics, perf hardening, or mixed.
|
||||
- **Performance risk inventory:** whether the PR touches typing, focus, tab/workspace switch, visibility resume, resize, render, startup, provider listing, hidden output, PTY output, store subscribers, git/worktree scans, SSH, WSL, Windows, or mobile/relay paths.
|
||||
- **No-regression evidence:** the exact count test, deterministic contract test, metric artifact, typecheck, or live run that proves the PR did not add unbounded scans, polling, hidden-pane wake loops, startup awaits, subprocess churn, renderer jank, or output buffering growth.
|
||||
- **Correctness evidence:** the exact oracle and command proving the invariant. Tests that only prove implementation shape are not enough.
|
||||
- **Provider/platform coverage:** local, daemon, SSH, WSL, remote-runtime, mobile/relay, macOS, Linux, and Windows must be marked covered, unaffected, or accepted-gap.
|
||||
- **Residual gaps:** what this PR still does not prove. These gaps must remain in the manifest until a later PR removes them.
|
||||
- **Promotion status:** whether the gate is `none`, `partial`, or `active`, and why it is not promoted beyond its current maturity.
|
||||
- **Rollback or demotion rule:** what signal should cause the PR's gate to be demoted, disabled, or followed up.
|
||||
|
||||
Performance rule:
|
||||
|
||||
- A reliability PR is incomplete if it adds polling, global session listing, provider fanout, hidden-pane renderer wakeups, startup-critical awaits, repeated resize IPC, subprocess churn, unbounded output queues, broad store subscriptions, or uncapped diagnostics without a deterministic count test or metric budget.
|
||||
- `pty:listSessions()` remains a global management/diagnostic operation, not a hot-path liveness primitive. Typing, focus, tab/workspace switch, visibility resume, resize, render, and per-pane liveness paths should use targeted APIs or cached ownership unless a PR provides measured proof that the broader call is safe.
|
||||
- Any PR touching git/worktree scans, SSH git providers, WSL paths, cleanup, or startup repository enumeration must include the `git-crash-perf` style proof: bounded subprocesses, timeout/abort behavior, provider parity, and no startup-critical scan.
|
||||
- Any PR touching terminal/session/provider/startup code must use the terminal-session reliability contract plus a performance budget. A green reliability test without a perf proof is not enough.
|
||||
- Any PR that changes PTY output, IPC batching, ACK/backpressure, replay, or hidden-output behavior (split item 5 especially) must include a before/after run of an existing terminal perf artifact — `test:e2e:terminal-perf:scale:report` or the typing-latency specs — on the same machine, with both results in the PR description. Deterministic byte/count contracts prove bounds; only the perf artifact proves interactive latency did not regress.
|
||||
- The gates themselves are a performance surface. Every command-backed gate must declare a runtime budget in the manifest, unit-layer gate commands should stay in single-digit seconds, and the aggregated CI job in Evidence Provenance And CI Wiring must stay inside its own wall-clock budget so reliability coverage does not degrade the development loop it protects.
|
||||
|
||||
Per-PR proof expectations:
|
||||
|
||||
1. Reliability gate manifest and plan docs.
|
||||
Performance proof: no runtime product path changes. Checker runtime should stay cheap and deterministic, and manifest validation should not depend on network calls or live providers.
|
||||
Materiality proof: the checker prevents overclaiming by requiring evidence metadata, existing test files, command-backed assertions, covered scope, and clear `none`/`partial`/`active` status.
|
||||
|
||||
2. Provider ownership and session replay safety.
|
||||
Performance proof: activation and replay checks must be bounded by indexed workspace-local state, not repeated provider listing or O(records x pending-startups) scans. Tests should include repeat activation and bounded-work assertions where scans are introduced.
|
||||
Materiality proof: this blocks the class where an inactive or hidden but valid provider session is mistaken for unowned and resumed again.
|
||||
|
||||
3. Startup hydration, provider fanout, and targeted liveness.
|
||||
Performance proof: startup hydration must not block first usable UI, must expose bounded counters, and must not multiply provider/git scans across repos and worktrees without a budget. Hot interaction paths must prove zero broad `pty:listSessions()` calls.
|
||||
Materiality proof: this prevents stale global snapshots or failed provider observations from closing or replacing the wrong terminal, while reducing session-listing fanout.
|
||||
|
||||
4. Terminal lifecycle observability.
|
||||
Performance proof: breadcrumbs and traces must be capped, primitive-only, deduped or ring-buffered, and must not serialize terminal output, filesystem trees, scrollback, or large state. Tests must prove caps and sanitization.
|
||||
Materiality proof: this does not itself prevent regressions, but it materially shortens future diagnosis by preserving the lifecycle facts needed to debug ownership, liveness, restore, resize, and replay failures.
|
||||
|
||||
5. PTY output, IPC, and performance hardening.
|
||||
Performance proof: this PR must carry the strongest perf evidence in the stack: byte caps, queue caps, ACK/backpressure ordering, active-output priority, hidden-output behavior, and no unbounded renderer/main buffering. It should not become blocking until soak data shows stable runtime and no unexplained flakes.
|
||||
Materiality proof: this protects the freeze/input-lag/memory-growth class where background or high-volume terminal output harms the active terminal or the app.
|
||||
|
||||
6. Live local PTY and E2E reliability slice.
|
||||
Performance proof: the Playwright gate must be small, serial where needed, artifact-producing, and timed. It should prove the user path without becoming a broad slow/flaky suite. Runtime and flake history are required before promotion.
|
||||
Materiality proof: this covers the layer that fake providers cannot prove: real Electron terminal spawn, keyboard input, process-visible output, hide/restore, resize, and cleanup.
|
||||
|
||||
## Factory And Skill Follow-Up
|
||||
|
||||
The current factory skills are close enough that the next step should be targeted edits, not a large new skill stack. The terminal-specific skill already exists, and Brennan's PR process already requires design review, perf review, code review, validation, pushed-state checks, and PR stabilization. The missing factory behavior is that reviewers and validators should explicitly fail PRs that omit the PR Proof Contract above.
|
||||
|
||||
Recommended skill/factory updates:
|
||||
|
||||
1. Update `terminal-session-reliability`.
|
||||
Add the PR Proof Contract as a required output for terminal/session/provider/startup PRs. The skill should require reliability invariant, material impact, performance risk inventory, no-regression evidence, provider/platform coverage, residual gaps, promotion status, and rollback/demotion rule.
|
||||
|
||||
2. Update `review-code`.
|
||||
Make missing proof-contract sections a review finding for P0-capable terminal/session/provider/startup/release surfaces. Reviewers should not accept "tests pass" as enough when the PR lacks a named invariant, perf budget, provider/platform matrix, or accepted-gap statement.
|
||||
|
||||
3. Update `brennan-test-changes`.
|
||||
Require validation reports to say whether the relevant reliability gate command ran, whether the evidence proves the user-visible invariant, whether the test layer is sufficient, and whether any skipped SSH, WSL, Windows, remote-runtime, mobile, or live-Electron path is an accepted gap.
|
||||
|
||||
4. Update `playwright-reliability-tests`.
|
||||
Add the live-terminal reliability bar directly: Playwright terminal tests must prove real input/output or visible degraded state, use deterministic event oracles instead of blind sleeps, record runtime/artifacts, and stay `experimental` until flake history supports promotion.
|
||||
|
||||
5. Update `perf`.
|
||||
Add the PR Proof Contract's no-regression evidence language so perf reviewers require deterministic count tests or metric artifacts for polling, global session listing, provider fanout, hidden-pane wakeups, startup awaits, subprocess churn, output queue growth, and broad store subscriptions.
|
||||
|
||||
6. After the repo-side gate commands stabilize, update factory prompts to invoke exact gate ids and commands.
|
||||
Do this after the PRs land or after their commands are stable enough to reference. Before then, the factory should require the gate class and evidence, but should not hard-code commands that may still split or rename during PR extraction.
|
||||
|
||||
Do not add a separate broad "terminal reliability reviewer" skill unless the existing skills fail to enforce this contract after these edits. Too many overlapping skills can make agents diffuse responsibility; the better shape is one terminal reliability contract plus required calls from design, review, perf, Playwright, and validation stages.
|
||||
|
||||
## Coverage Reality Check
|
||||
|
||||
This first stack is useful, but it must not be described as "terminal reliability is covered." The executable protection in this branch covers a deliberately small first slice of recent escaped classes:
|
||||
|
||||
- stale local/daemon liveness snapshots closing newer PTY bindings;
|
||||
- provider-session replay ownership for several active, inactive, queued, pending, live, retained, same-session hook, and wrong-session hook records, with real hook timing through Electron still explicitly partial;
|
||||
- targeted `hasPty` liveness after visibility resume instead of hot-path global `listSessions`;
|
||||
- degraded daemon fallback fail-closed behavior for restored worktree-scoped and legacy/non-scoped ids;
|
||||
- SSH provider listing/`hasPty` failures treated as unknown rather than destructive ownership evidence, plus mocked provider/renderer coverage for deferred SSH attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback;
|
||||
- remote replay coalescing, FIFO ordering, and bounded burst-tail coalescing at the renderer unit layer;
|
||||
- visible geometry contract behavior for delayed split layout settle, 0x0 split-right recovery, applied-vs-requested `pty:getSize`, and resume-time size reassertion;
|
||||
- boot-time local PTY registry hydration counters for repo/worktree enumeration, skipped SSH repos, adapter/session listing, registration/skips, duration, and failure phase;
|
||||
- terminal lifecycle anomaly breadcrumbs recorded into renderer crash diagnostics with dedupe and compact identity fields;
|
||||
- live local Electron PTY spawn, active PTY listed exactly once, focused xterm keyboard input, process-visible output, repeated workspace hide/restore of the same PTY, actual resize propagation, and exit cleanup.
|
||||
|
||||
Those gates should catch re-regressions in those classes, and the tests are intentionally deterministic. They mostly use renderer-unit, renderer-state, provider-contract, or fixture layers because those layers provide precise oracles with low flake risk. That is a strength for the classes they cover.
|
||||
|
||||
The manifest also contains commandless registered gaps for persisted-session upgrade fixtures, Windows ConPTY, startup color-query handling, and other future gates. Those entries are useful planning structure, not executable protection. Visible geometry convergence, xterm addon containment, IME/native text forwarding, runtime/mobile streams, and startup hydration counters now have first executable contract slices, but they remain `experimental` until they have repeatable CI/runtime history and red-green evidence. Output backpressure now has daemon stream and main pending-output contract slices, but the broader live perf budget remains experimental until it has repeatable metric artifacts, runtime history, and red-green evidence. The live local PTY gate now has a first executable Playwright slice that includes repeated workspace hide/restore of the same PTY, but it remains `experimental` until it has repeatable CI/runtime history and red-green evidence.
|
||||
|
||||
The critique that this stack is still shallow is fair. The current gates do not yet prove:
|
||||
|
||||
- tab-switch restore, scrollback after restore, app restart persistence, and non-local live providers;
|
||||
- Windows ConPTY behavior, including shell resolution, cursor restore, rewrite repaint, CJK/IME edge cases, kitty/input protocol behavior, and activation hangs;
|
||||
- terminal input latency, throughput, hidden-output pressure, renderer CPU, and store/subscriber hot paths under load;
|
||||
- IME and synthetic input forwarding across macOS, Windows, Linux/fcitx/Sogou, Vietnamese, CJK, Arabic, paste, and modifier paths;
|
||||
- focus recovery after browser/terminal handoffs, tab switches, app restart, and non-local provider restore;
|
||||
- daemon degraded spawns, stale bootstrap, remote-runtime startup, SSH/WSL provider boundaries, and mirror polling;
|
||||
- scrollback clearing/restoration correctness across hidden output, snapshot replay, and restore.
|
||||
|
||||
Those missing classes already appear in the full pain-points report, but this implementation plan should keep them visible so the first stack does not become false confidence.
|
||||
|
||||
## Fresh-Eyes Corrections
|
||||
|
||||
The follow-up reviews changed the plan in several concrete ways:
|
||||
|
||||
- Windows/IME/CJK are not covered by the current executable stack. Orca has useful unit coverage and product logic for local Windows ConPTY compatibility, including withholding Kitty keyboard protocol for local native Windows panes, but no live Windows ConPTY gate proves post-agent ordinary keys, shell parity, CJK repaint, cursor restore, resize readback, or real IME composition.
|
||||
- SSH restore is better covered than the first manifest draft said, but only at store/provider/main/renderer contract layers. The current gate asserts deferred wake metadata, deferred attach/passphrase/expired-relay contracts, and unknown-liveness behavior with fake transports; it does not prove a live SSH relay, WSL, remote-runtime mirror polling, false-live visual state, or real network reconnect timing.
|
||||
- Perf/backpressure is currently deterministic byte-accounting proof, not live responsiveness proof. The branch asserts daemon `write(false)`/`drain` behavior, cross-session drain priority for flush-immediate output, pure-backpressure cleanup, and main pending-output caps; it does not yet prove renderer parse pressure, event-loop delay, active key latency, or live hidden-output flood behavior.
|
||||
- Daemon startup reconcile remains a mechanism, not a production-proven startup behavior. Either wire it with dry-run diagnostics and prior-worktree aliases, or keep it as an accepted gap with a named owner.
|
||||
- Gate evidence is still too easy to hand-enter. The checker now prevents obvious metadata overclaims, but it does not prove the registered command was run in CI, preserve artifacts, or verify that each declared assertion executed. Add machine-checkable artifact/CI-run provenance before promoting gates to blocking.
|
||||
- Manifest evidence in this worktree is not yet durable. Three referenced test files are untracked (`resource-manager-session-polling.test.ts`, `terminal-lifecycle-diagnostics.test.ts`, `tests/e2e/terminal-live-pty-liveness.spec.ts`) and roughly 39 more files carry uncommitted modifications. Until each slice is committed and pushed to its owning split branch, the manifest references proof that CI cannot run and that no one can reproduce from a ref.
|
||||
- Fresh `origin/main` renamed `terminal-webgl-paste-recovery.{ts,test.ts}` to `terminal-webgl-atlas-recovery.{ts,test.ts}` and added `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` in #6949. The `xterm-addon.boundary-containment` gate and its documented command reference the old filename and will fail the checker after rebase; the gate should adopt the renamed and new tests during the rebase. #7133 (merged 2026-07-02) further modified `pane-webgl-renderer.{ts,test.ts}` and added `pane-reveal-repaint.{ts,test.ts}` and `terminal-visibility-resume.{ts,test.ts}`; the containment gate should evaluate those reveal-hardening tests for adoption in the same sweep. This is a confirmed instance of the stale-branch risk, and it shows the fresh-main review must include a mechanical testFiles-vs-main sweep, not only behavioral spot checks.
|
||||
- This document itself drifted from the manifest it describes (a prior revision said 23 gates, 13 partial/10 none, while the manifest held 24 gates, 15 partial/9 none). Treat the manifest as authoritative and this document's counts as dated snapshots.
|
||||
- The Resource Manager status segment no longer polls broad `pty:listSessions()` for its closed-popover badge in this branch. Broad session inventory is now scoped to the open Resource Manager and explicit kill/restart refresh paths; the no-hot-listing plan still needs status/diagnostic budgets and broader Electron counters before promotion.
|
||||
- Boot-time local PTY registry hydration now exposes a debug snapshot and deterministic tests for retry state, SSH skip-before-worktree-enumeration, local registration counters, adapter/session listing counts, recoverable adapter-list failures, fatal hydration failures, router adapter fanout, duration, and large daemon lists. This is still only a startup-hydration counter slice; it does not prove focus, typing, tab switch, workspace switch, render, high-session provider fanout, or real Electron startup budgets.
|
||||
- Terminal lifecycle anomaly diagnostics now record compact crash breadcrumbs in addition to console warnings. This is useful support evidence, not yet the full pane lifecycle trace buffer requested in the pain-points report.
|
||||
|
||||
## Fresh Main Architecture Findings
|
||||
|
||||
These findings came from focused reviews of current terminal lifecycle, renderer/input/perf, and provider/platform/startup code against freshly fetched `origin/main`. They are not all regressions introduced by the reliability-gate branch. They are the places the plan must cover before claiming broad terminal reliability.
|
||||
|
||||
Current coverage already exists in more places than the first gate stack showed:
|
||||
|
||||
- `terminal-dead-session-reconcile.test.ts`, `resume-sleeping-agent-session.test.ts`, and `pty-connection.test.ts` have substantial deterministic lifecycle coverage. A focused run of those files passed 292 tests.
|
||||
- Existing E2E/perf tests include terminal typing latency, hidden TUI visual restore, long-table scroll restore, daemon slow-init PTY restore, SSH localhost, and terminal tab-switch SIGWINCH restore.
|
||||
- Existing scripts include terminal perf report commands such as `test:e2e:terminal-perf`, `test:e2e:terminal-perf:scale`, `test:e2e:terminal-perf:scale:report`, and `test:e2e:terminal-perf:check-report`.
|
||||
|
||||
The problem is that these tests are not yet organized into reliability gates with named invariants, owners, maturity, runtime budgets, flake history, red/green evidence, and promotion rules. Some also need review before promotion because a useful regression test can still be too broad, too slow, or too flaky to be a blocking gate.
|
||||
|
||||
Local validation evidence from this review pass:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
|
||||
src/renderer/src/lib/resume-sleeping-agent-session.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-01: 3 test files passed, 292 tests passed, duration 6.04s. This asserts the current deterministic lifecycle/unit layer is healthy in this worktree; it does not prove the broader live/platform/provider/perf gaps.
|
||||
|
||||
The provider-session ownership gate now includes the queued/pending resume bridge from fresh `origin/main`: automatic resumes thread `resumeProviderSession` through `pendingStartupByTabId`, claim `automaticAgentResumeClaimsByTabId`, and treat pending startup, time-bounded runtime claims, and live agent statuses as provider-session ownership. The queued/pending claim scan is indexed once per activation so replay protection does not rescan every pending startup for every sleeping record. It also asserts live hook evidence claims only the matching provider session; a wrong-session hook cannot claim a replayed sleeping record; and stale automatic bridge claims cannot suppress recovery forever.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-01: 1 test file passed, 34 tests passed, duration 7.86s. This focused command asserts repeat activation or replayed sleeping records do not launch a second resume for the same provider session while the first resume is still pending, queued pending-startup provider-session ids are read once per activation rather than once per replayed record, a live hook for the same provider session owns the replay, and a wrong-session hook does not. It does not yet prove real hook timing through Electron or a full Electron workspace activation loop.
|
||||
|
||||
Structural manifest validation also passed locally:
|
||||
|
||||
```sh
|
||||
pnpm run check:reliability-gates
|
||||
```
|
||||
|
||||
Result on 2026-07-01 after the first expansion: reliability gate manifest check passed for 22 gates. This validated the manifest schema/metadata shape only; it did not prove that the registered commands covered the full terminal runtime.
|
||||
|
||||
After the broader architecture review and fresh-agent review, the manifest was expanded further, including a dedicated WSL restore contract instead of burying WSL under SSH/remote coverage, and later the three convergence/escape-detection gaps registered after the #7133/#7148 saga. As of 2026-07-03, main's manifest holds 27 gates split 9 `partial` (gates whose evidence exists on main itself, including the #7133/#7148/#7173/#7192 regression tests) and 18 `none`; the pending stack raises more gates to `partial` as its slices land. These counts are dated snapshots, and the manifest itself is authoritative. The new entries are intentionally `experimental`; a few now have first executable slices, while others are still commandless gap markers. They are not active protection until each gate gets an implemented command, red/green proof, runtime evidence, and promotion history.
|
||||
|
||||
The manifest checker now enforces the review lessons that were easy to miss manually:
|
||||
|
||||
- declared `testFiles` must exist;
|
||||
- every declared executable `testFiles` entry must be referenced by at least one gate command, so a manifest cannot borrow credibility from a file the registered command does not run;
|
||||
- command-backed gates cannot use title selectors such as `-t`, `--grep`, or `--testNamePattern`, because title drift silently changes coverage;
|
||||
- every gate must declare `protection: none | partial | active`;
|
||||
- commandless gates must declare `protection: "none"`, while command-backed gates must declare at least `protection: "partial"`;
|
||||
- `protection: "active"` is reserved for blocking gates with stable flake history and complete red/green evidence;
|
||||
- `partial` and `active` gates must include at least one passed structured `evidenceRuns` entry whose command exactly matches the gate command;
|
||||
- `none` gates must have no evidence runs, so planning gaps cannot look like tested coverage;
|
||||
- `partial` and `active` gates must include `assertionRefs` that point to declared test files and name the invariant assertions inside those files;
|
||||
- `none` gates must have no assertion refs, so planning gaps cannot borrow credibility from unrelated tests;
|
||||
- every gate must split risk scope from covered scope with `coveredPlatforms`, `coveredProviders`, and `coverageNotes`;
|
||||
- `coveredPlatforms` and `coveredProviders` must be inside the declared risk scope, and passed evidence-run platforms must appear in `coveredPlatforms`;
|
||||
- `soak` and `blocking` gates must have soaking/stable flake evidence and complete red/green evidence.
|
||||
|
||||
How to read the manifest:
|
||||
|
||||
- `platforms` and `providers` describe the affected risk scope for an invariant. They do not mean the current command exercises every listed platform/provider.
|
||||
- `coveredPlatforms`, `coveredProviders`, and `coverageNotes` describe the scope actually covered by current evidence. Empty `coveredProviders` on a partial gate usually means renderer/state/RPC contract coverage, not a real provider run.
|
||||
- `protection: "none"` means a registered gap only; `protection: "partial"` means useful executable coverage that is not blocking protection yet; `protection: "active"` means the gate is blocking and has stable evidence.
|
||||
- `evidenceRuns` are factual run records: date, runner, platform, exact command, result, duration, and summary. A local passed run supports `partial` coverage; it does not imply CI stability or flake history.
|
||||
- `assertionRefs` explain which assertion groups in the listed test files carry the gate. This is especially important for shared files like `pty-connection.test.ts`, where the whole file passing is not the same as the whole file being relevant.
|
||||
- `oracle`, `redGreenEvidence`, and `knownGaps` are the authoritative source for what is actually proved today.
|
||||
- Commandless `experimental` gates are registered gaps, not active protection.
|
||||
|
||||
New or expanded manifest entries:
|
||||
|
||||
- `terminal-platform.live-pty-liveness`
|
||||
- `terminal-platform.windows-conpty-liveness`
|
||||
- `terminal-performance.no-hot-list-sessions`
|
||||
- `terminal-performance.output-backpressure-budget`
|
||||
- `terminal-performance.input-throughput`
|
||||
- `terminal-performance.daemon-stream-backpressure`
|
||||
- `terminal-performance.store-and-git-hot-paths`
|
||||
- `terminal-provider.daemon-startup-degraded-contract`
|
||||
- `terminal-provider.ssh-remote-reattach-contract`
|
||||
- `xterm-addon.boundary-containment`
|
||||
- `terminal-output.scrollback-replay-fifo`
|
||||
- `terminal-output.scrollback-restore`
|
||||
- `terminal-input.ime-and-synthetic-forwarding`
|
||||
- `terminal-input.windows-conpty-keyboard-reset`
|
||||
- `terminal-render.windows-cjk-repaint`
|
||||
- `terminal-shell.windows-resolution-parity`
|
||||
- `terminal-capability.startup-color-query`
|
||||
- `terminal-runtime.mobile-stream-budget`
|
||||
- `terminal-mirror.parser-parity`
|
||||
- `terminal-observability.restore-convergence-selfcheck`
|
||||
- `terminal-render.pixel-refresh-repair`
|
||||
|
||||
The visible geometry gate now has its first deterministic renderer/main contract slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-size-reconcile.test.ts \
|
||||
src/renderer/src/components/terminal-pane/split-right-white-screen.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
|
||||
src/main/ipc/pty.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 4 test files passed, 481 tests passed, duration 5.98s. This focused command asserts delayed hidden split-layout settles are forwarded instead of stopping on a fixed frame budget, unmeasurable frames do not count as settled, visible 0x0 split-right panes get a nonzero recovery size while hidden background 0x0 panes are not forced to desktop size, `pty:getSize` reports applied size when a provider can expose it, and visibility resume reasserts only real PTY/xterm drift without falling back to `listSessions`. It does not yet prove shell-visible `stty`, SSH/remote geometry, Windows ConPTY geometry/readback, or full Electron geometry under real split/tab/app-restart workflows.
|
||||
|
||||
The live local PTY gate now has its first executable Electron slice:
|
||||
|
||||
```sh
|
||||
pnpm run ensure:electron-runtime && npx playwright test \
|
||||
tests/e2e/terminal-live-pty-liveness.spec.ts \
|
||||
--config tests/playwright.config.ts --project electron-headless --workers=1
|
||||
```
|
||||
|
||||
Result on 2026-07-02 after adding two workspace hide/restore cycles, exact active-PTY listing, and actual resize convergence: 1 Playwright test passed, test body 8.6s, full command 58.6s including build/setup. This one local macOS run asserts a real Electron local terminal binds a PTY, lists the active PTY id exactly once, accepts keyboard input through focused xterm, renders process output markers, preserves the same PTY id through repeated worktree switch-away/switch-back cycles, accepts additional keyboard input after each restore, applies an actual `pty:getSize` change after viewport resize, reports the same process-visible size, exits the probe, exits the shell, and removes the PTY id from `pty:listSessions`. It does not yet prove tab switch within the same worktree, scrollback after restore, app restart persistence, daemon, SSH, WSL, remote-runtime, Linux CI stability, or Windows ConPTY behavior.
|
||||
|
||||
The IME/native-text gate now has its first deterministic renderer-unit slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-ime-native-text-forwarder.test.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-ime-input-source.test.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-paste-runtime.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 3 test files passed, 63 tests passed, duration 887ms. This registers existing native-text, input-source, and paste/runtime forwarding contracts as a reliability gate slice. It does not prove real OS IME automation, Windows ConPTY post-agent keyboard reset, Arabic/RTL, JIS-yen, or the full CJK/Vietnamese/platform matrix.
|
||||
|
||||
The xterm addon containment gate now has its first deterministic renderer-unit slice and product hardening for core addon load failures:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts \
|
||||
src/renderer/src/lib/pane-manager/terminal-link-provider-guard.test.ts \
|
||||
src/renderer/src/components/terminal-search-safe-find.test.ts \
|
||||
src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-webgl-paste-recovery.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 5 test files passed, 39 tests passed, duration 370ms. This focused command asserts a core addon `loadAddon` throw is pane-scoped and later addons still load, link provider throws degrade to no-link results, search decoration positive-integer crashes return `false` instead of escaping, and WebGL attach/refresh/recovery failures stay contained. It does not yet prove typed PTY input/output survives a live addon failure in Electron, nor WebGL dispose/reset throws across active, hidden, and resumed panes.
|
||||
|
||||
The runtime/mobile stream budget gate now has its first deterministic runtime-RPC slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/runtime/rpc/terminal-subscribe-buffer.test.ts \
|
||||
src/main/runtime/rpc/terminal-output-batching.test.ts \
|
||||
src/main/runtime/rpc/terminal-multiplex.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 3 test files passed, 29 tests passed, duration 2.78s. The covered contracts assert mobile initial snapshots downgrade until they fit the 512KB budget, requested binary snapshots downgrade until they fit the 2MB budget, binary live output queued while the initial snapshot is serializing stays within 256KB while preserving the newest tail, large binary output is split into 48KB-or-smaller frames, output bursts are coalesced before emission, aborted subscribes do not register stale listeners, and stale mobile resize re-stream completions are dropped. It does not yet decide JSON subscribe fallback parity/deprecation.
|
||||
|
||||
The replay/scrollback gate now has its first executable slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 1 test file passed as part of the focused pty-connection validation, with 255 tests in that file. The relevant replay assertions cover four replay invariants: remote replay payloads that overlap before parsing starts intentionally coalesce to the latest payload, a replay whose xterm parsing has already started is not overwritten by a newer replay, multiple replay notifications accepted after parsing starts drain FIFO, and burst tails are bounded while keeping the newest snapshot. It does not yet prove fresh-main `clearBeforeReplay` metadata, metadata-only replay, or hidden-output/live-output interleaving.
|
||||
|
||||
The no-hot-`listSessions` gate now has its first executable slice and product hardening. The renderer/preload/main path exposes `pty.hasPty(id)` and uses targeted liveness on visibility resume and first input after resume, instead of calling global `pty:listSessions()` from that hot path. The resize-on-resume slice also asserts it uses targeted `getSize` plus `resize` rather than broad session listing. Light tab switches and visible active-state resume now assert they do not call `pty:listSessions`, `pty.hasPty`, or `pty.getSize`; a representative active-PTY case proves the scheduler hint `setActiveRendererPty` is the only allowed PTY API call there. Resource Manager broad session inventory is now scoped to the open popover instead of polling for its closed badge.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
|
||||
src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
|
||||
src/renderer/src/components/status-bar/resource-manager-session-polling.test.ts \
|
||||
src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 5 test files passed, 321 tests passed, duration 6.34s. The focused no-hot command asserts visibility resume prefers targeted `hasPty` even when `listSessions` is available, first input after visibility resume calls targeted `hasPty` once, resize re-assertion after visibility resume uses `getSize`/`resize` without `listSessions`, light tab switches and visible active-state resume avoid `listSessions`/`hasPty`/`getSize` fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover, panes close only on authoritative `false`, and unknown liveness keeps panes alive. It does not yet count raw focus, workspace switch, render, high-session typing, or real provider fanout in Electron.
|
||||
|
||||
The store/git hot-path gate now has its first boot-hydration counter slice. This is not broad interactive perf coverage; it is the narrow startup-adjacent part that previously had no measurable budget.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/memory/hydrate-local-pty-registry.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 1 test file passed, 8 tests passed, duration 0.342s. The focused command asserts provider-unavailable hydration does not scan repos/worktrees/providers and remains retryable, SSH repos are skipped before worktree enumeration, local daemon sessions are registered with debug counters for repo/worktree/adapter/session/register/skip/duration fields, recoverable adapter-list failures are counted without failing the whole hydration pass, fatal hydration failures record failed phase and error message, router-backed hydration records multi-adapter fanout counts, pre-existing pid rows are not clobbered, and large daemon session lists hydrate without spreading into argument-limit failures. It does not yet count store recomputes, git status requests, provider listings, focus, typing, tab switch, workspace switch, render, or high-session Electron fanout.
|
||||
|
||||
The degraded-daemon provider contract now has its first executable slice and product hardening. Existing-session operations fail closed when ownership is unknown, and restored daemon session ids, including legacy/non-worktree-scoped ids, cannot silently spawn a local fallback PTY under the old id. Fresh degraded-mode PTY creation still intentionally routes through fallback when the caller marks it as new, and process inspection returns benign defaults for unknown ownership instead of leaking fail-closed routing errors into window-close flows.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/daemon/degraded-daemon-pty-provider.test.ts \
|
||||
src/main/daemon/daemon-pty-adapter.test.ts \
|
||||
src/main/daemon/daemon-pty-router.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 3 test files passed, 107 tests passed, duration 2.54s. Focused provider validation asserts discovered daemon sessions route to the daemon, fresh degraded-mode PTYs route to fallback only when marked new, router spawn discovers uncached existing sessions before choosing an adapter, router spawn fails closed instead of falling through to current when legacy ownership cannot be listed or a known session has exited, targeted `hasPty` discovery caches legacy ownership before later operations, real daemon adapter `listProcesses()` discovery seeds targeted `hasPty` liveness, folder and floating terminal workspace ids survive startup reconcile when valid, restored worktree-scoped and legacy/non-scoped ids do not fall back through spawn after ownership is lost or unknown, unknown or exited existing ids fail closed for routing operations, startup reconcile can dry-run orphan detection without killing live daemon sessions, and process inspection returns benign defaults for unknown ownership. It does not yet prove production startup reconcile wiring, prior-worktree aliases, or real daemon process restart behavior.
|
||||
|
||||
The SSH/remote provider contract now has its first executable provider/main/renderer slice. Provider observation failures are treated as unknown instead of destructive evidence: a failed SSH `listProcesses()` call does not clear previously learned ownership, and a rejected SSH `hasPty()` probe returns unknown liveness (`null`) rather than dead. The focused renderer/provider tests also assert that saved SSH sessions reattach through relay `pty.attach`, expired relay attach does not silently fresh-spawn inside the provider, deferred passphrase cancellation does not auto-reconnect, saved leaf/tab session ids are used after connection, transient deferred reattach failure preserves the saved session id without clearing pane/tab bindings or fresh-spawning, deferred SSH no-result cleanup clears the pending serializer without consuming the saved restore id, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn.
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/providers/ssh-pty-provider.test.ts \
|
||||
src/main/ipc/pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
|
||||
src/renderer/src/store/slices/store-session-cascades.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 4 test files passed, 546 tests passed, duration 6.48s. Focused validation asserts store wake-hint metadata for disconnected SSH relay ids, provider attach/expired-attach behavior, main-process ownership and targeted-liveness failure semantics including async provider rejection, mocked-renderer deferred SSH attach/transient-failure/expired-relay fallback, and deferred SSH no-result cleanup that clears the pending serializer without consuming the saved restore id. It does not yet prove WSL, remote-runtime mirror polling, a live SSH relay, pending-vs-attached UI status, or real network reconnect timing.
|
||||
|
||||
API-surface validation after adding `pty.hasPty(id)`:
|
||||
|
||||
```sh
|
||||
pnpm run typecheck:node
|
||||
pnpm run typecheck:web
|
||||
```
|
||||
|
||||
Result on 2026-07-02: both typechecks passed.
|
||||
|
||||
The output-backpressure budget gate now has a deterministic provider/IPC/renderer slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/daemon/daemon-stream-data-batcher.test.ts \
|
||||
src/main/ipc/pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 3 test files passed, 469 tests passed, duration 9.35s. The focused command asserts daemon socket `write(false)`/`drain` ordering, cross-session interactive output priority, pure-backpressure cleanup, bounded daemon queued bytes, main pending renderer caps, active pending-output protection, replay/backlog slices, and ACK-gated in-flight bounds. It does not yet prove live hidden-output floods, renderer parse pressure, event-loop delay, active key latency, or full Electron perf artifacts.
|
||||
|
||||
Combined focused terminal/provider validation:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/daemon/degraded-daemon-pty-provider.test.ts \
|
||||
src/main/ipc/pty.test.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
|
||||
src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.test.ts \
|
||||
src/renderer/src/components/terminal-pane/pty-connection.test.ts \
|
||||
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-01: 8 test files passed, 625 tests passed, duration 6.55s. This was focused terminal/provider validation, not structural manifest validation.
|
||||
|
||||
Reliability manifest checker validation is separate:
|
||||
|
||||
```sh
|
||||
pnpm run check:reliability-gates
|
||||
```
|
||||
|
||||
Result on 2026-07-02: reliability gate manifest check passed for 27 gates. This validates the manifest shape and referenced files; it does not prove the registered commands cover the full terminal runtime.
|
||||
|
||||
The highest-risk architecture boundaries are:
|
||||
|
||||
- Terminal/session ownership is spread across tab PTY ids, leaf PTY ids, last-known relay ids, pending reconnect maps, deferred SSH maps, sleeping records, and provider ownership. The plan needs a derived ownership snapshot oracle for tests/logging so attach, reattach, exit, clear, hibernate, and restore can prove all authorities agree.
|
||||
- Queued or pending provider sessions must count as owned. This branch now restores the pending startup and automatic resume claim bridge and covers same-session/wrong-session hook evidence, but real hook timing and repeat activation through Electron still need coverage. This is the class-level invariant motivated by #6800.
|
||||
- Stale branch risk is real. The reliability branch previously lacked some protections present on fresh `origin/main`, including queued automatic agent resume claims and exact `hasPty` missing-session reconciliation. This branch has restored those two deterministic slices; the full stack still needs final rebase/retarget verification before landing.
|
||||
- Main PTY ownership historically had convenient fallback behavior for unknown ownership. This branch now hardens daemon-router and degraded-provider paths so discovered legacy ownership is cached and unknown existing-session operations fail closed, but SSH/remote/diagnostic listing paths still need live coverage.
|
||||
- Startup daemon reconciliation is present as a mechanism but not wired as production startup behavior. Without it, the daemon can still have a session while the adapter no longer knows it owns that session.
|
||||
- Degraded daemon routing on fresh `origin/main` can fall back to local for unknown existing-looking sessions. This branch now hardens operation plus restored worktree-scoped and legacy/non-scoped spawn paths to fail closed and registers a focused provider contract, but startup reconcile, prior-worktree aliases, and real daemon restart behavior still need coverage.
|
||||
- `pty:listSessions` both observes and mutates provider ownership. This branch now asserts failed SSH observation does not clear previously learned ownership and mocked deferred SSH attach/transient-failure/expired-relay fallback behaves correctly, but WSL, remote-runtime mirror polling, live SSH reconnect timing, and false-live tab behavior remain unproved.
|
||||
- Resource Manager broad session inventory is now limited to popover-open and explicit management refreshes. It still needs a status/diagnostic budget proving open-popover polling is bounded with many providers and does not become a hot-path substitute.
|
||||
- Boot hydration (`hydrateLocalPtyRegistryAtBoot`) is fire-and-forget, and this branch now records startup phase counters for repo/worktree enumeration, skipped remote repos, adapter/session listing, registrations/skips, duration, and failures. It can still create startup-adjacent git subprocess and daemon-listing churn across many local repos, so do not treat startup reliability as covered until those counters are budgeted in CI/runtime evidence.
|
||||
- Deferred SSH restore intentionally marks a saved PTY id before actual attach. This branch now covers the mocked renderer contract for passphrase cancellation, attach after connection, transient attach failure preserving retry state, and expired relay fallback; remaining work is false-live visual state, live SSH relay timing, and duplicate-spawn prevention under real reconnect churn.
|
||||
- Geometry truth is incomplete across SSH/remote paths. Requested size, renderer size, provider-applied size, shell-visible size, and the runtime mirror's parse dimensions must be separated instead of treated as interchangeable. #7192 proved the mirror can silently stay at birth dimensions while every other authority resizes, corrupting every later restore snapshot, and that mirror reflow must stay ordered with queued output writes.
|
||||
- Production diagnostics are still incomplete. This branch now records compact crash breadcrumbs for explicit terminal lifecycle anomalies, but real user reports still need a bounded pane lifecycle trace for ordinary transitions and a diagnostics-bundle artifact proving those breadcrumbs/traces are included.
|
||||
|
||||
The lifecycle breadcrumb gate now has its first executable slice:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-lifecycle-diagnostics.test.ts
|
||||
```
|
||||
|
||||
Result on 2026-07-02: 1 test file passed, 2 tests passed, duration 0.593s. The focused command asserts `warnTerminalLifecycleAnomaly` preserves its console warning, records a compact `terminal_lifecycle_anomaly` renderer crash breadcrumb with terminal identity/provider/PTY/reason fields, and dedupes repeated anomalies by lifecycle identity. It does not yet prove a full pane lifecycle trace buffer, diagnostics-bundle export, or forbidden-transition assertions across live Electron/provider flows.
|
||||
|
||||
Specific current-source evidence checked in this pass:
|
||||
|
||||
- Fresh `origin/main` has queued provider-session protections in `src/renderer/src/lib/resume-sleeping-agent-session.ts`: `resumeProviderSession`, `claimAutomaticAgentResume`, and `automaticAgentResumeClaimsByTabId`.
|
||||
- Fresh `origin/main` has exact liveness reconciliation in `src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.ts` and the `pty:hasPty` IPC handler in `src/main/ipc/pty.ts`.
|
||||
- Fresh `origin/main` still uses a single `pendingReplayData` slot in `src/renderer/src/components/terminal-pane/pty-connection.ts` (re-verified after #7133 merged; #7133 changed the same file's `applyMainBufferSnapshot` alt-screen restore path, not replay draining). This branch changes replay draining to coalesce snapshots before xterm parsing starts, queue accepted snapshots FIFO once parsing is in progress, and coalesce burst tails to the newest snapshot once the in-flight queue is full. The broader scrollback gate still needs metadata-only replay, `clearBeforeReplay`, and hidden/live-output interleaving coverage.
|
||||
- Fresh `origin/main` still has `DaemonPtyAdapter.reconcileOnStartup` with the explicit note that it has no production caller yet. This branch adds a dry-run mode so future startup wiring can audit would-kill sessions before destructive cleanup, but production startup-persistence remains a real gap until wired with prior-worktree aliases or tracked as an accepted gap with a gate.
|
||||
- Fresh `origin/main` still lets `DegradedDaemonPtyProvider.providerFor()` fall back to local when an existing-looking session id is unmapped and not rediscovered by `hasPty`. This branch changes existing-session operation paths and restored worktree-scoped plus legacy/non-scoped spawn paths to fail closed, adds contract tests, and makes real daemon adapter `listProcesses()` discovery seed targeted `hasPty` liveness. Startup reconcile wiring and live daemon-restart validation are still required before the full provider lifecycle is covered.
|
||||
- Fresh `origin/main` let the daemon router spawn an unknown existing `sessionId` on the current daemon when routing cache/discovery missed its real owner. This branch now probes/discovers all adapters first, requires `isNewSession` for intentional replacement after an authoritative exit event, and fails closed if legacy ownership cannot be listed or the known id has exited, so a stale existing id cannot silently mint over another daemon's history on current.
|
||||
- Fresh `origin/main` let canonical daemon-worktree restores treat a non-minted stale/local PTY id as restorable metadata. This branch now rejects non-minted restored ids when the workspace owner is canonical (`repo::path`, `folder:<id>`, or `global-floating-terminal`), clears the stale pane/tab binding, and fresh-spawns instead of calling daemon reattach or attaching that stale id. Shared parsing also recognizes folder and floating minted ids so startup reconcile does not falsely reap valid non-repo terminal sessions.
|
||||
- Fresh `origin/main` still lets `pty:listSessions` rebuild provider ownership while SSH listing failures become empty lists. This branch adds store/main/provider/renderer contracts proving disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, failed SSH observation does not clear previously learned ownership, `hasPty` failure is unknown, saved SSH sessions attach through relay `pty.attach`, deferred passphrase cancellation does not auto-reconnect, transient deferred reattach failure preserves the saved session id, deferred SSH no-result cleanup clears pending serializer state without consuming retry metadata, and expired deferred relay state clears stale pane/tab bindings before one fresh replacement spawn. Live SSH, WSL, remote-runtime mirror polling, false-live visual state, and real reconnect timing remain unproved.
|
||||
- Fresh `origin/main` has strong Windows/unit coverage around ConPTY packaging, shell resolution, size validation, keyboard reset, complex-script classification, and Windows PTY compatibility, but the real Windows Electron/ConPTY path is still not covered as a reliable PR gate.
|
||||
- Fresh `origin/main` still exposes global `pty:listSessions`; this branch now also uses targeted `hasPty` for visibility/input liveness, but gates should keep typing, focus, tab switch, visibility resume, resize, and render paths from reintroducing broad daemon/provider listing.
|
||||
- Fresh `origin/main` has main-to-renderer ACK/backpressure, renderer output-scheduler caps, terminal perf scripts, and a daily/manual terminal perf workflow. This branch adds daemon stream batcher assertions that socket `write(false)` pauses further stream events until `drain`, flush-immediate output from another session drains before unrelated background backlog, global cleanup clears clients that only have backpressured writes, and queued daemon stream bytes stay bounded by preserving priority output plus the newest tail under sustained pressure; it also caps main pending renderer output per PTY and in total, trims background pending output before active pending output under total pressure, and asserts `seq`/`rawLength` metadata stays correct for the surviving tail. Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved.
|
||||
- Fresh `origin/main` has binary runtime/mobile terminal stream caps, but the legacy JSON subscription path has weaker buffering/backpressure guarantees. Either gate both paths or explicitly deprecate/accept the JSON gap.
|
||||
|
||||
Existing E2E/perf tests that should be reviewed for promotion:
|
||||
|
||||
- `tests/e2e/daemon-slow-init-pty-gate.spec.ts`: strong daemon live-session restore signal; already has a tight user-visible invariant.
|
||||
- `tests/e2e/daemon-live-session-preservation.spec.ts` and `tests/e2e/daemon-slow-health-check-preservation.spec.ts`: likely useful daemon liveness/preservation gates after oracle and flake review.
|
||||
- `tests/e2e/terminal-typing-latency.spec.ts`, `tests/e2e/terminal-codex-local-typing-latency.spec.ts`, and `tests/e2e/terminal-history-size-typing-latency.spec.ts`: useful perf signals, but should be tied to stable budgets and artifacts before blocking promotion.
|
||||
- `tests/e2e/terminal-output-scheduler.spec.ts`, `tests/e2e/artificial-opencode-terminal-load.spec.ts`, and hidden-pressure scenarios: useful for output/backpressure, but should become metric-gates rather than visual-only broad E2Es.
|
||||
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`, `tests/e2e/terminal-document-visibility-webgl-recovery.spec.ts`, and `tests/e2e/terminal-tab-switch-visual-restore.spec.ts`: useful rendering recovery coverage; must keep deterministic text/buffer oracles and limit screenshots to diagnostics.
|
||||
- `tests/e2e/terminal-long-table-scroll-restore.spec.ts` and `tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts`: useful scrollback/rendering regression evidence, but they contain timed waits and should be reviewed before blocking promotion.
|
||||
- `tests/e2e/ssh-localhost.spec.ts`, `tests/e2e/ssh-docker-relay-perf.spec.ts`, and SSH Codex replay/reconnect specs: useful SSH coverage, but should be separated into deterministic provider contracts plus a small number of environment-dependent live SSH soaks.
|
||||
- `tests/e2e/terminal-windows-shell-paste-ownership.spec.ts` and Windows env/icon tests: useful Windows path coverage, but not a substitute for a Windows ConPTY live liveness gate.
|
||||
|
||||
Promotion split for existing E2Es:
|
||||
|
||||
- Promote after soak: a smaller derivative of `terminal-typing-latency.spec.ts` as the live local PTY input/output gate.
|
||||
- Promote after soak: daemon preservation specs, especially slow-init and stale launch identity, as daemon warm-reattach gates.
|
||||
- Promote targeted: paste ownership tests where the oracle is visible output plus exactly-one PTY write.
|
||||
- Keep in soak/nightly: artificial OpenCode load, terminal scale perf report, hidden real-PTY pressure, and SSH Docker perf.
|
||||
- Keep as release evidence: raw emoji/golden rendering, long-table rendering, WebGL recovery, and Codex/OpenCode artifact repros.
|
||||
- Rewrite before blocking: column desync specs need resize-applied acknowledgement or an in-PTY `SIGWINCH` marker instead of post-resize sleeps.
|
||||
- Rewrite before blocking: SSH Codex artifact repros and headful dead-terminal repros need deterministic fixtures/events instead of long waits and screenshots as primary proof.
|
||||
|
||||
The first live PTY gate is deliberately small:
|
||||
|
||||
- File: `tests/e2e/terminal-live-pty-liveness.spec.ts`.
|
||||
- Command: `pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-live-pty-liveness.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1`.
|
||||
- Invariant: a newly opened local terminal has one active PTY id listed exactly once, accepts real keyboard input through focused xterm, delivers process output visibly, keeps the same PTY id through repeated workspace hide/restore cycles, accepts input after restore, applies an actual size change, and exits without leaving a stale binding.
|
||||
- Oracle: start a deterministic Node raw-mode probe, wait for `LIVE_PTY_READY_<runId>`, require the active PTY id to appear exactly once in `pty:listSessions`, type through `.xterm-helper-textarea`, require ordered per-key markers in serialized terminal content, switch to another worktree and back twice, require the active pane to keep the original PTY id, type again after each restore, resize and require `pty:getSize` to change plus a matching process-visible size marker, then exit the probe and shell and require the old PTY id to disappear from `pty:listSessions`.
|
||||
- Wait rule: no blind sleeps. Wait only on PTY binding, active worktree changes, ready marker, key markers, resize marker, and list-session absence.
|
||||
- Budget: target p95 under 75s on Linux including e2e build/setup, hard test timeout 90s. Start blocking on Linux only after soak; run macOS/Windows nightly until each platform has its own history.
|
||||
- Failure artifacts: PTY id, provider classification, pid if available, size snapshots, key latencies, final `listSessions`, trace, and screenshot.
|
||||
- Red/green proof: intentionally break xterm focus, PTY write forwarding, resize forwarding, and exit cleanup in separate local runs and confirm the gate fails for each class.
|
||||
- Local run on 2026-07-02: 1 Playwright test passed in 58.6s total, with an 8.6s test body, on this macOS worktree. This is one green local run for the local live PTY path, not enough flake/runtime history for blocking promotion.
|
||||
|
||||
## Gate Coverage Matrix
|
||||
|
||||
This is the intended shape after rebasing the stack. The first two columns are what we have now; the last two are what must be added or promoted before the plan earns broader reliability claims.
|
||||
|
||||
| Reliability class | Existing evidence | Gap | Next gate or action |
|
||||
| --- | --- | --- | --- |
|
||||
| Stale liveness snapshots closing newer PTYs | `terminal-session.snapshot-freshness`; deterministic unit coverage | No full Electron tab-survival/input echo proof | Add `terminal-platform.live-pty-liveness` and keep snapshot gate as lower-layer guard |
|
||||
| Provider-session replay ownership | `agent-session.provider-ownership`; renderer state tests; queued/pending resume claim proof; bounded queued-claim index proof; same-session and wrong-session hook proof | Needs real Electron repeated activation/hook timing and bounded hook/status scan assertions if those grow | Add Electron activation proof |
|
||||
| Exact PTY liveness | Fresh main has targeted `hasPty` path | Current branch must not lose it; provider failures must be unknown, not dead | Rebase/restore exact liveness reconciliation and register it as a gate |
|
||||
| Store/session ownership coherence | Many focused tests touch pieces | No single derived oracle across tab, leaf, relay, reconnect, deferred SSH, and sleeping maps | Add ownership snapshot helper for tests/logging |
|
||||
| Terminal lifecycle diagnostics | `terminal-observability.lifecycle-breadcrumbs` now records compact anomaly breadcrumbs into crash diagnostics | No full pane lifecycle trace buffer or diagnostics-bundle artifact proof | Add bounded trace buffer and bundle artifact test |
|
||||
| Xterm addon/search/WebGL containment | `xterm-addon.boundary-containment`; deterministic core addon load, link-provider, search, and WebGL containment tests; #7054 hidden TUI product hardening | Needs live Electron typed input/output survival after addon failure, and WebGL dispose/reset throw coverage across active/hidden/resumed panes | Add focused component/live input echo follow-up; promote hidden TUI E2E after flake review |
|
||||
| Visible terminal geometry | #7006 renderer/provider-style gate; #7192's merged mirror-resize and reflow-ordering red tests on main | SSH/remote and shell-visible truth not fully covered; mirror-geometry slice unregistered until rebase | Add live PTY size readback and provider-applied-size contract; register #7192's mirror-authority tests |
|
||||
| Persisted session upgrade | #7007 fixture gate | Needs broader old-version corpus and daemon/SSH cases | Build immutable fixture corpus with startup timing |
|
||||
| Live local PTY lifecycle | `terminal-platform.live-pty-liveness` now has a focused Electron Playwright slice covering local spawn, single active PTY listing, xterm input, PTY output, repeated workspace hide/restore, actual resize propagation, and cleanup | Needs flake/runtime history, red-green evidence, tab switch, scrollback after restore, app restart persistence, and non-local provider coverage | Promote first slice after soak; add tab-switch/scrollback/restart follow-ups |
|
||||
| Windows ConPTY lifecycle | Recent fixes on main, some unit/platform logic | No required Windows live gate for ConPTY echo/resize/cursor/CJK/IME | Add `terminal-platform.windows-conpty-liveness` |
|
||||
| Windows keyboard reset | Unit coverage and recent #6999/#6858 fixes | No live proof that Enter/Backspace/Arrow return to normal after agent/TUI exit | Add `terminal-input.windows-conpty-keyboard-reset` |
|
||||
| Windows CJK/repaint | Complex-script and compatibility tests plus recent CJK repaint fixes | No Windows visual/pixel oracle for wide-glyph in-place redraws | Add `terminal-render.windows-cjk-repaint` |
|
||||
| Windows shell parity | Shell resolution unit coverage and packaging fixes | No local-vs-daemon parity gate for PowerShell 5/7, cmd, Git Bash, WSL, startup command delivery | Add `terminal-shell.windows-resolution-parity` |
|
||||
| Input latency/perf | Existing terminal perf scripts and typing-latency spec | Not registered, not tied to budgets/owners/promotion | Add `terminal-performance.input-throughput` |
|
||||
| Hot interaction listing | Recent #7002 fix, targeted `hasPty` path, deterministic visibility/input/resize no-listing assertions, light tab/active-state resume no-provider-fanout assertions, and closed Resource Manager broad-listing avoidance | No count gate proving `pty:listSessions` stays out of raw focus/workspace-switch/render/high-session Electron scenarios; open Resource Manager polling still needs an explicit budget | Extend `terminal-performance.no-hot-list-sessions` |
|
||||
| Daemon stream backpressure | Batching/chunking plus new batcher contract proof for `write(false)`/`drain`, cross-session flush-immediate drain priority, pure-backpressure cleanup, and bounded queued tails | No live hidden-output flood proof or active input latency artifact | Extend `terminal-performance.output-backpressure-budget` |
|
||||
| Renderer/main ACK and backlog | Main ACK gates, renderer scheduler caps, focused unit coverage, and bounded main pending tails exist | No live metric artifact tying hidden restore, active key latency, queued chars, and dropped backlogs to promotion | Extend `terminal-performance.output-backpressure-budget` |
|
||||
| Runtime/mobile terminal stream | `terminal-runtime.mobile-stream-budget`; runtime-RPC tests assert mobile initial snapshot <=512KB, requested binary snapshot <=2MB, pending live output <=256KB while snapshot loads, output frames <=48KB, output coalescing, abort cleanup, and stale resize re-stream suppression | Legacy JSON path parity/deprecation is undecided | Decide/gate/deprecate JSON fallback |
|
||||
| Store/git hot paths around terminal changes | Perf/git-crash skills exist; git status limits/coalescing exist; `terminal-performance.store-and-git-hot-paths` now has a boot-hydration counter slice | No Electron count gate for terminal-adjacent store projection, git status, focus, typing, tab/workspace switch, render, or high-session provider fanout | Extend `terminal-performance.store-and-git-hot-paths` with interactive counters |
|
||||
| IME and synthetic input | Input forwarding code and scattered coverage | No representative matrix or byte/cell oracle | Add `terminal-input.ime-and-synthetic-forwarding` |
|
||||
| Daemon degraded/startup provider contract | Mechanisms exist; daemon slow-init E2E helps | Startup reconcile is unwired; degraded fallback needs fail-closed/probe proof | Add daemon degraded and startup reconcile contract gates |
|
||||
| SSH/remote restore and mirror polling | SSH localhost and remote fixes exist; this branch has store/provider/main/renderer proof for deferred SSH wake metadata, SSH attach, listing failure, deferred attach, passphrase cancellation, transient deferred reattach failure, and expired deferred relay fallback | Remote mirror polling, live SSH relay behavior, false-live visual state, pending-vs-attached UI status, and real reconnect timing remain unproved | Add live SSH soak and remote mirror polling gates |
|
||||
| WSL restore and launch identity | `terminal-provider.wsl-restore-contract` is now registered as a first-class gap | No WSL provider contract or live Windows WSL smoke yet proves cwd/path identity, startup command delivery, targeted liveness, or restore ownership | Add deterministic WSL provider contract, then one focused Windows WSL smoke |
|
||||
| Scrollback/replay restore | Recent #7012 fix, long-table restore coverage, this branch's remote replay ordering proof, #7133's merged alt-only-clear tests, and #7173's merged ordered-seq interleaving and session-revival tests on main | Normal-buffer clear semantics and metadata-only replay still need deterministic lower-layer proof; the merged #7133/#7173 tests are unregistered until rebase | Add `terminal-output.scrollback-restore` as a dirty-state exactness contract, seeded with #7133's and #7173's tests |
|
||||
| Renderer/mirror parser parity | #7148 (merged 2026-07-02) fixed the first known width divergence; its red-green width test exists on main | Parser configs can still drift silently on any axis besides widths; no parity gate command is registered | Add `terminal-mirror.parser-parity` built on the shared `terminal-unicode-provider.ts`/`pane-terminal-options.ts` seams |
|
||||
| Post-restore convergence detection in production | Anomaly-breadcrumb machinery exists in this branch | Nothing measures renderer-vs-mirror divergence after real restores; #7133-class corruption stays silent until a user notices | Add `terminal-observability.restore-convergence-selfcheck` probe plus telemetry |
|
||||
| Stale pixels after reveal (render-level) | #7133's reveal hardening and the live repro harness's refresh-repair oracle | Buffer oracles are blind to buffer-clean, pixels-stale variants by definition | Add `terminal-render.pixel-refresh-repair` to the release-blocking terminal-rendering-golden suite |
|
||||
|
||||
## Convergence Oracles And Escape Detection
|
||||
|
||||
The #7133 saga is the design input for this section. One user-visible symptom — stale terminal frames on worktree return — decomposed into five distinct mechanisms: the skipped alt-screen clear (#7133 primary fix), the WebGL render-model fossil (#7133 hardening), the daemon mirror unicode width divergence (#7148), the stale hidden-chunk ordering hole with seq-restart data loss (#7173), and desktop resizes never reaching the runtime mirror (#7192). None of the three was predictable in advance, and seventeen faithful fresh-window repro attempts missed the class because the bugs only exist in dirty, long-lived pane states. Mechanism-enumeration testing cannot catch this family. Layer-convergence assertion can.
|
||||
|
||||
Orca holds four copies of what a terminal shows: the PTY application's intended screen, the main-process headless mirror buffer, the renderer xterm buffer, and the WebGL-rendered pixels. Every bug in this family is two adjacent copies silently disagreeing. The strategy is two layers that map to the program's two goals — catching issues nobody predicted, and catching regressions to issues already fixed.
|
||||
|
||||
Catch novel escapes:
|
||||
|
||||
1. `terminal-observability.restore-convergence-selfcheck`: after every hidden-to-visible reveal settles — whether or not a restore was triggered — compare a bounded row-hash sample of the renderer buffer against the current main-mirror state; on mismatch, record a compact content-free `terminal_lifecycle_anomaly` breadcrumb (machinery already in this branch) and count it in telemetry. Per-reveal scope is load-bearing: #7173's frozen-output variant restores faithfully and then silently drops later hidden output, so a restore-scoped probe would never fire. This turns silent corruption into a measurable signal — #7133 and #7173 would have appeared in anomaly counts weeks before the user reports — and after each fix the anomaly rate is the in-production regression detector. Budget: one bounded comparison per reveal, no polling, no content capture beyond hashed row samples.
|
||||
2. `terminal-render.pixel-refresh-repair`: the render-level variant is invisible to every buffer oracle by definition (buffer clean, pixels stale). The content-independent oracle: screenshot the revealed pane, force a full model-invalidating repaint, screenshot again; a material diff means something rendered stale, whatever the mechanism. Productize the existing live repro harness with this oracle plus a JS-level render-model-vs-buffer comparison, and land it in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` already runs against every release tag. Use nightly runs only as the interim phase to stabilize the per-platform diff threshold; golden-set membership is what makes "the next release does not ship this class" directly checkable.
|
||||
3. Session-corpus replay: record real agent-session byte streams (a Claude run is exactly the #7133 shape — banner, prompt echo, final frame with blank regions) as fixtures and replay them through the hide/skip/restore machinery under the convergence oracles. Real corpora contain escape-sequence conjunctions nobody writes by hand. This is fixture strategy for the gates above, not a separate gate.
|
||||
|
||||
Catch regressions to known fixes:
|
||||
|
||||
4. `terminal-mirror.parser-parity`: feed identical byte corpora to the renderer xterm configuration and the main headless mirror and assert cell-identical buffers, with both parsers constructed from one shared configuration module. #7148's divergence (Unicode 11 + ZWJ in the renderer, default width tables in the mirror) existed since the mirror was born and was catchable by a millisecond-scale unit test; this gate also blocks the next one-sided config drift.
|
||||
5. The dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s command: apply snapshots onto adversarially dirty panes and assert the resulting buffer exactly equals the snapshot frame. Exactness is load-bearing — presence-of-marker oracles pass on merged frames. Seeded with #7133's merged alt-only-clear tests and #7148's positioned-overwrite width oracle.
|
||||
6. Manifest registration of every escape: each fixed escape's red-green test becomes gate evidence, and per the Operating Rules the fix PR must also strengthen the relevant convergence oracle, so each escape permanently shrinks the space for the next one.
|
||||
|
||||
Seam placement is part of the design: convergence must be asserted at every adjacent pair of copies, not at one seam. #7192 proved this the hard way — a renderer-vs-mirror check is blind to corruption inside the mirror itself, because the restore is perfectly faithful to a corrupted source. That is why the geometry gate owns the runtime mirror's dimensions and reflow ordering as a first-class size authority, upstream of the seam this section's self-check watches.
|
||||
|
||||
The honest limit stands: this shrinks the escape space rather than zeroing it. The first four mechanisms in the saga are caught by items 1, 2, 4, and 5 without predicting any of them; the fifth (#7192) escaped every renderer-side oracle as originally specced and was closed by adding the mirror to the geometry authority list — evidence that the escape-driven loop (item 6) is not optional. The three new gates and the strengthened `terminal-output.scrollback-restore` entry are registered in the manifest as `protection: "none"` gaps, so the checker, factory reviews, and this plan cannot claim them as coverage before their commands exist.
|
||||
|
||||
## Immediate Fix Candidates
|
||||
|
||||
These are the concrete pieces to prioritize before declaring the plan implemented:
|
||||
|
||||
Three mechanical prerequisites gate everything below:
|
||||
|
||||
- Materialize the split: commit each working-tree slice to its owning branch per the file assignment in the PR Split Plan, reset the diverged child branches (#7004, #7005, #7006, #7008) from the worktree versions of their files, and push. Until then, every evidence run in this plan and in the manifest refers to unreproducible state.
|
||||
- During the rebase, run the testFiles-vs-fresh-main audit and fix the confirmed #6949 WebGL rename by adopting `terminal-webgl-atlas-recovery.test.ts` and evaluating `pane-webgl-context-recovery.test.ts` and `pane-webgl-renderer.test.ts` for the containment gate.
|
||||
- Open the aggregated non-blocking `reliability-gates` CI job so promotion evidence starts accruing with machine provenance.
|
||||
|
||||
1. Rebase or retarget the existing reliability PR stack onto fresh `origin/main`, then verify the stack still contains queued automatic resume claims and exact `hasPty` missing-session reconciliation. This branch has restored both deterministic slices, but final stack verification is still required.
|
||||
2. Extend the provider-session ownership test from the current queued/pending, bounded-index, same-session hook, and wrong-session hook proof to a real Electron repeated-activation test, plus bounded-work assertions for any new hook/status scans.
|
||||
3. Add or restore exact liveness tests: `hasPty(id) === false` tears down only the matching local PTY, while `true`, `null`, rejection, SSH/remote unknown states, and stale pre-bind responses do not close panes.
|
||||
4. Add a small derived terminal ownership snapshot helper for tests and diagnostics. It should report whether each live PTY is represented once by the intended tab/leaf and absent from stale maps after attach, reattach, exit, clear, hibernate, and restore.
|
||||
5. Keep the replay FIFO/burst fix and tests in the stack, rebased over #7133's alt-clear restore preamble in the same file. This branch now asserts that multiple replay notifications arriving during an async drain cannot overwrite one another, cannot reorder accepted replay snapshots, and cannot grow an unbounded replay queue under bursty snapshots; remaining replay work is normal-buffer clear semantics (alt-screen clear landed in #7133), metadata-only replay, and hidden/live-output interleaving.
|
||||
6. Wire `DaemonPtyAdapter.reconcileOnStartup`, or explicitly create a provider-contract gate and accepted gap if wiring is not safe yet.
|
||||
7. Keep the degraded daemon fail-closed contract in place and extend it to startup/restart coverage. This branch now prevents unknown existing ids from silently routing operation calls or restored worktree-scoped and legacy/non-scoped spawn calls to fallback; the remaining work is production startup reconcile, prior-worktree aliases, and live daemon-restart validation.
|
||||
8. Keep the daemon-router targeted-discovery fix in place. This branch now caches ownership discovered by `hasPty()` before later write/resize-style operations and fails closed for operations on unknown existing session ids, preventing legacy-daemon sessions from silently routing to the current daemon.
|
||||
9. Extend provider listing failure contract tests. This branch now asserts disconnected SSH relay ids are retained as deferred reconnect metadata and sidebar wake hints rather than attached PTY proof, a rejected SSH listing does not clear previously learned ownership, thrown SSH `hasPty` is unknown, provider expired attach does not silently fresh-spawn, deferred SSH passphrase cancellation does not reconnect, deferred attach uses saved session ids, transient deferred reattach failure preserves retry metadata instead of fresh-spawning, and expired deferred relay fallback clears stale pane/tab bindings before one fresh spawn. Remaining work is empty-but-ambiguous provider snapshots, WSL, remote-runtime mirror polling, live SSH relay timing, false-live visual state, pending-vs-attached UI status, and pane-close behavior.
|
||||
10. Extend the new lifecycle anomaly breadcrumbs into a bounded structured lifecycle trace outside E2E-only mode for reattach, expired sessions, provider-list failure, unknown ownership, fallback routing, replay, and terminal input recovery.
|
||||
11. Extend the hot-interaction listing count gate. The targeted `hasPty`, resize-on-resume, light tab/active-state resume, and closed Resource Manager polling slices are implemented; next it should prove `pty:listSessions()` calls are zero during raw focus, workspace switch, render, high-session Electron scenarios, and separately budget open status/diagnostic polling.
|
||||
12. Add a cheaper Resource Manager session-status path or an explicit budget for open-popover `pty:listSessions()` polling. The closed-popover poll is removed, but the visible Resource Manager inventory still fans out to providers and mutates ownership, so it should have a measured ceiling.
|
||||
13. Budget the new startup hydration counters for `hydrateLocalPtyRegistryAtBoot` in CI/runtime evidence, including repo/worktree enumeration count, git subprocess count proxy, daemon session-list duration, and confirmation that no startup-critical path awaits the scan.
|
||||
14. Extend the output backpressure proof. The daemon stream batcher now pauses after `write(false)`, prioritizes flush-immediate output from another session ahead of unrelated background backlog on `drain`, clears pure-backpressure queues on global cleanup, bounds queued stream bytes, and resumes on `drain`; main pending renderer output is capped per PTY and in total. Next, hidden-output floods and active key latency need metric artifacts under the perf budget.
|
||||
15. Add Windows platform gates rather than relying on Linux/macOS live PTY evidence: ConPTY liveness, keyboard reset, CJK repaint, shell resolution parity, and IME/native-text forwarding.
|
||||
16. Decide whether the legacy JSON runtime terminal subscribe path is supported. If supported, gate its snapshot/live-output byte caps; if not, mark it as an accepted gap with a deprecation path.
|
||||
17. Implement the dirty-state restore exactness contract as `terminal-output.scrollback-restore`'s first command, seeded with #7133's merged alt-only-clear tests and #7173's ordered-seq interleaving and session-revival tests: apply snapshots onto already-alt-screen panes with stale content in cells the new frame leaves blank, include revived sessions with restarted PTY seq counters in the dirty-state matrix, and assert exact buffer equality.
|
||||
18. Implement the parser-parity seed during the rebase, now that #7148 has merged: register `headless-emulator-unicode-width.test.ts` plus a shared parser-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts` as `terminal-mirror.parser-parity`'s first command. The test file exists on fresh main but not at this branch's stale merge-base, so the command cannot be registered before the rebase.
|
||||
19. Implement the per-reveal convergence self-check behind the existing anomaly-breadcrumb machinery — comparing the renderer buffer against current mirror state on every reveal, not only after restores, with a strict per-reveal budget and no content capture — and productize the stale-render repro harness into `terminal-render.pixel-refresh-repair` targeting the release-blocking terminal-rendering-golden suite.
|
||||
20. Register #7192's merged mirror-geometry red tests as the mirror-authority slice of `terminal-geometry.visible-convergence` during the rebase, covering both accepted-resize fan-out to the runtime mirror and reflow-vs-queued-output ordering.
|
||||
|
||||
Next gates to add before claiming broad terminal reliability coverage:
|
||||
|
||||
1. `terminal-platform.live-pty-liveness`: first focused Electron/live-PTY slice is implemented for local Linux/macOS-style terminals. One local macOS run asserts bind, single active PTY listing, focus, keyboard input, PTY echo, repeated workspace hide/restore of the same PTY, post-restore input, actual size propagation, and exit cleanup. Follow up with scrollback-after-restore, tab switch, app restart, and non-local providers before broad live-terminal claims; keep it `experimental` until it has flake/runtime and red-green evidence.
|
||||
2. `terminal-platform.windows-conpty-liveness`: Windows-specific gate for ConPTY spawn/activation, PowerShell resolution, cursor/rewrite repaint, CJK/IME bytes, and no stuck `stale_bootstrap`. Use lower-level provider contracts where possible and one focused Windows Electron smoke for the real ConPTY path.
|
||||
3. `terminal-input.windows-conpty-keyboard-reset`: Windows local ConPTY gate proving Enter, Backspace, Arrow, paste, and ordinary shell submission behave normally after an agent or TUI exits. Oracle should include PTY input logs proving stale Kitty/CSI-u mode is not still applied to standard keys.
|
||||
4. `terminal-render.windows-cjk-repaint`: Windows local ConPTY wide-glyph redraw gate. Oracle should combine xterm buffer/text evidence with screenshot/canvas/pixel evidence and a bounded refresh count.
|
||||
5. `terminal-shell.windows-resolution-parity`: local and daemon providers must resolve equivalent shell path, args, cwd, env, startup command delivery, and fallback behavior for PowerShell 5/7, cmd, Git Bash, WSL, and missing `pwsh`.
|
||||
6. `terminal-performance.input-throughput`: non-blocking perf gate for typing latency, hidden-output pressure, renderer CPU, terminal output throughput, resize churn, and store subscriber work. This should be metric-based, not screenshot-based.
|
||||
7. `terminal-performance.no-hot-list-sessions`: deterministic count gate proving `pty:listSessions()` is not called from typing, focus, tab/workspace switch, visibility resume, resize, render, or per-pane liveness paths.
|
||||
8. `terminal-performance.daemon-stream-backpressure`: provider/daemon contract gate proving daemon socket writes respect backpressure under output floods and do not starve active input.
|
||||
9. `terminal-performance.output-backpressure-budget`: main-to-renderer ACK and renderer scheduler budget gate. Suggested ceilings: peak renderer in-flight bytes <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped renderer backlogs 0 in normal perf scenarios, and hidden restore <=1000ms.
|
||||
10. `terminal-runtime.mobile-stream-budget`: runtime/mobile stream gate proving binary terminal snapshots stay <=512KB mobile or <=2MB requested, live pending output stays <=256KB while snapshot loads, chunks stay <=48KB, and batches stay <=64KB.
|
||||
11. `terminal-input.ime-and-synthetic-forwarding`: representative IME/input matrix with deterministic byte/cell oracles at the lowest reliable layer, plus platform-specific manual/soak coverage where real IMEs are required.
|
||||
12. `terminal-provider.remote-daemon-contract`: provider contract for daemon degraded spawn, SSH/remote unknown liveness, mirror polling, reconnect, and path/cwd authority.
|
||||
13. `terminal-provider.wsl-restore-contract`: Windows WSL provider contract proving host/guest cwd identity, shell launch args, startup command delivery, targeted liveness semantics, and restore ownership, followed by one focused live WSL smoke.
|
||||
14. `terminal-output.scrollback-restore`: deterministic scrollback/snapshot/replay gate for hidden output, restore, clearing, and no cross-tab or stale-output overlap. Seed it with #7133's merged assertions — the alt-only clear preamble written before alt-screen snapshot data (and no `3J`) in `pty-connection.test.ts`, plus the reveal-repaint ordering tests — then extend to normal-buffer clear semantics, metadata-only replay, and hidden/live-output interleaving. #7133's buffer-merge escape (pre-hide frame bleeding through blank cells) is the motivating regression for this gate.
|
||||
15. `terminal-capability.startup-color-query`: startup OSC 10/11 replies are answered out of band at startup only, do not leak into shell/renderer streams, and ordinary OSC color queries remain renderer-handled.
|
||||
16. `terminal-mirror.parser-parity`: renderer xterm and the main headless mirror parse identical byte corpora into cell-identical buffers, with both parsers constructed from one shared configuration module. Seed with #7148's merged width test (`headless-emulator-unicode-width.test.ts`) plus a shared-construction assertion over `terminal-unicode-provider.ts` and `pane-terminal-options.ts`; extend with recorded agent-session corpora.
|
||||
17. `terminal-observability.restore-convergence-selfcheck`: production per-reveal probe comparing a bounded row-hash sample of the renderer buffer against current main-mirror state — on every reveal, not only after restores, because #7173's frozen-output variant never triggers another restore — recording deduped content-free anomaly breadcrumbs and telemetry counts. This is the in-production escape detector for the whole restore family; corruption upstream of the mirror itself is owned by the geometry gate.
|
||||
18. `terminal-render.pixel-refresh-repair`: content-independent refresh-repair oracle (screenshot, force a model-invalidating repaint, screenshot, bounded diff) plus a render-model-vs-buffer comparison, productized from the existing stale-render repro harness into the release-blocking `terminal-rendering-golden` suite; nightly runs only until the diff threshold is stable.
|
||||
|
||||
Specific near-term hardening before promotion:
|
||||
|
||||
- #7008 now includes a bounded queued-claim assertion plus same-session and wrong-session hook cases for provider-session ownership checks. It should still be followed by a real Electron repeated-activation proof and any additional bounded-work assertions if hook/status ownership scans grow.
|
||||
- Each gate PR should keep its residual gaps in the PR description and manifest until a real follow-up gate removes them. Do not let a green lower-layer gate imply live Electron, SSH, WSL, Windows ConPTY, or IME coverage.
|
||||
|
||||
Promotion rule for these additions is unchanged: do not make any of them blocking until they have red/green proof, stable runtime history, zero unexplained flakes in the promotion window, a clear owner, and a demotion rule. A non-blocking gate is useful evidence and documentation; it becomes protection only after promotion criteria are met.
|
||||
|
||||
## Goal
|
||||
|
||||
Prevent the recent terminal, tab, session, startup, provider, and performance regressions from escaping again by turning each high-risk reliability class into a small executable gate with explicit maturity, owner, runtime budget, flake history, and promotion evidence.
|
||||
|
||||
This plan is broad in scope but staged in rollout. The first milestone exercises the operating model end to end; later milestones add live Electron, Windows ConPTY, SSH/remote, daemon, IME, scrollback, and performance gates without turning the suite into noisy sprawl.
|
||||
|
||||
## Operating Rules
|
||||
|
||||
- Add gates only when they protect a named invariant tied to a real issue, PR, or accepted gap.
|
||||
- Prefer deterministic unit or provider-contract tests over Electron UI tests when the lower layer proves the user-visible invariant.
|
||||
- Never promote a gate to blocking until it has red/green evidence, stable runtime evidence, and a named demotion rule.
|
||||
- Keep stress/torture runs non-blocking unless they have deterministic oracles and flake history.
|
||||
- Include a performance or crash-safety budget whenever the protected change touches terminal throughput, hidden panes, persistence, startup, polling, subprocesses, git/worktree scans, SSH, WSL, or Windows paths.
|
||||
- Restore, replay, reattach, and snapshot gates must start from adversarially dirty initial states — already-alt-screen panes, stale content occupying cells the new frame leaves blank, scrollback present, wide/emoji glyphs — never only fresh terminals. Fresh-state-only testing is why the #7133 class survived seventeen faithful repro attempts.
|
||||
- Restore and replay oracles must assert frame exactness or stale-content absence. Marker-presence oracles pass on merged frames and are not acceptable correctness evidence for restore paths.
|
||||
- Every escaped terminal bug's fix PR must ship both the narrow mechanism regression test and a strengthening of the relevant convergence oracle (parser parity, restore exactness, convergence self-check, or pixel refresh-repair), so each escape permanently shrinks the space for the next one.
|
||||
|
||||
## Milestone 1
|
||||
|
||||
Milestone 1 creates the reliability-gate loop with the two highest-leverage escaped classes:
|
||||
|
||||
1. `terminal-session.snapshot-freshness`
|
||||
A stale local/daemon liveness snapshot cannot close a newer PTY binding.
|
||||
|
||||
2. `agent-session.provider-ownership`
|
||||
Workspace activation, restore, sleep, hibernate, dedupe, clearing, or reconnect code cannot replay or resume a provider session already owned, queued, pending, or live in the workspace.
|
||||
|
||||
Both gates already have targeted tests in the repo. Milestone 1 makes them reviewable factory artifacts by registering their invariant, command, owner, maturity, known gaps, and promotion criteria in `config/reliability-gates.jsonc`.
|
||||
|
||||
## First Commands
|
||||
|
||||
Run the structural manifest check:
|
||||
|
||||
```sh
|
||||
pnpm run check:reliability-gates
|
||||
```
|
||||
|
||||
Run the first two gate tests:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/renderer/src/components/terminal-pane/terminal-dead-session-reconcile.test.ts \
|
||||
src/renderer/src/lib/resume-sleeping-agent-session.test.ts
|
||||
```
|
||||
|
||||
Run the existing terminal perf report gate before promoting terminal lifecycle gates to blocking:
|
||||
|
||||
```sh
|
||||
pnpm run test:e2e:terminal-perf:scale:report
|
||||
```
|
||||
|
||||
## Promotion Criteria
|
||||
|
||||
A gate can move from `experimental` to `soak` when:
|
||||
|
||||
- it is registered in the manifest;
|
||||
- it has a deterministic oracle with no blind sleeps;
|
||||
- it has a cheap command that can run repeatedly;
|
||||
- it names the failure artifact reviewers should inspect;
|
||||
- it has a clear owner and demotion rule.
|
||||
|
||||
A gate can move from `soak` to `blocking` when:
|
||||
|
||||
- it has red/green evidence against the old behavior, a regression fixture, or an intentionally broken variant;
|
||||
- it has at least 100 consecutive passing soak runs or 14 days of required-platform CI history, whichever is more appropriate for the gate;
|
||||
- that history comes from the aggregated `reliability-gates` CI job with machine-recorded results (see Evidence Provenance And CI Wiring), not from hand-entered local runs;
|
||||
- p95 runtime is within the manifest budget;
|
||||
- there are zero unexplained flakes in the promotion window;
|
||||
- any required perf/git-crash budget has measured evidence.
|
||||
|
||||
For visual and live release-assurance gates, `blocking` means membership in the release-blocking `terminal-rendering-golden` suite that `release-cut.yml` runs against every release tag — the existing golden rail, not a new checkpoint mechanism, and never PR-blocking. Deterministic unit and contract gates stay on the PR path, where catching a regression is strictly cheaper than catching it at release time.
|
||||
|
||||
The manifest also allows `accepted-gap` for explicitly deferred reliability work with a named owner and `deprecated` for gates superseded by a stronger invariant or removed product surface. Those levels should stay visible in the manifest so factory review can distinguish intentional gaps from missing coverage.
|
||||
|
||||
## Factory Contract
|
||||
|
||||
`brennan-yolo-lite`, `review-code`, `perf`, and `git-crash-perf` should treat the manifest as the source of truth.
|
||||
|
||||
For any PR touching a P0-capable surface, the agent or reviewer should:
|
||||
|
||||
- identify the touched reliability class;
|
||||
- name the matching manifest gate or accepted gap;
|
||||
- run the gate command or explain why it does not apply;
|
||||
- require a new manifest entry when the PR introduces a new reliability class;
|
||||
- invoke `perf` or `git-crash-perf` when the touched surface matches their risk scope.
|
||||
|
||||
## Evidence Provenance And CI Wiring
|
||||
|
||||
Hand-entered evidence is the weakest link in this plan: the checker validates metadata shape, but nothing yet proves a registered command actually ran against the code it claims to cover. Reliability gates become protection only when they run automatically, against pushed commits, with recorded history. Requirements:
|
||||
|
||||
1. Every `evidenceRuns` entry must record the pushed commit SHA it ran against. A run against uncommitted working-tree state is a development note, not promotion evidence; it must be re-run and re-recorded once the owning PR's branch is pushed. All evidence runs currently in the manifest predate the split and must be refreshed this way.
|
||||
2. Add one aggregated non-blocking `reliability-gates` CI job that runs `check:reliability-gates` plus every command-backed gate command, and uploads a machine-readable per-gate result artifact (gate id, command, result, duration, commit SHA, runner platform). Run it nightly and on PRs that touch terminal/session/provider/startup paths. Because every gate command except the live Playwright slice is a focused vitest run measured in seconds (a combined run of all 28 unit-layer gate test files completed in 10.2s with 928 tests on 2026-07-02), the job's cost is dominated by setup, not by gate count.
|
||||
3. Promotion to `active`/blocking requires history from that CI job — the 100-consecutive-run or 14-day criteria in Promotion Criteria — not local runs. Flakes recorded by the job must be triaged before the affected gate can be promoted, and an unexplained flake on an `active` gate is the demotion signal.
|
||||
4. Checker follow-up: once the CI artifact exists, extend the checker so `active` gates require at least one CI-provenance evidence run, and so evidence-run commit SHAs are verified to be ancestors of the branch under check.
|
||||
5. Rebase audit: before any merge claim, mechanically verify every declared `testFiles` entry exists on fresh `origin/main` (or on the rebased tree) rather than trusting memory. The #6949 WebGL rename is the standing example of why.
|
||||
|
||||
## Milestone 2
|
||||
|
||||
After Milestone 1 works, add the next three gates as experimental entries with deterministic harnesses:
|
||||
|
||||
- `terminal-geometry.visible-convergence`
|
||||
- `xterm-addon.boundary-containment`
|
||||
- `startup-upgrade.persisted-session-corpus`
|
||||
|
||||
These should not become blocking until the team has red/green, runtime, and flake evidence. The goal is fewer useful gates, not a larger noisy suite.
|
||||
|
||||
## Milestone 3
|
||||
|
||||
Milestone 3 is where the plan stops being only a lower-layer regression net and starts exercising the real terminal runtime:
|
||||
|
||||
- Register existing terminal perf and E2E tests as experimental manifest gates only after reviewing their oracles, waits, artifacts, and flake history.
|
||||
- Add the live local PTY gate with a tight oracle: spawn a shell, print a unique token, switch hidden-visible, assert the same PTY/session id, verify visible buffer content, type again, and prove no duplicate tab or blank pane appeared.
|
||||
- Add Windows ConPTY coverage on Windows CI for echo, resize/readback, cursor visibility, CJK/emoji output, keyboard reset, shell resolution parity, and activation after restore.
|
||||
- Add daemon degraded/startup reconcile contracts before relying on daemon fallback behavior.
|
||||
- Add live SSH, WSL, remote-runtime mirror polling, and provider-listing edge contracts before treating remote/SSH restore as broadly covered; the current mocked SSH deferred-reattach and expired-relay tests are useful lower-layer proof, not live remote coverage.
|
||||
- Add perf gates with explicit budgets for input latency, event-loop delay, hidden-output pressure, daemon stream backpressure, no hot `listSessions`, pending bytes, renderer ACK behavior, store hot paths, and runtime/mobile stream byte caps.
|
||||
- Add IME/synthetic-input coverage at the lowest deterministic layer, with platform-specific manual or soak coverage only where real IMEs are required.
|
||||
|
||||
Milestone 3 gates should begin as `experimental`. They become `soak` only when the command is stable enough to run repeatedly and the failure artifact tells an engineer exactly what broke. They become `blocking` only after the promotion criteria above are satisfied.
|
||||
@@ -0,0 +1,712 @@
|
||||
# Reliability Pain Points and Improvement Plan
|
||||
|
||||
Date: 2026-06-30
|
||||
|
||||
## Scope
|
||||
|
||||
This note summarizes the recent reliability review across:
|
||||
|
||||
- Recently merged reliability PRs, especially terminal/tab/startup/session work from 2026-06-26 through 2026-06-30.
|
||||
- GitHub issues labeled `P0` and `P1`.
|
||||
- Local git history for authorship when issue text or PR bodies named a regressing change.
|
||||
|
||||
The goal is to identify where Orca is hurting, what engineering improvements would reduce repeat incidents, and what can fairly be said about attribution.
|
||||
|
||||
## Issue Snapshot
|
||||
|
||||
At the time of review:
|
||||
|
||||
- Open `P0`: 9
|
||||
- Total `P0`: 14
|
||||
- Open `P1`: 191
|
||||
- Open `P1` split: 90 bugs, 86 enhancements, 15 other
|
||||
|
||||
Last seven days reviewed: 2026-06-23 through 2026-06-30.
|
||||
|
||||
- Merged PRs in the window: 386
|
||||
- `P0` issues created in the window: 8
|
||||
- `P1` issues created in the window: 83
|
||||
|
||||
The last-seven-day issue set is the strongest signal for the current pain. It includes the new-tab autoclose `P0`, Windows terminal activation hangs, renderer CPU/input lag, Windows startup visibility failure, Codex config corruption on Windows, a large terminal/IME/rendering cluster, WSL/remote/provider drift, and agent/sidebar/session identity drift.
|
||||
|
||||
Open `P0` issues:
|
||||
|
||||
- [#6877](https://github.com/stablyai/orca/issues/6877): macOS 1.4.106 nested `simcam` dylib is unnotarized and rejected by Gatekeeper.
|
||||
- [#6874](https://github.com/stablyai/orca/issues/6874): Windows `AppHangB1` when opening or activating a terminal session; runtime stuck in `stale_bootstrap`.
|
||||
- [#6873](https://github.com/stablyai/orca/issues/6873): likely duplicate or near-duplicate of #6874, though the issue metadata says macOS while the title/body describe Windows.
|
||||
- [#6795](https://github.com/stablyai/orca/issues/6795): app became very slow after an update; typing can lag by 2-5 seconds.
|
||||
- [#6773](https://github.com/stablyai/orca/issues/6773): cannot create a new terminal tab or agent; the tab opens and closes immediately.
|
||||
- [#6655](https://github.com/stablyai/orca/issues/6655): high `Orca Helper (Renderer)` CPU on Mac Mini M4 causing severe UI/input lag.
|
||||
- [#5787](https://github.com/stablyai/orca/issues/5787): whole Windows window freezes; input dies in every pane; sessions close on click.
|
||||
- [#5356](https://github.com/stablyai/orca/issues/5356): session loss on first launch after upgrading from 1.4.65 to 1.4.68.
|
||||
- [#5314](https://github.com/stablyai/orca/issues/5314): Windows close button and Alt+F4 do nothing.
|
||||
|
||||
Closed `P0` issues worth keeping in the reliability history:
|
||||
|
||||
- [#6233](https://github.com/stablyai/orca/issues/6233): Windows window fails to appear while the process runs in the background.
|
||||
- [#6163](https://github.com/stablyai/orca/issues/6163): Orca-managed Codex `config.toml` duplicate key.
|
||||
- [#5377](https://github.com/stablyai/orca/issues/5377): native watcher main-process crash on macOS.
|
||||
- [#5144](https://github.com/stablyai/orca/issues/5144): Windows close-confirmation deadlock.
|
||||
- [#5109](https://github.com/stablyai/orca/issues/5109): sidebar agent status mismatch.
|
||||
|
||||
## P1 Reliability Clusters
|
||||
|
||||
The `P1` set is too large to treat as a flat list. The high-signal reliability clusters are:
|
||||
|
||||
### Terminal Rendering and Input
|
||||
|
||||
- [#6901](https://github.com/stablyai/orca/issues/6901): garbled text.
|
||||
- [#6764](https://github.com/stablyai/orca/issues/6764): Windows terminal issues.
|
||||
- [#6632](https://github.com/stablyai/orca/issues/6632): display artifacts in Claude Code full-screen TUI.
|
||||
- [#6144](https://github.com/stablyai/orca/issues/6144): laggy Claude Code terminal scroll.
|
||||
- [#5969](https://github.com/stablyai/orca/issues/5969): remote-over-SSH Codex display artifacts.
|
||||
- [#5345](https://github.com/stablyai/orca/issues/5345): Windows/WSL scrolling causes duplicated or garbled text and content overlap.
|
||||
|
||||
### IME and International Text
|
||||
|
||||
- [#6905](https://github.com/stablyai/orca/issues/6905): Vietnamese IME input broken in terminal.
|
||||
- [#6765](https://github.com/stablyai/orca/issues/6765): Linux Sogou Pinyin/fcitx only commits the first CJK character.
|
||||
- [#6698](https://github.com/stablyai/orca/issues/6698): Vietnamese Telex input loses characters.
|
||||
- [#5921](https://github.com/stablyai/orca/issues/5921): CJK characters duplicated in Orca terminal on Windows.
|
||||
- [#5262](https://github.com/stablyai/orca/issues/5262): Arabic text rendering appears reversed or disconnected in terminal input.
|
||||
|
||||
### Paste and Clipboard
|
||||
|
||||
- [#5365](https://github.com/stablyai/orca/issues/5365): terminal freezes for seconds when pasting long text on Windows.
|
||||
- [#5358](https://github.com/stablyai/orca/issues/5358): Windows paste preview collapses long text and cannot expand it.
|
||||
- [#5919](https://github.com/stablyai/orca/issues/5919): generic paste reliability issue.
|
||||
- [#5960](https://github.com/stablyai/orca/issues/5960): bad screen result when pasting links.
|
||||
- [#6364](https://github.com/stablyai/orca/issues/6364): pasting images into an agent CLI does not work using Remote Host.
|
||||
|
||||
### SSH, Remote Runtime, WSL, and Provider Boundaries
|
||||
|
||||
- [#6106](https://github.com/stablyai/orca/issues/6106): SSH terminal loses pre-TUI shell output after Codex tab restore.
|
||||
- [#6846](https://github.com/stablyai/orca/issues/6846): remote PTY sessions do not stay alive on disconnect.
|
||||
- [#6908](https://github.com/stablyai/orca/issues/6908): WSL project added through UI does not discover branches/base refs, while CLI repo add works.
|
||||
- [#6907](https://github.com/stablyai/orca/issues/6907): Codex launched in a WSL worktree does not appear in agent status/sidebar.
|
||||
- [#6916](https://github.com/stablyai/orca/issues/6916): OMP launched in a WSL worktree has no live agent status.
|
||||
- [#6032](https://github.com/stablyai/orca/issues/6032): Remote Orca Servers can lose host-scoped repo and project state.
|
||||
- [#6688](https://github.com/stablyai/orca/issues/6688): viewing a diff on remote host errors.
|
||||
- [#6753](https://github.com/stablyai/orca/issues/6753): remote-host GitHub merge errors.
|
||||
|
||||
### Agent, Tab, Session, and Sidebar State Drift
|
||||
|
||||
- [#6910](https://github.com/stablyai/orca/issues/6910): active agent tabs duplicate UI sessions when switching repositories.
|
||||
- [#6803](https://github.com/stablyai/orca/issues/6803): multiple working agents thrash their order in the sidebar.
|
||||
- [#6072](https://github.com/stablyai/orca/issues/6072): mobile keeps showing old agent rows after terminals are closed.
|
||||
- [#5913](https://github.com/stablyai/orca/issues/5913): Pi compact agent row remains after closing its terminal tab.
|
||||
- [#5718](https://github.com/stablyai/orca/issues/5718): OMP sessions not showing up in Agent Session History or filter options.
|
||||
- [#5404](https://github.com/stablyai/orca/issues/5404): false "agent running" notifications persist after starting Claude Agents on VSSH/WSL.
|
||||
|
||||
### Mobile Terminal and Tabs
|
||||
|
||||
- [#5421](https://github.com/stablyai/orca/issues/5421): iOS workspace tabs do not open.
|
||||
- [#6756](https://github.com/stablyai/orca/issues/6756): iOS terminal renders in cursive/italic font and continuously flickers.
|
||||
- [#5628](https://github.com/stablyai/orca/issues/5628): mobile resizing feature request, closely related to terminal fit/reflow reliability.
|
||||
|
||||
### Browser, Profile, and Embedded Runtime
|
||||
|
||||
- [#6923](https://github.com/stablyai/orca/issues/6923): browser profiles do not isolate storage.
|
||||
- [#6760](https://github.com/stablyai/orca/issues/6760): floating browser cannot load any page.
|
||||
- [#6268](https://github.com/stablyai/orca/issues/6268): embedded-browser cookie/session limitation prevents Auth0 login.
|
||||
- [#6875](https://github.com/stablyai/orca/issues/6875): cannot copy Google Chrome cookies database.
|
||||
- [#6652](https://github.com/stablyai/orca/issues/6652): browser zoom broken.
|
||||
|
||||
### Startup, Install, and Platform Hangs
|
||||
|
||||
- [#5657](https://github.com/stablyai/orca/issues/5657): macOS startup PATH probe can hang under Endpoint Security agents.
|
||||
- [#5107](https://github.com/stablyai/orca/issues/5107): Orca opens very slowly on Windows 11 and leaves a residual process.
|
||||
- [#5989](https://github.com/stablyai/orca/issues/5989): Windows Defender blocks application during installation.
|
||||
|
||||
## Recent Reliability PR Context
|
||||
|
||||
The late-June reliability work clustered around the same few subsystems:
|
||||
|
||||
- New terminal tabs opening and immediately closing: [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801).
|
||||
- Stale/frozen/blank terminals: [#6514](https://github.com/stablyai/orca/pull/6514), [#6800](https://github.com/stablyai/orca/pull/6800), [#6833](https://github.com/stablyai/orca/pull/6833), [#6830](https://github.com/stablyai/orca/pull/6830), [#6866](https://github.com/stablyai/orca/pull/6866).
|
||||
- PTY size and layout desync: [#6644](https://github.com/stablyai/orca/pull/6644), [#6649](https://github.com/stablyai/orca/pull/6649), [#6684](https://github.com/stablyai/orca/pull/6684), [#6725](https://github.com/stablyai/orca/pull/6725), [#6785](https://github.com/stablyai/orca/pull/6785), [#6853](https://github.com/stablyai/orca/pull/6853).
|
||||
- Renderer/xterm crash hardening: [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872), [#6855](https://github.com/stablyai/orca/pull/6855), [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), [#6857](https://github.com/stablyai/orca/pull/6857).
|
||||
- Windows shell/spawn/input: [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876), [#6858](https://github.com/stablyai/orca/pull/6858), [#6890](https://github.com/stablyai/orca/pull/6890).
|
||||
- Hidden/agent startup behavior: [#6824](https://github.com/stablyai/orca/pull/6824), [#6798](https://github.com/stablyai/orca/pull/6798), [#6836](https://github.com/stablyai/orca/pull/6836).
|
||||
|
||||
## Last-Seven-Day Regression Origin Map
|
||||
|
||||
This section maps recent regressions to the place where the system failed, and to the gate that should catch the same class next time.
|
||||
|
||||
| Issue | Symptom | Regression point | Fix / related PR | Missing gate |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| [#6773](https://github.com/stablyai/orca/issues/6773) | New terminal/agent tab opens and closes immediately | Dead-session reconciliation could treat a fresh PTY binding as absent from a stale `listSessions()` snapshot. This likely traces to the local dead-session reconcile path introduced for hidden exited panes in [#6514](https://github.com/stablyai/orca/pull/6514). | [#6796](https://github.com/stablyai/orca/pull/6796), [#6801](https://github.com/stablyai/orca/pull/6801) | Newborn terminal lifecycle test: create shell and agent tabs while liveness reconciliation is in flight; assert the tab cannot be closed by an older snapshot. |
|
||||
| [#5356](https://github.com/stablyai/orca/issues/5356) | First launch after upgrade loses active floating/agent terminal sessions | Cold-restore relied on state that only versions after the fix could write, so users upgrading from pre-fix versions had no migration source. Issue text explicitly names [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). | Not fully closed in current open `P0` set | Upgrade fixture test: boot current app with persisted state from the last affected production version and assert active terminal/agent sessions are preserved or visibly recoverable. |
|
||||
| [#5319](https://github.com/stablyai/orca/issues/5319) | Linux/Wayland terminals render but stop accepting keyboard/scroll input | Wayland-aware GPU safeguard was removed in [#1344](https://github.com/stablyai/orca/pull/1344), leaving eager GPU channel setup able to wedge terminal input on affected systems. | [#6557](https://github.com/stablyai/orca/pull/6557) | Platform GPU matrix test: Linux Wayland launch with terminal WebGL/input smoke and assertion that GPU flags preserve input, not only rendering. |
|
||||
| [#6233](https://github.com/stablyai/orca/issues/6233) | Windows process runs but no window appears | Startup window reveal depended on Electron `ready-to-show`; if that event stalled, there was no fallback reveal or user-visible failure. | [#6462](https://github.com/stablyai/orca/pull/6462) | Startup watchdog test: simulate missing `ready-to-show` on Windows and assert the app either reveals or exits with a clear recovery path. |
|
||||
| [#6163](https://github.com/stablyai/orca/issues/6163) | Orca-managed Codex `config.toml` fails with duplicate `hooks.state` keys on Windows | Two TOML writers serialized the same Windows path key differently, and Orca deduped textually rather than by decoded key. | [#6318](https://github.com/stablyai/orca/pull/6318), follow-up [#6388](https://github.com/stablyai/orca/pull/6388) bug-scan fix | Cross-writer config round-trip test: seed config with Codex-written literal Windows paths, run Orca hook sync twice, then parse with a TOML parser and assert one decoded key. |
|
||||
| [#6244](https://github.com/stablyai/orca/issues/6244) | Headless-launched automation terminal showed only a bash prompt until unmount/remount | Hidden/background automation worktrees were not mounted early enough for output/replay to hydrate on first view. | [#6568](https://github.com/stablyai/orca/pull/6568) | Headless automation mount test: launch background automation, wait for output, first-focus the tab, and assert output is already visible. |
|
||||
| [#6331](https://github.com/stablyai/orca/issues/6331) | WSL workspaces with UNC cwd hit `DaemonProtocolError` | Windows/WSL path handling crossed provider boundaries and daemon spawn assumptions. | [#6536](https://github.com/stablyai/orca/pull/6536) | WSL UNC provider contract: add/open/delete workspace whose root is `\\wsl.localhost\...`; assert daemon and file operations use the right host/path semantics. |
|
||||
| [#6336](https://github.com/stablyai/orca/issues/6336) | Monorepo subfolder import opened the repo root instead of the selected subfolder | Git-root normalization leaked into the first-terminal cwd/user-facing workspace root behavior. | [#6574](https://github.com/stablyai/orca/pull/6574) | Add-project matrix: repo root, monorepo subfolder, folder workspace, remote runtime; assert file tree root and first terminal cwd separately. |
|
||||
| [#6147](https://github.com/stablyai/orca/issues/6147) | Chinese full-width punctuation became half-width ASCII in terminal | IME/composition forwarding lacked coverage for macOS full-width punctuation and composition-owned text. | [#6417](https://github.com/stablyai/orca/pull/6417) | IME matrix test: macOS CJK full-width punctuation, Vietnamese Telex, Linux fcitx/Sogou, Windows CJK; assert committed bytes and displayed cells. |
|
||||
| [#5656](https://github.com/stablyai/orca/issues/5656) / [#5653](https://github.com/stablyai/orca/issues/5653) | Windows Claude Code prompt showed phantom/overwritten characters until resize | Rewrite-style terminal output did not reliably force visible row repaint for CR/CHA/erase updates. | [#6544](https://github.com/stablyai/orca/pull/6544), [#6449](https://github.com/stablyai/orca/pull/6449) | Terminal redraw fixture: feed CR, CHA, backspace, erase-line/screen and assert visible cells after each chunk without resize. |
|
||||
| [#5161](https://github.com/stablyai/orca/issues/5161) | PowerShell terminal failed to spawn with Windows error code 5 | Windows shell resolver handed ConPTY a bare `pwsh.exe`, which could resolve to an App Execution Alias stub. | [#6537](https://github.com/stablyai/orca/pull/6537), [#6876](https://github.com/stablyai/orca/pull/6876) | Windows shell resolution contract: auto and explicit PowerShell selections must resolve to real executables and skip WindowsApps aliases. |
|
||||
| [#6270](https://github.com/stablyai/orca/issues/6270) | Restoring agent sessions was not working for SSH/runtime use cases | Resume logic and runtime host restrictions did not cover SSH workspaces. | [#6685](https://github.com/stablyai/orca/pull/6685) | Agent resume provider matrix: local, daemon, SSH, remote runtime, Windows shell; assert resume command is queued on the configured host/provider. |
|
||||
| [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) | Terminal search could crash terminal surface on narrow viewports | xterm search decorations received invalid dimensions and threw through the terminal surface. | [#6852](https://github.com/stablyai/orca/pull/6852), [#6872](https://github.com/stablyai/orca/pull/6872) | xterm addon safety test: narrow viewport, zero/near-zero columns, wrapped matches, keyboard next/previous; assert addon errors are caught and pane remains usable. |
|
||||
| [#6855](https://github.com/stablyai/orca/pull/6855) | xterm web-link provider `RangeError` could kill the Windows renderer | Link provider ran synchronously inside xterm with no guard around pathological wrapped lines. | [#6855](https://github.com/stablyai/orca/pull/6855) | Addon boundary policy: all xterm addon callbacks are guarded and failure disables only that addon for that pane. |
|
||||
| [#6853](https://github.com/stablyai/orca/pull/6853) | Split-right PTY stayed at `0x0` and rendered a white screen | Deferred spawn measured an unlaid-out pane; post-spawn reconcile could terminate before forwarding a usable size. | [#6853](https://github.com/stablyai/orca/pull/6853) | Split spawn invariant: visible panes must never keep PTY size `0x0`; fallback size must be forwarded if measurement is unavailable. |
|
||||
|
||||
The repeated pattern is not one person repeatedly making the same mistake. It is that Orca does not yet have enough executable contracts around lifecycle, provider boundaries, terminal sizing, xterm safety, platform shell behavior, and upgrade state.
|
||||
|
||||
## PR Process Evidence
|
||||
|
||||
Reading the PR descriptions for the confirmed or likely regression chains shows that the failure was usually not "no process." The failure was that the process accepted plausible local validation without requiring the invariant that would have protected the escaped user path.
|
||||
|
||||
### #6514: Dead-Session Reconcile Introduced a Newborn-Tab Hazard
|
||||
|
||||
[PR #6514](https://github.com/stablyai/orca/pull/6514) was a Brennan-authored reliability fix for frozen terminal panes after a backgrounded agent exits. The PR body shows a substantial process: design review, completeness verification, headline behavior status, perf audit, code-review loop, Electron validation, and targeted unit tests. The stated non-goal explicitly excluded SSH/remote reconciliation, and the implementation revalidated pane/PTY identity at apply time.
|
||||
|
||||
What still got through:
|
||||
|
||||
- The PR protected "stale dead pane should close" but did not name the dual invariant "a fresh or newly bound pane cannot be closed by a liveness snapshot requested before that binding existed."
|
||||
- The tests covered live/dead/remote/idempotency paths, but not the TOCTOU case where `listSessions()` is requested, a new PTY binds before the response returns, and the stale response omits the newborn id.
|
||||
- Reviewers accepted "healthy panes are untouched" as a semantic claim, but the executable gate did not prove that claim under delayed session enumeration.
|
||||
|
||||
Skill implication:
|
||||
|
||||
- `brennan-yolo-lite`, `auto-design-review-fix`, `review-code`, and `brennan-test-changes` all need to force lifecycle changes to state the inverse safety property, not only the intended cleanup behavior.
|
||||
- For terminal lifecycle changes, the review checklist should ask "what newer state can this async observation accidentally destroy?"
|
||||
|
||||
### #5234 and #5240: Session-Restore Fixes Missed Old-Version Upgrade Fixtures
|
||||
|
||||
[PR #5234](https://github.com/stablyai/orca/pull/5234) and [PR #5240](https://github.com/stablyai/orca/pull/5240) were Jinwoo-authored fixes for slow-startup daemon PTY restoration and quit-time agent session persistence. Their PR descriptions show AI review, negative controls, targeted tests, and high-quality e2e coverage against the newly modeled behavior.
|
||||
|
||||
What still got through:
|
||||
|
||||
- The tests proved the new behavior once the new data and startup gates existed.
|
||||
- They did not appear to boot the current app against serialized persisted state from the affected production versions before the fix.
|
||||
- The escaped class is an upgrade/migration class: a fix can be correct for state written after the fix while still losing users who upgrade from state written before it.
|
||||
|
||||
Skill implication:
|
||||
|
||||
- Startup, restore, daemon, and persistence PRs need an "old state corpus" gate: seed data from the last affected production version, not only state produced by current code.
|
||||
- Negative controls should include "old production fixture fails before fix and is preserved after fix" when the bug is upgrade-related.
|
||||
|
||||
### #1344: GPU Rendering Parity Missed Linux Wayland Input Failure
|
||||
|
||||
[PR #1344](https://github.com/stablyai/orca/pull/1344) replaced global GPU opt-ins with VS Code-style GPU startup flags and terminal GPU acceleration settings. The PR description cites reference behavior and local tests, but it does not show the later, stricter Brennan PR process markers used in the June reliability fixes.
|
||||
|
||||
What still got through:
|
||||
|
||||
- The change removed or bypassed the Wayland-aware GPU safeguard later identified by [PR #6557](https://github.com/stablyai/orca/pull/6557).
|
||||
- Validation covered configuration and pane lifecycle tests, but not a Linux Wayland terminal input smoke where rendering appears live while keyboard/scroll input is wedged.
|
||||
- One TypeScript command was reported as failing on existing project-include issues, which made the PR less clean as a release-risk artifact.
|
||||
|
||||
Skill implication:
|
||||
|
||||
- Cross-platform rendering/startup changes need an explicit platform matrix tied to the failure mode: not just "renders," but "renders and accepts input."
|
||||
- Reference implementations are useful context, but factory review must still require Orca-specific platform hazards to be named and tested or accepted.
|
||||
|
||||
### Regression Lessons From Fresh Review
|
||||
|
||||
Fresh reviewer passes on the recent regression chains pointed to these reusable misses. These are class-level gates, not new blame findings.
|
||||
|
||||
- [#6514](https://github.com/stablyai/orca/pull/6514) -> [#6773](https://github.com/stablyai/orca/issues/6773), fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801): the original PR correctly proved the forward cleanup case, where a hidden local/daemon PTY exits and should reconcile on resume while live listed panes survive. The missed invariant was the inverse lifecycle property: a destructive liveness snapshot can only close state that existed before the snapshot was requested. The escaped path was a macOS local new shell or agent tab where `listSessions()` was requested, a new PTY bound before the response returned, the stale response omitted the newborn id, and the renderer routed the healthy binding through exit teardown. The next gate is a Terminal Snapshot Freshness and Ownership Gate: a fake daemon holds `listSessions()` at `t0`, binds a newborn PTY at `t1`, resolves the stale `t0` snapshot without that PTY, and asserts the tab/pane survives, still owns the same PTY, accepts input, and renders output. A later post-bind snapshot may close a genuinely dead PTY, but only when provider, pane, PTY id, and binding epoch still match.
|
||||
- [#6800](https://github.com/stablyai/orca/pull/6800), with [#5240](https://github.com/stablyai/orca/pull/5240), [#6411](https://github.com/stablyai/orca/pull/6411), [#6514](https://github.com/stablyai/orca/pull/6514), and [#6833](https://github.com/stablyai/orca/pull/6833) as related context: the original fix correctly proved that active worktree-sleep records should not be claimed merely because old tab/layout state exists; visible connecting panes with restorable identity can own wake, stale hidden panes fresh-resume, and completed hibernation evidence stays passive. The missed invariant was provider-session ownership, not "pane connects now." For each provider-session claim key, exactly one durable owner must exist: a live hook/status for that same provider session, a queued/sent resume command for that same provider session, or an explicit retained record because spawn/connect failed. Layout match, PTY wake hints, replayed terminal bytes, and cold-restored screen snapshots are display evidence, not ownership evidence. The next gate is an Agent Provider Session Ownership Matrix covering active focused tabs, inactive tabs, visible non-focused splits, live/quit/worktree-sleep origins, replay-only/cold-restore-only/wrong-session/no-hook/delayed-hook cases, and repeated workspace activation.
|
||||
- [#5234](https://github.com/stablyai/orca/pull/5234) / [#5240](https://github.com/stablyai/orca/pull/5240) -> [#5356](https://github.com/stablyai/orca/issues/5356): the original PRs correctly proved slow-daemon restoration and newly written quit-origin provider-session persistence. The missed invariant was first-open-after-upgrade compatibility: startup/restore fixes must preserve or explicitly recover user sessions from the last affected production persisted-state schema, not only from state written by the fixed build. The escaped path was first launch after a 1.4.65 -> 1.4.68 upgrade, including Windows 10 active floating shell and Claude panes, where the old version had no `sleepingAgentSessionsByPaneKey` payload to consume. The next gate is a Persisted Session Upgrade/Restore Contract: boot current Orca against immutable user-data fixtures from the last affected version, including active and inactive tabs, visible and hidden splits, old tab-level PTY wake hints, missing `sleepingAgentSessionsByPaneKey`, live/quit/worktree-sleep/legacy origins, repeat activation, and a second restart after current code writes upgraded state. The oracle rejects blank replacement panes, duplicate resume tabs, silent session loss, and "works only after current code writes new state."
|
||||
- [#1344](https://github.com/stablyai/orca/pull/1344) -> [#5319](https://github.com/stablyai/orca/issues/5319) / [#6557](https://github.com/stablyai/orca/pull/6557): the original PR correctly attempted to replace broad GPU opt-ins with VS Code-style GPU channel flags and prove terminal GPU/WebGL fallback at unit level. The missed invariant was terminal platform liveness, not visual readiness. A terminal is healthy only if it renders output, receives focus, delivers keyboard input to the correct PTY, echoes process output back to the xterm buffer, scrolls, and survives GPU/renderer startup on the target platform. The next gate is a Terminal Platform Liveness Gate across Linux Wayland GPU paths, headless Weston plus at least one scheduled/manual headful Wayland run, control Linux/macOS/Windows paths when renderer policy is shared, active and inactive tabs, visible and hidden panes, first mount, tab switch, repeated workspace activation, local PTY, and SSH/remote PTY. Screenshots alone never pass.
|
||||
- [#6644](https://github.com/stablyai/orca/pull/6644) / [#6649](https://github.com/stablyai/orca/pull/6649) / [#6725](https://github.com/stablyai/orca/pull/6725) / [#6785](https://github.com/stablyai/orca/pull/6785) / [#6794](https://github.com/stablyai/orca/pull/6794) / [#6853](https://github.com/stablyai/orca/pull/6853) / [#6939](https://github.com/stablyai/orca/pull/6939): the fixes correctly proved pieces of the PTY geometry class: first-mount column desync, single-frame insufficiency, split-mount convergence, requested-size versus applied-size drift, mobile-owned PTY exceptions, visible split `0x0` fallback, and visible geometry reassertion after stable fit. The missed invariant was cross-layer terminal geometry authority. A visible desktop-owned terminal is not valid until xterm `cols/rows`, fit/proposed dimensions, applied PTY winsize, shell-visible `stty`/`stdout.columns`, and echo-wrap behavior converge after layout settles. Hidden/inactive panes may defer resize, and mobile/remote providers may intentionally diverge, but every transition into visible desktop ownership must converge. The next gate is a terminal geometry matrix with fault injection for first `0x0` fit, delayed layout settle, dropped/delayed resize acknowledgement, requested-size-versus-applied-size drift, SSH-style unknown applied size, hidden-to-visible activation, split create/collapse, restore, repeated workspace activation, and no redundant hidden SIGWINCH.
|
||||
- [#6852](https://github.com/stablyai/orca/pull/6852) / [#6872](https://github.com/stablyai/orca/pull/6872) / [#6855](https://github.com/stablyai/orca/pull/6855), with [#6868](https://github.com/stablyai/orca/pull/6868), [#6856](https://github.com/stablyai/orca/pull/6856), and [#6857](https://github.com/stablyai/orca/pull/6857) as nearby WebGL/renderer-risk hardening: the fixes correctly moved toward safe wrappers for search dimensions, keyboard search navigation, link providers, WebGL addon patches, and renderer/GPU crash fallback. The missed invariant was xterm addon boundary containment. Optional addons are untrusted boundary code: search decorations, link providers, WebGL attach/reset/dispose, renderer fallback, hover callbacks, and stale async callbacks must not throw across the pane, React, or window boundary. The next gate is an xterm-addon-boundary harness that injects throwing addon behavior under active tabs, inactive tabs, visible and hidden splits, hidden-to-visible resume, floating tab switch, repeated workspace activation, and pane destroy/recreate; the oracle is pane-scoped addon disable/degrade with one breadcrumb, no React/window crash, and surviving focus, input, PTY echo, and rendering.
|
||||
|
||||
Concrete gate shapes from the fresh passes:
|
||||
|
||||
- **Snapshot freshness harness**: defer and reorder `listSessions()` / `listTerminals()` responses. Request a snapshot at `t0`, bind a new PTY at `t1`, resolve the `t0` response without the new PTY, and assert the pane survives with PTY id, tab ownership, and input/output proof. Then request a fresh `t2 > t1` snapshot for a genuinely killed PTY and assert teardown works. Run active/inactive tabs, visible/hidden splits, first-input probes, restore/reattach, repeat activation, and SSH/remote unknown-liveness variants.
|
||||
- **Provider-session ownership matrix**: model provider-session ownership separately from terminal render output. For live, quit, active worktree-sleep, and completed worktree-sleep origins, prove each provider session is exactly one of owned, resuming, or intentionally retained. Cover active focused tabs, inactive hidden tabs, visible non-focused splits, delayed/missing/wrong hooks, duplicate legacy records, fresh-spawn failure, and repeat activation. Displayed replay must never count as ownership.
|
||||
- **Old-version restore corpus**: keep immutable user-data fixtures from the last affected production versions, especially pre-#5232 session state. Boot current Orca against those fixtures and assert every persisted PTY/agent record is preserved, resumed once, warm-reattached once, or visibly marked unrecoverable. Include second restart after current Orca writes upgraded state.
|
||||
- **Wayland platform liveness**: extend the Linux Wayland verifier so it proves terminal render, focus, keyboard input, PTY echo, scrollback movement, and GPU crash/stall absence across active/inactive tabs, visible/hidden splits, first mount, tab switch, restore, repeated activation, local PTY, and SSH/remote runtime. Screenshot-only evidence is not enough.
|
||||
- **Terminal geometry convergence**: add an `assertTerminalGeometryConverged` oracle that compares xterm cols/rows, fit/proposed dimensions, applied PTY size when available, shell-visible `stty size` / `$COLUMNS`, and deterministic echo/wrap behavior. Inject dropped resize, delayed resize until visible, stale applied size, null SSH-style readback, initial `0x0`, and layout settling after more frames than old fixed budgets.
|
||||
- **xterm addon boundary harness**: inject throwing search decorations, link providers, WebGL attach/reset/dispose, and stale async callbacks under active tab, inactive tab, visible split, hidden split, hidden-to-visible resume, floating tab switch, repeat activation, and pane destroy/recreate. Assert no `window.onerror` or React boundary hit, one structured breadcrumb, addon-scoped degradation, focus/input survival, PTY/output rendering, and WebGL fallback to DOM when needed.
|
||||
|
||||
### Corrective PRs Show the Desired Direction
|
||||
|
||||
The later fixes, especially [#6801](https://github.com/stablyai/orca/pull/6801), show the right shape: root-cause description, happens-before proof, explicit `auto-design-review-fix`, completeness verification, code-review loop, `brennan-test-changes`, and targeted tests for the stale snapshot/newborn binding race. [#6852](https://github.com/stablyai/orca/pull/6852), [#6853](https://github.com/stablyai/orca/pull/6853), and [#6855](https://github.com/stablyai/orca/pull/6855) also show good local invariant tests around xterm addon throws and PTY `0x0` sizing.
|
||||
|
||||
The process improvement is to make that shape mandatory before the regression escapes, and to promote only the deterministic, red/green parts into blocking CI.
|
||||
|
||||
## Core Pain Points
|
||||
|
||||
### 1. Terminal Lifecycle Is Inferred Instead of Owned
|
||||
|
||||
Many paths infer terminal state from a combination of pane visibility, provider session membership, stored pane layout, active leaf ids, sleeping-agent records, and xterm mount state. That makes it easy for a stale observation to tear down a fresh pane, or for a dead pane to look live.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- New tab opens and immediately closes.
|
||||
- Hidden terminal exits but the visible pane later looks frozen and swallows input.
|
||||
- Sleeping or hibernated agents get stranded in stale panes.
|
||||
- Mobile and desktop disagree about which terminal/session still exists.
|
||||
|
||||
### 2. PTY Size Authority Is Split Across Too Many Places
|
||||
|
||||
`FitAddon`, `ResizeObserver`, deferred `requestAnimationFrame`, mobile-fit locks, hidden-pane guards, PTY locks, split layout equalization, and provider resize forwarding all compete to decide the active terminal size.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- PTY born at `0x0`.
|
||||
- White split pane.
|
||||
- First-mount column desync.
|
||||
- TUI output wraps at the wrong width until manual resize.
|
||||
- Mobile-size terminals fight desktop-size restoration.
|
||||
|
||||
### 3. xterm Addons Are Unsafe Boundary Code
|
||||
|
||||
The terminal surface trusts addon behavior too much. Recent failures came from search decorations, web-link providers, WebGL texture atlas resets, CJK/wide glyph rendering, and synchronized-output buffering.
|
||||
|
||||
Any addon that can throw synchronously or produce invalid dimensions can take down the workbench unless Orca wraps it defensively.
|
||||
|
||||
### 4. Provider Semantics Drift
|
||||
|
||||
Local PTY, daemon PTY, SSH relay, remote runtime, WSL, Windows ConPTY, Linux Wayland, and mobile sessions do not share the same truth source for liveness, size, replay, cwd, shell, or reconnect behavior.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- Local-only liveness checks are accidentally applied to SSH/remote panes.
|
||||
- Remote paths miss fixes made only in the renderer.
|
||||
- WSL works through CLI add but not UI add.
|
||||
- Daemon can be protocol-healthy while PTY spawn is degraded.
|
||||
|
||||
### 5. Agent Identity Is Coupled to Terminal UI Details
|
||||
|
||||
Agent status, sidebar rows, compact rows, mobile session tabs, sleeping-agent records, terminal helper identity, and pane reuse all share responsibility for "what agent is this?"
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- Duplicate agent tabs.
|
||||
- Stale sidebar rows after terminal close.
|
||||
- Wrong agent icon/title.
|
||||
- False running/completed notifications.
|
||||
- Worktree switching changes agent ordering or ownership.
|
||||
|
||||
Class-level invariant:
|
||||
|
||||
- Agent/provider session replay must be ownership-safe. When code touches agent launch, restore, sleep, hibernate, dedupe, clearing, or workspace activation, it must prove Orca does not replay or resume a provider session id that is already owned, queued, pending, or live in that workspace. This applies across active and inactive tabs, visible and hidden panes, repeat activation of the same workspace, and session records whose origin is live, quit, or worktree sleep.
|
||||
- Motivating example: the #6800 escape showed how code can conflate "this pane will connect right now" with "this pane/session owns this provider session." A valid inactive or hidden existing session can then look unowned, causing workspace activation to replay `agent resume <same-session-id>` instead of recognizing the session is already represented. The gate should protect the whole class, not only the #6800 path.
|
||||
- Display evidence is not ownership evidence. Replayed terminal bytes, cold-restored screen snapshots, tab layout matches, and "will connect soon" state must never count as proof that a provider session id is already claimed.
|
||||
|
||||
### 6. Upgrade and Startup Are Under-Tested as First-Class Flows
|
||||
|
||||
Several failures only appear after an app update, daemon survival across versions, old persisted state, stale helper paths, or a first launch after migration.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- First launch after upgrade loses sessions.
|
||||
- Daemon remains reachable but cannot spawn PTYs.
|
||||
- App process runs but no window appears.
|
||||
- Release packaging/notarization works at top level but fails for a nested binary.
|
||||
|
||||
### 7. Reliability Fixes Can Regress Performance or Crash Safety
|
||||
|
||||
Several reliability fixes add watchers, reconciliation loops, terminal replay, git scans, or richer diagnostics. Those are high-risk performance surfaces if they wake hidden panes, serialize large state, multiply subprocesses, or make cleanup scans wait for final results before showing progress.
|
||||
|
||||
Common symptoms:
|
||||
|
||||
- Terminal input becomes correct but slower under foreground TUI redraw or background PTY output.
|
||||
- Hidden panes keep waking the renderer for data/title/spinner frames.
|
||||
- Store subscribers or persistence writes serialize large terminal/worktree maps on common interactions.
|
||||
- Git/worktree cleanup opens a modal that spins, duplicates rows, hangs on a subprocess, or crashes on Windows/WSL/SSH paths.
|
||||
- Safety checks run unbounded git subprocesses or trust stale cleanup evidence before destructive removal.
|
||||
|
||||
## Proposed Improvements
|
||||
|
||||
### A. Build a Terminal Lifecycle State Machine
|
||||
|
||||
Create a single source of truth for terminal lifecycle:
|
||||
|
||||
- `newborn`
|
||||
- `connecting`
|
||||
- `live`
|
||||
- `hidden-live`
|
||||
- `exited-observed`
|
||||
- `exited-unobserved`
|
||||
- `hibernated`
|
||||
- `restoring`
|
||||
- `degraded`
|
||||
- `closed`
|
||||
|
||||
All teardown, restore, reconnect, hibernation, and liveness decisions should transition through this machine. Avoid having individual React effects independently infer lifecycle from partial state.
|
||||
|
||||
### B. Make Liveness Reconciliation Epoch-Based
|
||||
|
||||
Every pane/PTY binding should carry a monotonic renderer-local epoch or generation. Any async liveness snapshot must include the epoch it was requested against.
|
||||
|
||||
Rules:
|
||||
|
||||
- A snapshot requested before a bind cannot close that bind.
|
||||
- A provider membership result can only prove death when the current binding still exactly matches the request context: pane id, provider id, connection id, PTY id, and epoch.
|
||||
- Remote/SSH/WSL providers must supply provider-scoped liveness, or the reconciler must treat them as unknown.
|
||||
|
||||
This generalizes the timestamp guard added for newborn terminal tabs and makes it harder to reintroduce the class.
|
||||
|
||||
### C. Define a PTY Size Ownership Protocol
|
||||
|
||||
Document and enforce one protocol for size authority:
|
||||
|
||||
- Initial spawn size source.
|
||||
- Hidden-pane spawn behavior.
|
||||
- Mobile-fit lock behavior.
|
||||
- Split layout settle behavior.
|
||||
- Fallback size behavior.
|
||||
- Provider resize acknowledgement.
|
||||
- Applied PTY size reporting.
|
||||
- Dropped, delayed, or skipped resize recovery.
|
||||
- Hand-off from reconcile loop to live `ResizeObserver`.
|
||||
|
||||
Add invariant tests that a visible terminal never remains `0x0`, and that xterm `cols/rows` converge with PTY-reported `cols/rows` under split, restore, reload, and mobile-fit scenarios.
|
||||
|
||||
Class-level invariant:
|
||||
|
||||
- A visible terminal pane must have one authoritative size path from layout measurement to xterm fit to provider resize to applied PTY size. `0x0` and unknown layout measurements are non-authoritative for visible panes. If a resize is skipped because of a mobile lock, PTY lock, hidden state, split layout settle, first mount, provider delay, or dropped acknowledgement, Orca must keep reconciling until the applied PTY size and xterm size converge or the pane enters an explicit degraded state.
|
||||
|
||||
Required deterministic hooks:
|
||||
|
||||
- Force first measurement to `0x0` during split-right spawn.
|
||||
- Delay split layout settlement across frames.
|
||||
- Hold and release mobile-fit or PTY locks while reconcile is active.
|
||||
- Drop or delay one provider resize acknowledgement.
|
||||
- Report applied PTY size separately from requested size.
|
||||
- Switch hidden-to-visible or worktree-to-worktree while a resize is pending.
|
||||
|
||||
Required oracle:
|
||||
|
||||
- After the fault, assert visible xterm `cols/rows`, provider-reported applied PTY `cols/rows`, and process-observed `stdout.columns/rows` converge.
|
||||
- Assert a width-sensitive TUI or long-line fixture renders without one-column wrapping.
|
||||
- Assert typed input reaches the process and echoed output appears after convergence.
|
||||
- Assert no test is promoted to blocking if it relies on blind `waitForTimeout()` instead of size/lifecycle events or controlled fault release.
|
||||
|
||||
### D. Add Provider Contract Tests
|
||||
|
||||
Start with a deterministic fake/fault-injection provider, then run the same lifecycle contract against real providers.
|
||||
|
||||
The fake provider should be able to:
|
||||
|
||||
- Delay and reorder `listSessions()` responses.
|
||||
- Return `unknown` separately from empty/live/dead.
|
||||
- Delay attach/replay and resize acknowledgements.
|
||||
- Kill processes while panes are hidden.
|
||||
- Drop or duplicate exit/replay/resize events.
|
||||
- Simulate stale cwd/helper paths.
|
||||
|
||||
After the fake provider is stable, run the contract against:
|
||||
|
||||
- local PTY
|
||||
- daemon PTY
|
||||
- degraded daemon wrapper
|
||||
- SSH relay
|
||||
- remote runtime
|
||||
- WSL path/shell combinations
|
||||
- mobile session replay
|
||||
|
||||
Required scenarios:
|
||||
|
||||
- spawn
|
||||
- hidden spawn
|
||||
- visible restore
|
||||
- hidden exit
|
||||
- reconnect
|
||||
- resize before/after mount
|
||||
- cwd deleted
|
||||
- helper binary moved after upgrade
|
||||
- process dies while renderer is hidden
|
||||
|
||||
### E. Create a Terminal Torture Harness
|
||||
|
||||
Add a nightly and pre-release reliability suite that exercises:
|
||||
|
||||
- rapid tab create/close
|
||||
- agent create/close
|
||||
- split right/down
|
||||
- reload with split layout
|
||||
- hidden-to-visible switching
|
||||
- huge streaming TUI output
|
||||
- CJK, Vietnamese, Arabic, and emoji input/output
|
||||
- long paste, link paste, image paste
|
||||
- browser/terminal focus handoffs
|
||||
- hibernation wake
|
||||
- daemon restart/degraded fallback
|
||||
- mobile connect/disconnect during active terminal traffic
|
||||
|
||||
This should record screenshots, terminal snapshots, PTY sizes, lifecycle transitions, and renderer CPU.
|
||||
|
||||
Each scenario must also declare its oracle before it can be promoted beyond `experimental`: the observable pass condition, the failure artifact, and whether it is a discovery/stress run or a merge-blocking gate. Stress scenarios without deterministic oracles should stay nightly/pre-release only.
|
||||
|
||||
### F. Wrap xterm Addons Behind Safe Adapters
|
||||
|
||||
Search, links, WebGL, decorations, and input-protocol helpers should be treated as untrusted code:
|
||||
|
||||
- Catch synchronous addon throws.
|
||||
- Validate all rows/cols/decoration dimensions before calling xterm APIs.
|
||||
- Disable or degrade an addon for a pane after repeated failures.
|
||||
- Emit a structured breadcrumb with pane id, provider, terminal size, addon name, and sanitized error.
|
||||
|
||||
Class-level invariant:
|
||||
|
||||
- xterm addon failures must be contained to the addon and pane. A search, link, WebGL, decoration, input-protocol, or keyboard-navigation error must never unmount the terminal surface, crash the renderer, break focus, or stop typed input/output for the PTY. Invalid dimensions, wrapped-line ranges, zero columns, texture atlas resets, and synchronous callback throws should either be validated away before the addon call or caught and downgraded after the call.
|
||||
|
||||
Required deterministic hooks:
|
||||
|
||||
- Force zero or near-zero terminal columns.
|
||||
- Force wrapped search and link ranges across line boundaries.
|
||||
- Throw from search, link, decoration, and WebGL callback boundaries.
|
||||
- Simulate WebGL texture/atlas reset or addon initialization failure.
|
||||
- Invoke next/previous search navigation while the search addon reports an invalid match.
|
||||
|
||||
Required oracle:
|
||||
|
||||
- The terminal pane remains mounted and focused.
|
||||
- The failing addon is disabled or downgraded only for the affected pane.
|
||||
- A structured breadcrumb names the addon and sanitized failure.
|
||||
- Typed input reaches the process and echoed output appears after the addon failure.
|
||||
|
||||
### G. Add Upgrade Fixtures
|
||||
|
||||
Keep serialized workspaces and daemon/session state from known old versions:
|
||||
|
||||
- pre-#5232 session restore data
|
||||
- stale daemon cwd
|
||||
- stale node-pty helper path
|
||||
- old hibernated agent records
|
||||
- old mobile session snapshots
|
||||
- old Windows shell settings
|
||||
|
||||
CI should boot the current app against these fixtures and assert no session loss, no blank first pane, and no startup deadlock.
|
||||
|
||||
Fixture rules:
|
||||
|
||||
- Name the exact production version and build source.
|
||||
- Store the pre-migration state immutably and copy it to a temp profile before boot.
|
||||
- Document the expected pre-migration shape and post-migration behavior.
|
||||
- Include stale daemon/helper/path state when that is the real user upgrade path.
|
||||
- Treat silent blank replacement as a failure. If exact restoration is impossible, the accepted outcome must be an explicit recoverable/degraded state or user-facing migration notice, not silent session loss.
|
||||
|
||||
### H. Add Platform Startup, Input, and Release Artifact Contracts
|
||||
|
||||
Some failures look visually healthy while the product is broken. Platform contracts must prove the user action, not only the frame.
|
||||
|
||||
Required contracts:
|
||||
|
||||
- Terminal rendering/input: on Linux Wayland, Windows ConPTY, and macOS, assert the terminal renders, accepts typed input, echoes process output, scrolls, and preserves focus after GPU/renderer startup paths touched by the PR.
|
||||
- IME/input: when terminal input handling changes, assert representative CJK, Vietnamese, Arabic, paste, and modifier paths at the lowest deterministic layer available.
|
||||
- Startup/window lifecycle: packaged app reveals, close button and Alt+F4 work on Windows, startup watchdogs recover when `ready-to-show` stalls, and PATH/shell probes time out with a recoverable state.
|
||||
- Release artifact: packaged builds include required nested binaries and pass signing/notarization checks, not only top-level app validation.
|
||||
- Daemon degradation: daemon reachable-but-unable-to-spawn and stale helper/cwd states produce a visible degraded path instead of blank or frozen terminals.
|
||||
|
||||
### I. Add an Agent Identity Contract
|
||||
|
||||
Provider and terminal tests are not enough for agent/session identity drift. Add a contract for:
|
||||
|
||||
- Pane reuse and tab close.
|
||||
- Worktree switch and sidebar ordering.
|
||||
- Sleep/wake and hibernation records.
|
||||
- Provider session id capture and consumption.
|
||||
- Mobile rows and desktop rows converging after terminal close.
|
||||
- False running/completed notifications.
|
||||
- Agent history/filter visibility for restored sessions.
|
||||
- Provider session replay ownership: workspace activation, launch, restore, sleep/hibernate, dedupe, clearing, and reconnect code must not issue a second resume/launch for a provider session id already owned by an active tab, inactive tab, visible pane, hidden pane, queued pane, pending connection, or live session in the same workspace.
|
||||
- Session record origins: the same invariant must be proved for live sessions, quit-restored sessions, and worktree-sleep records, including repeat activation of an already-active workspace.
|
||||
|
||||
Required agent identity scenarios:
|
||||
|
||||
- Activate a workspace with an existing visible live agent pane; assert no duplicate `resume <provider-session-id>` is queued.
|
||||
- Activate a workspace with an existing hidden/inactive live agent pane; assert the provider session is recognized as represented even before the pane visibly reconnects.
|
||||
- Re-activate the same workspace repeatedly while agent restore/session discovery is pending; assert the pending ownership claim dedupes all replay attempts.
|
||||
- Restore a quit-origin session and a worktree-sleep-origin session; assert each is claimed once, survives hidden-to-visible transitions, and does not produce duplicate sidebar/mobile rows.
|
||||
- Clear or close stale panes while a same-session replacement is queued or pending; assert cleanup cannot erase the newer ownership claim or trigger a second replay.
|
||||
|
||||
### J. Introduce Reliability Gates for Terminal/Session PRs
|
||||
|
||||
For PRs touching terminal, agent status, sessions, startup, provider routing, or hibernation, require:
|
||||
|
||||
- Provider matrix explicitly listed.
|
||||
- Hidden-pane behavior listed.
|
||||
- SSH/remote behavior listed.
|
||||
- Windows/WSL behavior listed when shell, cwd, path, ConPTY, or process launch is touched.
|
||||
- A regression test that fails against the old behavior.
|
||||
- A manual or automated stress command if the change touches timing.
|
||||
- PTY size authority proof when the PR touches terminal spawn, split layout, hidden/visible transitions, mobile fit, resize forwarding, PTY locks, provider resize acknowledgement, or xterm fit. The proof must cover `0x0` quarantine, requested versus applied PTY size, xterm/process size convergence, and a user-visible input/output oracle.
|
||||
- Agent/provider replay ownership proof when the PR touches agent launch, restore, sleep/hibernate, dedupe, clearing, workspace activation, pane reuse, provider session id capture, sidebar/mobile agent rows, or session history. The proof must cover active versus inactive tabs, visible versus hidden panes, repeat workspace activation, and live, quit, and worktree-sleep session record origins.
|
||||
- xterm addon containment proof when the PR touches search, links, WebGL, decorations, keyboard terminal shortcuts, synchronized output, or xterm addon registration. The proof must show addon throws and invalid geometry cannot crash the terminal surface, unmount the pane, or break typed input/output.
|
||||
- The terminal/session checklist should explicitly ask: "Can this change make an already represented provider session look unowned, or replay/resume it a second time?"
|
||||
- The terminal/session checklist should explicitly ask: "Can this change make a visible pane trust `0x0`, stale requested size, or renderer-only size instead of the size the PTY actually applied?"
|
||||
- The terminal/session checklist should explicitly ask: "Can an xterm addon callback or invalid addon geometry throw through the terminal surface instead of being contained?"
|
||||
|
||||
### K. Make Reliability Tests Predictable and Useful
|
||||
|
||||
The goal is not "more tests." The goal is a small set of executable contracts that protect named invariants from real failures.
|
||||
|
||||
Required shape for every new reliability gate:
|
||||
|
||||
- Name the invariant it protects.
|
||||
- Name the real issue, PR, or failure mode that motivated it.
|
||||
- Prove red/green: the gate must fail against the old behavior, a regression fixture, or a small intentionally broken variant before it becomes a trusted blocker.
|
||||
- Attach a promotion record: invariant id, motivating issue/PR, old-fail evidence, new-pass evidence, exact command, failure output or CI link, deterministic wait condition, owner, runtime budget, and demotion rule.
|
||||
- Pick the cheapest deterministic layer: pure state-machine or provider-contract tests first, integration tests second, Electron/UI tests only for the small number of golden paths that require the real app shell.
|
||||
- Avoid sleeps as proof. Wait for observable state, protocol events, lifecycle transitions, PTY size convergence, replay completion, or process exit.
|
||||
- Record why the test is allowed to block merges: expected runtime, flake history, covered invariant, and owner.
|
||||
|
||||
Test maturity levels:
|
||||
|
||||
- `experimental`: local or nightly only; useful for shaking out the harness, not allowed to block merges.
|
||||
- `soak`: runs repeatedly in CI but non-blocking; collects flake rate, runtime, and whether failures are actionable.
|
||||
- `blocking`: promoted only after red/green proof, stable runtime, and low flake rate.
|
||||
|
||||
Flake policy:
|
||||
|
||||
- Blocking promotion should require numeric evidence, for example 100 consecutive soak runs or 14 days across required CI platforms, p95 runtime within the gate budget, and zero unexplained flakes. Tune the numbers later, but do not leave "stable" undefined.
|
||||
- A blocking reliability test that flakes above the agreed threshold should be demoted or fixed quickly; rerun-only culture hides weak gates.
|
||||
- CI should quarantine or demote a blocking test automatically when it exceeds the threshold over the configured window, open or update an owner issue, and require a fresh soak window before re-promotion.
|
||||
- A flaky test should be treated as a product or harness bug with an owner, not as background noise.
|
||||
- If a scenario cannot be made deterministic yet, keep it as soak/manual evidence and do not let it become required CI until the signal is trustworthy.
|
||||
|
||||
Deletion policy:
|
||||
|
||||
- Delete or demote tests that do not map to an invariant, duplicate a stronger gate, assert implementation details instead of behavior, only fail from timing/environment noise, or require blind sleeps.
|
||||
- Prefer one high-signal contract test over several UI tests that all exercise the same happy path.
|
||||
- Track regression coverage by invariant, not by raw test count.
|
||||
- Maintain a reliability-gates manifest in the repo. Each gate should list invariant id, motivating issue/PR, layer, owner, maturity, runtime budget, flake history link, promotion record, and replacement gate if deprecated. Tests without current metadata should fail review or be demoted.
|
||||
|
||||
How to know the program is working:
|
||||
|
||||
- Every recent P0/P1 reliability class maps to an invariant and an executable gate or explicitly accepted gap.
|
||||
- New gates carry red/green evidence.
|
||||
- Blocking gate flake rate and runtime stay within budget.
|
||||
- Future incidents identify either a missing invariant, a failed gate, or an explicit accepted gap instead of requiring archaeology.
|
||||
- Accepted gaps have an owner, reason, affected platforms/providers, issue link, and expiry date. Expired gaps should block future high-risk PRs until renewed or closed.
|
||||
- The dashboard tracks touched high-risk PRs, gates required, gates implemented, accepted gaps by age/owner, promotions, demotions, and escaped regressions by invariant.
|
||||
|
||||
### L. Make Performance and Git Crash Safety First-Class Gates
|
||||
|
||||
Correctness gates must include a performance budget when the fix touches terminal throughput, hidden panes, store subscribers, persistence, startup, polling, git/worktree scans, cleanup modals, subprocesses, SSH/WSL providers, or Windows git paths.
|
||||
|
||||
Required performance contract:
|
||||
|
||||
- Name the user-visible performance symptom that could regress: typing latency, scroll/resize jank, terminal throughput, startup delay, modal loading, CPU, memory, subprocess churn, or crash/reload.
|
||||
- Name the resource being protected: renderer frame time, event-loop delay, PTY IPC count, xterm writes, serialized bytes, subprocess count, file descriptors, WebGL contexts, webviews, or retained buffers.
|
||||
- Add a before/after measurement or deterministic count/leak test. Do not rely on "felt faster."
|
||||
- Keep foreground input echo and control responses latency-sensitive while batching background or high-volume redraw streams.
|
||||
- Prove hidden snapshot-capable panes do not wake the renderer per PTY chunk/title frame and restore from main-owned buffers when shown.
|
||||
- Preserve `orca terminal read` pagination, cursor metadata, bounded preview behavior, partial-line handling, truncation flags, and total counts when changing terminal buffering.
|
||||
- Prove subscriber, persistence, and publication paths avoid unchanged large slices and record serialized byte size when persisted payloads are touched.
|
||||
- Keep stress/torture runs non-blocking until they have numeric runtime and flake evidence.
|
||||
|
||||
Required git crash/perf contract:
|
||||
|
||||
- Bound every git subprocess in scan/cleanup paths with a timeout or abort signal, and kill the process tree where applicable.
|
||||
- Use bounded concurrency and in-flight dedupe; avoid one subprocess per repo x worktree x polling tick.
|
||||
- Stream progress incrementally to the renderer and remove IPC listeners in `finally`.
|
||||
- Verify close/reopen/refresh during active or just-settled scans clears loading state, preserves rows, and does not duplicate progress.
|
||||
- Recheck cleanup candidates immediately before destructive delete, including dirty files, unpushed commits, unknown upstream/base, folder repos, main worktrees, pinned workspaces, running terminals, disconnected SSH, and WSL paths.
|
||||
- Keep local, WSL, and SSH git providers at parity for timeouts, aborts, errors, and path handling.
|
||||
- On Windows-risky cleanup changes, validate a real Electron flow when feasible: progress appears before final completion, close/reopen recovers, a clean old worktree delete does not crash, and no orphan Electron/git process remains.
|
||||
|
||||
Promotion rule:
|
||||
|
||||
- A reliability fix cannot become a blocking gate if it makes the product slower or crashier. The promotion record must include the perf command or measurement, budget, result, platform/provider scope, and any accepted perf gap.
|
||||
|
||||
### M. Make the Factory Enforce Those Gates
|
||||
|
||||
The factory should turn the reliability gate into default behavior for planning, review, and merge-confidence testing. This should be treated as part of the prevention plan, not as a substitute for the actual test harnesses and CI gates above.
|
||||
|
||||
Factory changes to carry forward:
|
||||
|
||||
- `brennan-yolo-lite` should require a Reliability regression gate in the design doc whenever terminal, tab, session, provider, startup, upgrade, persistence, packaging, shell, path, or release behavior is touched.
|
||||
- The gate should force the agent to classify touched P0-capable surfaces: terminal lifecycle, tab creation/binding, dead-session reconciliation, hidden-to-visible resume, PTY identity/routing, PTY geometry including `0x0` quarantine, xterm addon boundaries, IME/paste/redraw behavior, local/daemon/SSH/remote/WSL/mobile provider contracts, attach/replay/resize/exit ordering, startup/upgrade persisted state, daemon survival, renderer/GPU crash containment, packaging/notarization, shell/PATH probing, terminal throughput, hidden-pane output, store subscriber hot paths, persistence payload size, git/worktree scan subprocess churn, cleanup modal progress, and Windows/WSL/SSH git crash risk.
|
||||
- Completeness verification should block when a touched P0-capable surface has no implemented or explicitly accepted reliability gate.
|
||||
- `review-code` should treat missing reliability tests or validation gates as findings for these surfaces, with the same review weight as lifecycle, provider, or cross-platform regressions.
|
||||
- `review-code` should invoke the `perf` skill for slow UI, terminal throughput, high CPU, memory/resource leaks, polling, subprocess churn, startup latency, render churn, or persistence writes.
|
||||
- `review-code` should invoke the `git-crash-perf` skill for worktree cleanup, git scans/status/listing, destructive removal, cleanup modal progress, SSH/WSL git providers, subprocess timeouts, or Windows git crash risk.
|
||||
- `brennan-test-changes` should run or explicitly justify the relevant terminal torture, provider contract, startup/upgrade, persisted-state, release, cross-platform, perf, or git crash/perf matrix subset.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
- Internal factory skill update PR: https://github.com/stablyai/orca-internal/pull/491
|
||||
- Scope of that PR: add the reliability gate to `brennan-yolo-lite` and expand `review-code` to inspect the recent terminal/session/provider/startup/release failure classes.
|
||||
- Follow-up needed: once the concrete repo-side harnesses exist, update the skills again to name the exact commands and required CI jobs instead of only naming the expected reliability evidence.
|
||||
|
||||
Additional factory follow-up:
|
||||
|
||||
- `auto-design-review-fix`: treat missing reliability gates for P0-capable surfaces as design findings; require inverse invariants for cleanup/reconcile/init code.
|
||||
- `review-code-fix-loop`: require the final loop report to name the touched reliability classes, invariant, gate, and whether both reviewers addressed them. A clean review that never discusses the required reliability class should not count as clean.
|
||||
- `brennan-test-changes`: add merge-confidence sub-gates for stale snapshot/newborn binding races, hidden-to-visible resume, attach/replay/exit ordering, old-version persisted state, provider/platform matrices, and render-live/input-live checks.
|
||||
- `pr-review`: classify P0-capable surfaces before review/fix and require the same invariant/test/platform matrix before approval.
|
||||
- `yolo-lite` and `electron`: require terminal/runtime validation to prove input, scroll, focus, process output, and relevant platform behavior, not only screenshots or visible frames.
|
||||
- `playwright-reliability-tests`: add a focused skill for writing and reviewing Playwright tests that are evidence-based, low-flake, and tied to named invariants, so browser/Electron tests do not become more noisy checklist coverage.
|
||||
- `perf`: require measurement-backed review for terminal throughput, hidden pane output, store subscribers, persistence, startup, polling, subprocess churn, resource leaks, and memory growth.
|
||||
- `git-crash-perf`: require bounded subprocesses, progress streaming, close/reopen recovery, fresh delete preflight, provider parity, and Windows Electron validation for git cleanup/worktree changes.
|
||||
|
||||
### N. Add Pane Lifecycle Telemetry and Diagnostics
|
||||
|
||||
Each terminal pane should be able to emit a compact lifecycle trace:
|
||||
|
||||
- pane id, tab id, worktree id
|
||||
- provider and connection id
|
||||
- pty id and binding epoch
|
||||
- visibility transitions
|
||||
- spawn/connect timestamps
|
||||
- resize measurements and forwarded sizes
|
||||
- liveness snapshot request/response times
|
||||
- teardown reason
|
||||
- hibernation/restore reason
|
||||
|
||||
This will make future reports much easier to attribute without guessing. It should also be test-consumable: CI should be able to fail on forbidden transitions, stale close attempts, stuck `0x0`, missing input echo, and startup watchdog expiry.
|
||||
|
||||
## Attribution Methodology
|
||||
|
||||
"Who caused it?" is not reliably answerable from labels alone. This review separates:
|
||||
|
||||
- **Confirmed**: the issue body or fix PR explicitly names the introducing PR/change.
|
||||
- **Likely**: the fix PR identifies the failing subsystem and local git history shows the regressing mechanism was introduced by a specific PR.
|
||||
- **Area ownership**: the same people are repeatedly fixing or modifying the subsystem, but there is not enough evidence to say they caused a given issue.
|
||||
- **Systemic**: no single introducer is evident; the problem is a missing invariant, provider contract, or test harness.
|
||||
|
||||
## Attribution Findings
|
||||
|
||||
### Confirmed Regression Chains
|
||||
|
||||
- [#5356](https://github.com/stablyai/orca/issues/5356), session loss on first launch after upgrading from 1.4.65 to 1.4.68, explicitly names PRs [#5234](https://github.com/stablyai/orca/pull/5234) and [#5240](https://github.com/stablyai/orca/pull/5240). Local git history shows both PRs were authored by Jinwoo Hong. Count: Jinwoo 1 confirmed open `P0` regression chain.
|
||||
- [#5319](https://github.com/stablyai/orca/issues/5319), Linux/Wayland terminal input freeze, is closed, but the fixing PR [#6557](https://github.com/stablyai/orca/pull/6557) says the Wayland-aware GPU path had been removed in [#1344](https://github.com/stablyai/orca/pull/1344). Local git history shows #1344 was authored by Neil. Count: Neil 1 confirmed closed `P1` regression chain.
|
||||
|
||||
### Likely Regression Chains
|
||||
|
||||
- [#6773](https://github.com/stablyai/orca/issues/6773), new terminal/agent tabs opening and immediately closing, was fixed by [#6796](https://github.com/stablyai/orca/pull/6796) and [#6801](https://github.com/stablyai/orca/pull/6801). Those PRs identify the dead-session reconciler as the cause. Local git history shows [#6514](https://github.com/stablyai/orca/pull/6514), which introduced the focused dead-session reconcile module for hidden exited panes, was authored by Brennan Benson. Count: Brennan 1 likely open `P0` regression chain.
|
||||
|
||||
### Area Ownership Without Clear Causation
|
||||
|
||||
The recent terminal reliability churn is concentrated among Brennan, Neil, and Jinwoo because they authored many of the fixes and nearby changes. That does not mean each issue was caused by them. It does mean the riskiest ownership surfaces are:
|
||||
|
||||
- Terminal lifecycle/reconcile/session persistence: Brennan, Neil, Jinwoo.
|
||||
- PTY size/layout/reflow: Neil, Jinjing, Jinwoo, Brennan.
|
||||
- Daemon/degraded spawn/focus/remote lifecycle: Neil, Jinwoo.
|
||||
- Windows terminal/shell/ConPTY/GPU recovery: Brennan, Neil, Jinwoo.
|
||||
- Agent status/sidebar/session identity: Brennan, Neil, Jinwoo, Jinjing.
|
||||
|
||||
### Current Conservative Count
|
||||
|
||||
For currently open `P0`/`P1` issues with a clear or likely PR-caused chain:
|
||||
|
||||
- Jinwoo: 1 confirmed open `P0` regression chain.
|
||||
- Brennan: 1 likely open `P0` regression chain.
|
||||
- Neil: 0 confirmed open `P0`/`P1` chains from the evidence pulled, plus 1 confirmed closed `P1` chain.
|
||||
|
||||
Most open `P0`/`P1` issues do not identify an introducing PR. Treating those as individual blame would be misleading. The stronger conclusion is that Orca has systemic reliability gaps around terminal/session lifecycle, provider contracts, PTY sizing, and upgrade/startup flows.
|
||||
|
||||
## Recommended Next Steps
|
||||
|
||||
Implementation plan: [`docs/reference/reliability-gates-implementation-plan.md`](./reliability-gates-implementation-plan.md).
|
||||
|
||||
1. Deduplicate and update `P0` issues #6873/#6874 and #6773 with the suspected fix status.
|
||||
2. Add narrow red/green gates for known escapes first: newborn liveness race, PTY `0x0`, xterm addon throw containment, old-version upgrade fixture, and Linux Wayland render-live/input-live smoke.
|
||||
3. Define test maturity levels, promotion records, numeric flake/demotion policy, and a reliability-gates manifest before making new reliability gates blocking.
|
||||
4. Build the fake/fault-injection provider contract kit, then run the same contract against local and daemon PTY before expanding to SSH, WSL, remote runtime, and mobile.
|
||||
5. Add upgrade/startup/release fixtures for pre-#5232 session data, stale daemon/helper paths, packaged app reveal/close, nested notarization, PATH probe timeout, and Windows terminal activation.
|
||||
6. Add the terminal lifecycle state machine design doc and implement it incrementally behind the known escaped-regression gates.
|
||||
7. Formalize the PTY size protocol and make `0x0` a quarantined/non-authoritative state for visible panes.
|
||||
8. Add the agent identity contract for pane reuse, worktree switch, sidebar/mobile rows, provider session ids, stale notifications, and ownership-safe provider session replay across active/inactive tabs, visible/hidden panes, repeated activation, and live/quit/worktree-sleep records.
|
||||
9. Add performance budgets and measurements to reliability gates that touch terminal throughput, hidden panes, store subscribers, persistence, startup, polling, subprocess churn, git/worktree scans, cleanup modals, SSH/WSL git providers, or Windows git crash risk.
|
||||
10. Gate terminal/session PRs behind the reliability checklist above, including perf and git crash/perf checks when those surfaces are touched.
|
||||
11. Keep the factory skill update as the enforcement layer, add the Playwright reliability-testing skill, and make `perf` plus `git-crash-perf` mandatory companion skills for relevant PRs; then revise companion skills once exact repo commands and CI job names exist.
|
||||
12. Add pane lifecycle traces so future issues can be attributed from evidence instead of archaeology.
|
||||
@@ -1,4 +1,5 @@
|
||||
node_modules/
|
||||
src/terminal/terminal-webview-engine.generated.ts
|
||||
.expo/
|
||||
dist/
|
||||
/android/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"extends": ["../.oxlintrc.json"],
|
||||
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
|
||||
"rules": {
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react/no-unescaped-entities": "off",
|
||||
@@ -35,7 +36,7 @@
|
||||
{
|
||||
"files": ["src/terminal/terminal-webview-html.ts"],
|
||||
"rules": {
|
||||
"max-lines": ["error", { "max": 1778, "skipBlankLines": true, "skipComments": true }]
|
||||
"max-lines": ["error", { "max": 1784, "skipBlankLines": true, "skipComments": true }]
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.20",
|
||||
"version": "0.0.22",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 2
|
||||
"versionCode": 4
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -7,6 +7,11 @@ export default function MobileFilePreviewRoute() {
|
||||
hostId?: string | string[]
|
||||
worktreeId?: string | string[]
|
||||
relativePath?: string | string[]
|
||||
source?: string | string[]
|
||||
absolutePath?: string | string[]
|
||||
grantId?: string | string[]
|
||||
line?: string | string[]
|
||||
column?: string | string[]
|
||||
name?: string | string[]
|
||||
worktreeName?: string | string[]
|
||||
}>()
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../../src/transport/rpc-client'
|
||||
import type { RuntimeTerminalPathResolution } from '../../../../../src/shared/runtime-types'
|
||||
import { loadHosts } from '../../../../src/transport/host-store'
|
||||
import {
|
||||
loadTerminalAutocompleteEnabled,
|
||||
@@ -97,9 +96,8 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
focusTerminalLiveInputTarget,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
@@ -166,9 +164,11 @@ import {
|
||||
} from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
@@ -789,6 +789,21 @@ function FileReader({
|
||||
return renderSourceText(doc.content)
|
||||
}
|
||||
|
||||
function updateTerminalCwdFromStreamEvent(
|
||||
handle: string,
|
||||
data: Record<string, unknown>,
|
||||
terminalCwd: Map<string, string>
|
||||
): void {
|
||||
if (!('cwd' in data)) {
|
||||
return
|
||||
}
|
||||
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
|
||||
terminalCwd.set(handle, data.cwd)
|
||||
return
|
||||
}
|
||||
terminalCwd.delete(handle)
|
||||
}
|
||||
|
||||
export default function SessionScreen() {
|
||||
const {
|
||||
hostId,
|
||||
@@ -881,11 +896,14 @@ export default function SessionScreen() {
|
||||
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
|
||||
useState<MobileTerminalLinkOpenMode>('orca-browser')
|
||||
const [liveInputCapture, setLiveInputCapture] = useState('')
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(null)
|
||||
@@ -977,6 +995,7 @@ export default function SessionScreen() {
|
||||
const terminalGestureInputBucketsRef = useRef<Map<string, TerminalGestureInputBucket>>(new Map())
|
||||
const terminalGestureInputQueuesRef = useRef<Map<string, TerminalGestureInputQueue>>(new Map())
|
||||
const terminalGestureInputInFlightRef = useRef<Set<string>>(new Set())
|
||||
const terminalCwdRef = useRef<Map<string, string>>(new Map())
|
||||
const initialModesSeenRef = useRef<Set<string>>(new Set())
|
||||
const deviceTokenRef = useRef<string | null>(null)
|
||||
const clientRef = useRef<RpcClient | null>(null)
|
||||
@@ -1088,7 +1107,6 @@ export default function SessionScreen() {
|
||||
sessionTabsRef.current = sessionTabs
|
||||
activeSessionTabIdRef.current = activeSessionTabId
|
||||
markdownDocsRef.current = markdownDocs
|
||||
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
|
||||
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
|
||||
createWarningState,
|
||||
initialCreateWarning
|
||||
@@ -1154,52 +1172,6 @@ export default function SessionScreen() {
|
||||
[clearToastHideTimer]
|
||||
)
|
||||
|
||||
// Why: direct input is now the mobile default, but only once per discovered
|
||||
// handle so a user's buffered-mode toggle survives tab/list refreshes.
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
handles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const dictation = useMobileDictation({
|
||||
client,
|
||||
enabled: canSend,
|
||||
@@ -1343,6 +1315,7 @@ export default function SessionScreen() {
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
terminalCwdRef.current.clear()
|
||||
setTerminalKeyboardMetrics(new Map())
|
||||
for (const term of terminalRefs.current.values()) {
|
||||
term.clear()
|
||||
@@ -1442,6 +1415,7 @@ export default function SessionScreen() {
|
||||
if (initializedHandlesRef.current.has(handle)) {
|
||||
return
|
||||
}
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const scrollbackCols = cols
|
||||
@@ -1537,7 +1511,10 @@ export default function SessionScreen() {
|
||||
}
|
||||
})()
|
||||
}
|
||||
} else if (data.type === 'metadata') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
} else if (data.type === 'data') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Why: log when data arrives but the WebView ref is missing
|
||||
// — this is the most likely cause of "blank but input works":
|
||||
// server stream is alive, sends flow, but writes are dropped
|
||||
@@ -1560,6 +1537,7 @@ export default function SessionScreen() {
|
||||
}
|
||||
dataRef.write(data.chunk as string)
|
||||
} else if (data.type === 'resized') {
|
||||
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
|
||||
// Why: inline resize event — the server changed the PTY dimensions
|
||||
// (mode toggle, desktop restore, or a width reflow). When the server
|
||||
// includes a fresh full-buffer snapshot (width reflow), reinitialize
|
||||
@@ -2663,9 +2641,6 @@ export default function SessionScreen() {
|
||||
setSessionTabs([])
|
||||
setActiveSessionTabId(null)
|
||||
clearPendingLiveInputCommit()
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
setMarkdownDocs(new Map())
|
||||
setFileDocs(new Map())
|
||||
clearDelayedActionTimers()
|
||||
@@ -3160,8 +3135,12 @@ export default function SessionScreen() {
|
||||
if (!canSend || !liveInputEnabled) {
|
||||
return
|
||||
}
|
||||
liveInputRef.current?.focus()
|
||||
}, [canSend, liveInputEnabled])
|
||||
focusTerminalLiveInputTarget(liveInputRef.current, {
|
||||
keyboardHeight,
|
||||
refocus: () =>
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
})
|
||||
}, [canSend, keyboardHeight, liveInputEnabled])
|
||||
|
||||
const clearSessionTabActionSheetKeyboardListener = useCallback(() => {
|
||||
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
|
||||
@@ -3242,82 +3221,42 @@ export default function SessionScreen() {
|
||||
// Tap on a file path in terminal output → resolve it on the host and open it
|
||||
// as a file tab (mirrors desktop Cmd/Ctrl-click). Silent on a miss; the
|
||||
// WebView only emits this when the tap landed on a detected path.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string) => {
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
if (handle !== activeHandleRef.current || !client) {
|
||||
return
|
||||
}
|
||||
void (async () => {
|
||||
try {
|
||||
const worktree = `id:${worktreeId}`
|
||||
const response = await client.sendRequest(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree, pathText },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory || !resolved.relativePath) {
|
||||
return
|
||||
}
|
||||
// Confirm the tap landed on something openable before giving feedback.
|
||||
triggerSelection()
|
||||
// Why: HTML opens in a browser pane (streamed from the desktop),
|
||||
// matching desktop's terminal-click behavior, instead of a file view.
|
||||
if (classifyMobileArtifact(resolved.relativePath) === 'html' && resolved.absolutePath) {
|
||||
void handleCreateBrowser('file://' + resolved.absolutePath)
|
||||
return
|
||||
}
|
||||
const openResponse = await client.sendRequest(
|
||||
'files.open',
|
||||
{ worktree, relativePath: resolved.relativePath },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!openResponse.ok) {
|
||||
return
|
||||
}
|
||||
// Why: the host opens the file as a markdown/file/image tab (the type
|
||||
// depends on the file — .md opens as a 'markdown' tab), and from a terminal
|
||||
// the active tab stays on the terminal. Once the new tab syncs in, switch to
|
||||
// it by relativePath across ANY openable type. Poll since it arrives async.
|
||||
const openedPath = resolved.relativePath
|
||||
// Why: retries poll for the async-arriving tab, but once activation lands
|
||||
// a later retry would steal focus back from the user — short-circuit the
|
||||
// remaining ones once the opened tab is (or becomes) the active tab.
|
||||
let activated = false
|
||||
const activateOpenedTab = async (): Promise<void> => {
|
||||
if (activated) {
|
||||
return
|
||||
}
|
||||
await fetchSessionTabs()
|
||||
if (activated) {
|
||||
return
|
||||
}
|
||||
const opened = sessionTabsRef.current.find(
|
||||
(tab): tab is Extract<MobileSessionTab, { relativePath?: string }> =>
|
||||
'relativePath' in tab && tab.relativePath === openedPath
|
||||
)
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
if (activeSessionTabIdRef.current === opened.id) {
|
||||
activated = true
|
||||
return
|
||||
}
|
||||
switchSessionTabRef.current?.(opened)
|
||||
activated = true
|
||||
}
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 300)
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 900)
|
||||
scheduleDelayedAction(() => void activateOpenedTab(), 1800)
|
||||
} catch {
|
||||
// Resolution/open is best-effort; a failed tap silently no-ops.
|
||||
}
|
||||
})()
|
||||
const activationSeq = ++handleFileTapActivationSeqRef.current
|
||||
openMobileTerminalFileTap<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => router.push(href),
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: handleFileTapActivationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: activeHandleRef.current,
|
||||
activeTabType: activeSessionTabTypeRef.current
|
||||
}),
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[client, worktreeId, scheduleDelayedAction, fetchSessionTabs]
|
||||
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
|
||||
)
|
||||
|
||||
const handleOpenedFileDiffActivationSeqRef = useRef(0)
|
||||
@@ -3380,17 +3319,7 @@ export default function SessionScreen() {
|
||||
if (!activeHandle) {
|
||||
return
|
||||
}
|
||||
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(activeHandle)
|
||||
} else {
|
||||
next.delete(activeHandle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
const nextEnabled = toggleTerminalLiveInput(activeHandle)
|
||||
clearPendingLiveInputCommit()
|
||||
if (nextEnabled) {
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
@@ -3398,7 +3327,7 @@ export default function SessionScreen() {
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
liveInputRef.current?.blur()
|
||||
}
|
||||
}, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles])
|
||||
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
|
||||
|
||||
const allowTerminalGestureInput = useCallback(
|
||||
(handle: string, sequenceCount: number): boolean => {
|
||||
@@ -4642,28 +4571,27 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.newTerminalButton,
|
||||
pressed && styles.newTerminalButtonPressed,
|
||||
(creating ||
|
||||
creatingBrowser ||
|
||||
creatingMarkdown ||
|
||||
connState !== 'connected') &&
|
||||
styles.newTerminalButtonDisabled
|
||||
]}
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
}
|
||||
onPress={() => {
|
||||
setCreateError('')
|
||||
setShowCreateTabDrawer(true)
|
||||
}}
|
||||
accessibilityLabel="New tab"
|
||||
>
|
||||
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</ScrollView>
|
||||
{/* Why: pinned outside the scroll strip so the new-agent button
|
||||
stays reachable no matter how far the tabs scroll. */}
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.newTerminalButton,
|
||||
pressed && styles.newTerminalButtonPressed,
|
||||
(creating || creatingBrowser || creatingMarkdown || connState !== 'connected') &&
|
||||
styles.newTerminalButtonDisabled
|
||||
]}
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
}
|
||||
onPress={() => {
|
||||
setCreateError('')
|
||||
setShowCreateTabDrawer(true)
|
||||
}}
|
||||
accessibilityLabel="New tab"
|
||||
>
|
||||
<Plus size={16} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -5023,10 +4951,16 @@ export default function SessionScreen() {
|
||||
{liveInputEnabled ? (
|
||||
<View style={[styles.inputBar, styles.liveInputBar]}>
|
||||
<Pressable
|
||||
style={styles.liveInputFocusTarget}
|
||||
style={({ pressed }) => [
|
||||
styles.liveInputFocusTarget,
|
||||
pressed && styles.liveInputFocusTargetPressed,
|
||||
!canSend && styles.liveInputFocusTargetDisabled
|
||||
]}
|
||||
disabled={!canSend}
|
||||
onPress={focusLiveInput}
|
||||
accessibilityLabel="Focus live terminal input"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Show keyboard for live terminal input"
|
||||
accessibilityHint="Typed text is sent directly to the active terminal"
|
||||
>
|
||||
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
|
||||
<MobileTerminalLiveInputStatus
|
||||
@@ -5057,6 +4991,7 @@ export default function SessionScreen() {
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
showSoftInputOnFocus
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -141,7 +141,20 @@ export const mobileSessionCommandInputStyles = StyleSheet.create({
|
||||
minHeight: 34,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.sm + 2
|
||||
},
|
||||
|
||||
liveInputFocusTargetPressed: {
|
||||
backgroundColor: colors.borderSubtle
|
||||
},
|
||||
|
||||
liveInputFocusTargetDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
|
||||
liveInputCapture: {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"start": "node scripts/start-expo.mjs",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
|
||||
"test": "vitest run",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --write .",
|
||||
@@ -18,6 +19,9 @@
|
||||
"dependencies": {
|
||||
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.285",
|
||||
"@xterm/addon-webgl": "0.20.0-beta.284",
|
||||
"@xterm/xterm": "6.1.0-beta.285",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.23",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
@@ -62,6 +66,8 @@
|
||||
"@types/react-native": "^0.73.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"acorn": "8.15.0",
|
||||
"esbuild": "0.25.4",
|
||||
"expo-module-scripts": "^55.0.2",
|
||||
"oxfmt": "^0.52.0",
|
||||
"oxlint": "^1.71.0",
|
||||
|
||||
Generated
+317
-47
@@ -14,6 +14,15 @@ importers:
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.285
|
||||
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.284
|
||||
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.285
|
||||
version: 6.1.0-beta.285
|
||||
buffer:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -141,9 +150,15 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
acorn:
|
||||
specifier: 8.15.0
|
||||
version: 8.15.0
|
||||
esbuild:
|
||||
specifier: 0.25.4
|
||||
version: 0.25.4
|
||||
expo-module-scripts:
|
||||
specifier: ^55.0.2
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
oxfmt:
|
||||
specifier: ^0.52.0
|
||||
version: 0.52.0
|
||||
@@ -161,10 +176,10 @@ importers:
|
||||
version: 5.9.3
|
||||
vite:
|
||||
specifier: ^8.0.16
|
||||
version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1157,126 +1172,252 @@ packages:
|
||||
'@emnapi/wasi-threads@1.2.2':
|
||||
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.4':
|
||||
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.25.4':
|
||||
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.25.4':
|
||||
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.25.4':
|
||||
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.25.4':
|
||||
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.25.4':
|
||||
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.25.4':
|
||||
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.4':
|
||||
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.4':
|
||||
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.4':
|
||||
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.25.4':
|
||||
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.25.4':
|
||||
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.4':
|
||||
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1289,24 +1430,48 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/sunos-x64@0.25.4':
|
||||
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.25.4':
|
||||
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.25.4':
|
||||
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.25.4':
|
||||
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
|
||||
engines: {node: '>=18'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1730,56 +1895,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-arm64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-gnu@0.52.0':
|
||||
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxfmt/binding-linux-x64-musl@0.52.0':
|
||||
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxfmt/binding-openharmony-arm64@0.52.0':
|
||||
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
|
||||
@@ -1852,56 +2009,48 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.71.0':
|
||||
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.71.0':
|
||||
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.71.0':
|
||||
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
|
||||
@@ -2363,42 +2512,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-arm64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-s390x-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-gnu@1.1.3':
|
||||
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/binding-linux-x64-musl@1.1.3':
|
||||
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/binding-openharmony-arm64@1.1.3':
|
||||
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
|
||||
@@ -2653,6 +2796,19 @@ packages:
|
||||
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
|
||||
engines: {node: '>=14.6'}
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.285':
|
||||
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.285
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.284':
|
||||
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
|
||||
peerDependencies:
|
||||
'@xterm/xterm': ^6.1.0-beta.285
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.285':
|
||||
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
|
||||
|
||||
abab@2.0.6:
|
||||
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
|
||||
deprecated: Use your platform's native atob() and btoa() methods instead
|
||||
@@ -2681,8 +2837,8 @@ packages:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
acorn@8.15.0:
|
||||
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3378,6 +3534,11 @@ packages:
|
||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
esbuild@0.25.4:
|
||||
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
esbuild@0.28.1:
|
||||
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4660,28 +4821,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -7675,81 +7832,156 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/aix-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openharmony-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.28.1':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.25.4':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.28.1':
|
||||
optional: true
|
||||
|
||||
@@ -9327,13 +9559,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
|
||||
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
@@ -9363,6 +9595,16 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.285
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.285
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.285': {}
|
||||
|
||||
abab@2.0.6: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
@@ -9381,7 +9623,7 @@ snapshots:
|
||||
|
||||
acorn-globals@7.0.1:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
acorn-walk: 8.3.5
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.17.0):
|
||||
@@ -9390,9 +9632,9 @@ snapshots:
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
acorn@8.15.0: {}
|
||||
|
||||
acorn@8.17.0: {}
|
||||
|
||||
@@ -10235,6 +10477,34 @@ snapshots:
|
||||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
esbuild@0.25.4:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.4
|
||||
'@esbuild/android-arm': 0.25.4
|
||||
'@esbuild/android-arm64': 0.25.4
|
||||
'@esbuild/android-x64': 0.25.4
|
||||
'@esbuild/darwin-arm64': 0.25.4
|
||||
'@esbuild/darwin-x64': 0.25.4
|
||||
'@esbuild/freebsd-arm64': 0.25.4
|
||||
'@esbuild/freebsd-x64': 0.25.4
|
||||
'@esbuild/linux-arm': 0.25.4
|
||||
'@esbuild/linux-arm64': 0.25.4
|
||||
'@esbuild/linux-ia32': 0.25.4
|
||||
'@esbuild/linux-loong64': 0.25.4
|
||||
'@esbuild/linux-mips64el': 0.25.4
|
||||
'@esbuild/linux-ppc64': 0.25.4
|
||||
'@esbuild/linux-riscv64': 0.25.4
|
||||
'@esbuild/linux-s390x': 0.25.4
|
||||
'@esbuild/linux-x64': 0.25.4
|
||||
'@esbuild/netbsd-arm64': 0.25.4
|
||||
'@esbuild/netbsd-x64': 0.25.4
|
||||
'@esbuild/openbsd-arm64': 0.25.4
|
||||
'@esbuild/openbsd-x64': 0.25.4
|
||||
'@esbuild/sunos-x64': 0.25.4
|
||||
'@esbuild/win32-arm64': 0.25.4
|
||||
'@esbuild/win32-ia32': 0.25.4
|
||||
'@esbuild/win32-x64': 0.25.4
|
||||
|
||||
esbuild@0.28.1:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.28.1
|
||||
@@ -10688,7 +10958,7 @@ snapshots:
|
||||
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
||||
expo-json-utils: 55.0.2
|
||||
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
|
||||
dependencies:
|
||||
'@babel/cli': 7.28.6(@babel/core@7.29.7)
|
||||
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
|
||||
@@ -10708,7 +10978,7 @@ snapshots:
|
||||
jest-snapshot-prettier: prettier@2.8.8
|
||||
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3))
|
||||
resolve-workspace-root: 2.0.1
|
||||
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
|
||||
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
@@ -11832,7 +12102,7 @@ snapshots:
|
||||
jsdom@20.0.3:
|
||||
dependencies:
|
||||
abab: 2.0.6
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
acorn-globals: 7.0.1
|
||||
cssom: 0.5.0
|
||||
cssstyle: 2.3.0
|
||||
@@ -13463,7 +13733,7 @@ snapshots:
|
||||
terser@5.46.2:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
acorn: 8.16.0
|
||||
acorn: 8.15.0
|
||||
commander: 2.20.3
|
||||
source-map-support: 0.5.21
|
||||
|
||||
@@ -13527,7 +13797,7 @@ snapshots:
|
||||
picomatch: 4.0.4
|
||||
typescript: 5.9.3
|
||||
|
||||
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
|
||||
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
|
||||
dependencies:
|
||||
bs-logger: 0.2.6
|
||||
fast-json-stable-stringify: 2.1.0
|
||||
@@ -13543,7 +13813,7 @@ snapshots:
|
||||
'@babel/core': 7.29.7
|
||||
'@jest/types': 29.6.3
|
||||
babel-jest: 29.7.0(@babel/core@7.29.7)
|
||||
esbuild: 0.28.1
|
||||
esbuild: 0.25.4
|
||||
|
||||
tsconfig-paths@3.15.0:
|
||||
dependencies:
|
||||
@@ -13700,7 +13970,7 @@ snapshots:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
|
||||
vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
@@ -13709,16 +13979,16 @@ snapshots:
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
esbuild: 0.28.1
|
||||
esbuild: 0.25.4
|
||||
fsevents: 2.3.3
|
||||
terser: 5.48.0
|
||||
tsx: 4.22.4
|
||||
yaml: 2.9.0
|
||||
|
||||
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
@@ -13735,7 +14005,7 @@ snapshots:
|
||||
tinyexec: 1.1.2
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as esbuild from 'esbuild'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const scriptDir = import.meta.dirname
|
||||
const mobileRoot = path.resolve(scriptDir, '..')
|
||||
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
|
||||
const target = 'chrome74'
|
||||
|
||||
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
|
||||
|
||||
async function readPackageVersion(packageName) {
|
||||
// Why: a package.json module specifier must use '/' — path.join emits '\' on
|
||||
// Windows, yielding an unresolvable bare specifier that fails postinstall there.
|
||||
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
||||
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
||||
return `${packageName}@${packageJson.version}`
|
||||
}
|
||||
|
||||
function htmlText(value, closingTag) {
|
||||
return value.replace(new RegExp(`</${closingTag}`, 'gi'), `<\\/${closingTag}`)
|
||||
}
|
||||
|
||||
async function buildEngineJs() {
|
||||
const result = await esbuild.build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
||||
import { WebglAddon } from '@xterm/addon-webgl'
|
||||
|
||||
// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
|
||||
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
|
||||
// chrome74-syntax bundle actually run on old WebViews (the #7030 goal)
|
||||
// instead of throwing at construction and only surfacing the error overlay.
|
||||
|
||||
// WeakRef (Chrome 84+): lazily constructed in window-tracking paths.
|
||||
// Strong retention is fine for a single-document terminal WebView.
|
||||
if (typeof window.WeakRef === 'undefined') {
|
||||
window.WeakRef = function WeakRefShim(target) { this.__target = target }
|
||||
window.WeakRef.prototype.deref = function () { return this.__target }
|
||||
}
|
||||
|
||||
// structuredClone (Chrome 98+): xterm clones its plain-data DEC-mode default
|
||||
// objects at Terminal construction; a JSON round-trip clones those correctly
|
||||
// (any undefined-valued keys drop, but every reader treats absent == undefined).
|
||||
if (typeof window.structuredClone === 'undefined') {
|
||||
window.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)) }
|
||||
}
|
||||
|
||||
// Element.prototype.replaceChildren (Chrome 86+): used on the row/selection
|
||||
// render path; polyfill via remove-all + append (Chrome 54+, under the floor).
|
||||
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
|
||||
Element.prototype.replaceChildren = function () {
|
||||
while (this.firstChild) this.removeChild(this.firstChild)
|
||||
this.append.apply(this, arguments)
|
||||
}
|
||||
}
|
||||
|
||||
window.Terminal = Terminal
|
||||
window.Unicode11Addon = { Unicode11Addon }
|
||||
window.WebglAddon = { WebglAddon }
|
||||
`,
|
||||
resolveDir: mobileRoot,
|
||||
sourcefile: 'terminal-webview-engine-entry.js'
|
||||
},
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
minify: true,
|
||||
platform: 'browser',
|
||||
target,
|
||||
legalComments: 'none',
|
||||
write: false,
|
||||
logLevel: 'silent'
|
||||
})
|
||||
|
||||
return result.outputFiles[0].text
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
|
||||
buildEngineJs(),
|
||||
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
|
||||
...packages.map(readPackageVersion)
|
||||
])
|
||||
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
|
||||
// terminal document. These xmlns URIs live inside data: URLs (never fetched);
|
||||
// percent-encoding the scheme colon satisfies the gate and URI-decodes back
|
||||
// before the SVG is parsed.
|
||||
const engineCss = rawEngineCss
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
|
||||
|
||||
const source = [
|
||||
'// Generated by scripts/build-terminal-webview-engine.mjs.',
|
||||
`// Packages: ${versions.join(', ')}.`,
|
||||
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
|
||||
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
|
||||
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
|
||||
''
|
||||
].join('\n')
|
||||
|
||||
await writeFile(outputPath, source)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -22,31 +22,12 @@ function fail(message) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, name) {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
fail(`${name} must be a positive integer`)
|
||||
}
|
||||
|
||||
const parsed = Number(value)
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
fail(`${name} must be a positive integer`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function validateSemver(version, name) {
|
||||
if (!semverPattern.test(version)) {
|
||||
fail(`${name} must use x.y.z format`)
|
||||
}
|
||||
}
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
validateSemver(version, 'Current mobile version')
|
||||
const [major, minor, patch] = version.split('.')
|
||||
return `${major}.${minor}.${Number(patch) + 1}`
|
||||
}
|
||||
|
||||
function writeOutput(name, value) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT
|
||||
if (!outputPath) {
|
||||
@@ -74,10 +55,6 @@ const tagVersion = githubRef.startsWith(androidTagRefPrefix)
|
||||
const requestedVersion = input('MOBILE_ANDROID_RELEASE_VERSION')
|
||||
const bumpPatch = truthy(input('MOBILE_ANDROID_BUMP_PATCH_VERSION'))
|
||||
|
||||
if (requestedVersion && bumpPatch) {
|
||||
fail('Use either MOBILE_ANDROID_RELEASE_VERSION or MOBILE_ANDROID_BUMP_PATCH_VERSION, not both')
|
||||
}
|
||||
|
||||
if (tagVersion) {
|
||||
validateSemver(tagVersion, 'Android release tag version')
|
||||
}
|
||||
@@ -86,43 +63,36 @@ if (requestedVersion) {
|
||||
validateSemver(requestedVersion, 'MOBILE_ANDROID_RELEASE_VERSION')
|
||||
}
|
||||
|
||||
if (tagVersion && requestedVersion && requestedVersion !== tagVersion) {
|
||||
fail('MOBILE_ANDROID_RELEASE_VERSION must match the mobile-android-v tag')
|
||||
if (bumpPatch) {
|
||||
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is no longer supported; commit mobile/app.json first')
|
||||
}
|
||||
|
||||
if (tagVersion && bumpPatch) {
|
||||
fail('MOBILE_ANDROID_BUMP_PATCH_VERSION is only supported for manual branch runs')
|
||||
if (tagVersion && tagVersion !== currentVersion) {
|
||||
fail('Android release tag version must match the committed mobile app version')
|
||||
}
|
||||
|
||||
const version =
|
||||
requestedVersion || tagVersion || (bumpPatch ? bumpPatchVersion(currentVersion) : currentVersion)
|
||||
if (requestedVersion && requestedVersion !== currentVersion) {
|
||||
fail('MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version')
|
||||
}
|
||||
|
||||
const requestedVersionCode = input('MOBILE_ANDROID_VERSION_CODE')
|
||||
const bumpVersionCode = truthy(input('MOBILE_ANDROID_BUMP_VERSION_CODE'))
|
||||
|
||||
if (requestedVersionCode && bumpVersionCode) {
|
||||
fail('Use either MOBILE_ANDROID_VERSION_CODE or MOBILE_ANDROID_BUMP_VERSION_CODE, not both')
|
||||
if (requestedVersionCode || bumpVersionCode) {
|
||||
// Why: Android rejects lower versionCode installs; release-only bumps leave
|
||||
// committed dev builds behind shipped APKs and break local testing.
|
||||
fail('Android versionCode changes must be committed in mobile/app.json before release')
|
||||
}
|
||||
|
||||
const versionCode = requestedVersionCode
|
||||
? parsePositiveInteger(requestedVersionCode, 'MOBILE_ANDROID_VERSION_CODE')
|
||||
: bumpVersionCode || version !== currentVersion
|
||||
? currentVersionCode + 1
|
||||
: currentVersionCode
|
||||
|
||||
expo.version = version
|
||||
android.versionCode = versionCode
|
||||
fs.writeFileSync(appConfigPath, `${JSON.stringify(config, null, 2)}\n`)
|
||||
|
||||
const tag = `mobile-android-v${version}`
|
||||
const tag = `mobile-android-v${currentVersion}`
|
||||
const publishRelease =
|
||||
githubRef.startsWith(androidTagRefPrefix) || truthy(input('MOBILE_ANDROID_PUBLISH_RELEASE'))
|
||||
|
||||
writeOutput('version', version)
|
||||
writeOutput('android_version_code', String(versionCode))
|
||||
writeOutput('version', currentVersion)
|
||||
writeOutput('android_version_code', String(currentVersionCode))
|
||||
writeOutput('tag', tag)
|
||||
writeOutput('publish_release', publishRelease ? 'true' : 'false')
|
||||
|
||||
console.log(`Prepared Orca Mobile Android ${version} (${versionCode})`)
|
||||
console.log(`Prepared Orca Mobile Android ${currentVersion} (${currentVersionCode})`)
|
||||
console.log(`Release tag: ${tag}`)
|
||||
console.log(`Publish GitHub Release: ${publishRelease ? 'yes' : 'no'}`)
|
||||
|
||||
@@ -92,9 +92,10 @@ export function MobileDiffReviewLine({
|
||||
)
|
||||
}
|
||||
|
||||
// Row height comes from the 18px code lineHeight alone (no vertical padding or
|
||||
// minHeight) so mobile diff density matches the desktop diff editor (STA-1239).
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
minHeight: 32,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'stretch',
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
@@ -112,25 +113,24 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
prefix: {
|
||||
width: 18,
|
||||
paddingTop: spacing.sm,
|
||||
textAlign: 'center',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.metaSize
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18
|
||||
},
|
||||
lineNumber: {
|
||||
width: 44,
|
||||
paddingTop: spacing.sm,
|
||||
paddingRight: spacing.xs,
|
||||
textAlign: 'right',
|
||||
color: colors.textMuted,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.metaSize
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18
|
||||
},
|
||||
code: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.sm
|
||||
},
|
||||
codePressed: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Pressable, ScrollView, View } from 'react-native'
|
||||
import { Code, Pencil } from 'lucide-react-native'
|
||||
import { MobileMarkdown } from '../components/MobileMarkdown'
|
||||
@@ -14,10 +14,20 @@ type Props = {
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
initialLine?: number
|
||||
}
|
||||
|
||||
export function MobileFileMarkdownPreview({ relativePath, content, truncated, byteLength }: Props) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview')
|
||||
export function MobileFileMarkdownPreview({
|
||||
relativePath,
|
||||
content,
|
||||
truncated,
|
||||
byteLength,
|
||||
initialLine
|
||||
}: Props) {
|
||||
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
|
||||
useEffect(() => {
|
||||
setMode(initialLine ? 'source' : 'preview')
|
||||
}, [initialLine, relativePath])
|
||||
const previewSelected = mode === 'preview'
|
||||
const sourceSelected = mode === 'source'
|
||||
|
||||
@@ -62,6 +72,7 @@ export function MobileFileMarkdownPreview({ relativePath, content, truncated, by
|
||||
content={content}
|
||||
truncated={truncated}
|
||||
byteLength={byteLength}
|
||||
initialLine={initialLine}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ActivityIndicator, Image, Pressable, ScrollView, Text, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { MobileFilePreviewResult } from './mobile-file-preview-request'
|
||||
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
|
||||
import { MobileFilePreviewEditableSource } from './MobileFilePreviewEditableSource'
|
||||
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
|
||||
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
preview: MobileFilePreviewResult
|
||||
relativePath: string
|
||||
title: string
|
||||
editable: boolean
|
||||
draftContent: string
|
||||
saveError: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
onDraftChange: (content: string) => void
|
||||
onImageError: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
|
||||
export function MobileFilePreviewBody({ preview, ...options }: Props) {
|
||||
if (preview.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>{preview.message}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.status === 'error' || preview.status === 'waiting') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{preview.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={options.onRetry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.status === 'empty') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Empty file</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<View style={styles.imageContainer}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.imageScrollContent}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
source={{ uri: preview.dataUri }}
|
||||
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
|
||||
resizeMode="contain"
|
||||
onError={options.onImageError}
|
||||
accessibilityLabel={`${options.title} image`}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'markdown') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<MobileFileMarkdownPreview
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (preview.kind === 'html') {
|
||||
return options.editable ? (
|
||||
<EditablePreviewSource {...options} />
|
||||
) : (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (options.editable) {
|
||||
return <EditablePreviewSource {...options} />
|
||||
}
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
initialLine={options.lineColumn?.line}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function EditablePreviewSource(options: {
|
||||
title: string
|
||||
draftContent: string
|
||||
saveError: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
onDraftChange: (content: string) => void
|
||||
}) {
|
||||
return (
|
||||
<MobileFilePreviewEditableSource
|
||||
title={options.title}
|
||||
draftContent={options.draftContent}
|
||||
saveError={options.saveError}
|
||||
lineColumn={options.lineColumn}
|
||||
onDraftChange={options.onDraftChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Text, TextInput, View } from 'react-native'
|
||||
import type { MobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import { textOffsetForLineColumn } from './mobile-file-preview-line-column'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
draftContent: string
|
||||
lineColumn: MobileFilePreviewLineColumn | null
|
||||
saveError?: string
|
||||
onDraftChange: (content: string) => void
|
||||
}
|
||||
|
||||
export function MobileFilePreviewEditableSource({
|
||||
title,
|
||||
draftContent,
|
||||
lineColumn,
|
||||
saveError,
|
||||
onDraftChange
|
||||
}: Props) {
|
||||
const selectionTargetKey = lineColumn
|
||||
? `${title}:${lineColumn.line}:${lineColumn.column ?? ''}`
|
||||
: ''
|
||||
const [selection, setSelection] = useState<{ start: number; end: number } | null>(null)
|
||||
const [revealedTargetKey, setRevealedTargetKey] = useState('')
|
||||
|
||||
// Why: line/column opens should reveal once, then user cursor movement owns selection.
|
||||
useEffect(() => {
|
||||
if (!lineColumn || !selectionTargetKey || revealedTargetKey === selectionTargetKey) {
|
||||
return
|
||||
}
|
||||
const offset = textOffsetForLineColumn(draftContent, lineColumn)
|
||||
const initialSelection = { start: offset, end: offset }
|
||||
setSelection(initialSelection)
|
||||
setRevealedTargetKey(selectionTargetKey)
|
||||
}, [draftContent, lineColumn, revealedTargetKey, selectionTargetKey])
|
||||
|
||||
return (
|
||||
<View style={styles.editContainer}>
|
||||
{saveError ? <Text style={styles.saveErrorText}>{saveError}</Text> : null}
|
||||
<TextInput
|
||||
style={styles.editInput}
|
||||
value={draftContent}
|
||||
onChangeText={onDraftChange}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
selection={selection ?? undefined}
|
||||
onSelectionChange={() => setSelection(null)}
|
||||
accessibilityLabel={`${title} editor`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import {
|
||||
hasUnsavedMobileTerminalArtifactDraft,
|
||||
isEditableMobileTerminalArtifactPreview,
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult
|
||||
} from './mobile-file-preview-editability'
|
||||
|
||||
describe('MobileFilePreviewScreen', () => {
|
||||
it('treats empty terminal artifact text previews as editable', () => {
|
||||
expect(isEditableMobileTerminalArtifactPreview({ status: 'empty', kind: 'text' })).toBe(true)
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: '',
|
||||
truncated: false,
|
||||
byteLength: 0
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: 'data:image/png;base64,aW1n'
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('treats truncated terminal artifact text previews as read-only', () => {
|
||||
expect(
|
||||
isEditableMobileTerminalArtifactPreview({
|
||||
status: 'ready',
|
||||
kind: 'text',
|
||||
content: 'partial',
|
||||
truncated: true,
|
||||
byteLength: 1024
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts protected while preview is waiting for reconnect', () => {
|
||||
const sourceKey = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
|
||||
expect(
|
||||
hasUnsavedMobileTerminalArtifactDraft({
|
||||
source: 'terminalArtifact',
|
||||
draftSourceKey: sourceKey,
|
||||
previewSourceKey: sourceKey,
|
||||
draftContent: '{"ok":false}',
|
||||
savedContent: '{"ok":true}'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps dirty terminal artifact drafts when a reload fails or waits', () => {
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
|
||||
status: 'error',
|
||||
message: 'Unable to reach desktop filesystem',
|
||||
reconnect: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(true, {
|
||||
status: 'waiting',
|
||||
message: 'Waiting for desktop...',
|
||||
reconnect: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult(false, {
|
||||
status: 'error',
|
||||
message: 'Unable to load preview',
|
||||
reconnect: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Text,
|
||||
View,
|
||||
useWindowDimensions
|
||||
} from 'react-native'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Alert, BackHandler, Pressable, Text, View, useWindowDimensions } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { ChevronLeft, Save } from 'lucide-react-native'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { useForceReconnect, useHostClient } from '../transport/client-context'
|
||||
import {
|
||||
loadMobileFilePreview,
|
||||
previewError,
|
||||
saveMobileTerminalArtifactPreview,
|
||||
type MobileFilePreviewSource,
|
||||
type MobileFilePreviewResult
|
||||
} from './mobile-file-preview-request'
|
||||
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
|
||||
import { MobileFilePreviewSourceText } from './MobileFilePreviewSourceText'
|
||||
import { MobileFilePreviewBody } from './MobileFilePreviewBody'
|
||||
import {
|
||||
displayNameFromPreviewPath,
|
||||
type MobileFilePreviewRouteState
|
||||
} from './mobile-file-preview-route'
|
||||
import { previewSourceFromRoute, sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
import { normalizeMobileFilePreviewLineColumn } from './mobile-file-preview-line-column'
|
||||
import {
|
||||
hasUnsavedMobileTerminalArtifactDraft,
|
||||
isEditableMobileTerminalArtifactPreview,
|
||||
shouldKeepDirtyDraftOnPreviewLoadResult
|
||||
} from './mobile-file-preview-editability'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
type Props = {
|
||||
@@ -39,30 +39,116 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
const [preview, setPreview] = useState<MobileFilePreviewResult>(() =>
|
||||
route.ok ? { status: 'loading', message: 'Loading preview...' } : previewError(route.message)
|
||||
)
|
||||
const [draftContent, setDraftContent] = useState('')
|
||||
const [savedContent, setSavedContent] = useState('')
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const draftContentRef = useRef(draftContent)
|
||||
const savedContentRef = useRef(savedContent)
|
||||
const draftSourceKeyRef = useRef<string | null>(null)
|
||||
const { width, height } = useWindowDimensions()
|
||||
const routePreviewSource = useMemo(
|
||||
() => (previewParams ? previewSourceFromRoute(previewParams) : null),
|
||||
[previewParams]
|
||||
)
|
||||
const [previewSource, setPreviewSource] = useState<MobileFilePreviewSource | null>(
|
||||
routePreviewSource
|
||||
)
|
||||
const previewSourceKey = useMemo(() => sourceKeyForPreview(previewSource), [previewSource])
|
||||
const routePreviewSourceKey = useMemo(
|
||||
() => sourceKeyForPreview(routePreviewSource),
|
||||
[routePreviewSource]
|
||||
)
|
||||
const previewSourceKeyRef = useRef(previewSourceKey)
|
||||
const lineColumn = useMemo(
|
||||
() =>
|
||||
previewParams
|
||||
? normalizeMobileFilePreviewLineColumn(previewParams.line, previewParams.column)
|
||||
: null,
|
||||
[previewParams]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewSource(routePreviewSource)
|
||||
draftSourceKeyRef.current = null
|
||||
}, [routePreviewSource])
|
||||
|
||||
useEffect(() => {
|
||||
previewSourceKeyRef.current = previewSourceKey
|
||||
}, [previewSourceKey])
|
||||
|
||||
useEffect(() => {
|
||||
draftContentRef.current = draftContent
|
||||
}, [draftContent])
|
||||
|
||||
useEffect(() => {
|
||||
savedContentRef.current = savedContent
|
||||
}, [savedContent])
|
||||
|
||||
const loadPreview = useCallback(async () => {
|
||||
if (!previewParams) {
|
||||
const loadSourceKey = previewSourceKey
|
||||
if (!previewParams || !previewSource || loadSourceKey !== routePreviewSourceKey) {
|
||||
setPreview(previewError(route.ok ? 'Unable to load preview' : route.message))
|
||||
return
|
||||
}
|
||||
const preserveDirtyDraft =
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
draftContentRef.current !== savedContentRef.current
|
||||
if (!client || connState !== 'connected') {
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError('Waiting for desktop...')
|
||||
return
|
||||
}
|
||||
setPreview({ status: 'waiting', message: 'Waiting for desktop...', reconnect: true })
|
||||
return
|
||||
}
|
||||
setPreview({ status: 'loading', message: 'Loading preview...' })
|
||||
if (!preserveDirtyDraft) {
|
||||
setPreview({ status: 'loading', message: 'Loading preview...' })
|
||||
}
|
||||
setSaveError('')
|
||||
try {
|
||||
const result = await loadMobileFilePreview(
|
||||
client,
|
||||
previewParams.worktreeId,
|
||||
previewParams.relativePath
|
||||
)
|
||||
const result = await loadMobileFilePreview(client, previewSource, undefined, {
|
||||
onTerminalArtifactSourceRefreshed: setPreviewSource,
|
||||
refreshGrant: true
|
||||
})
|
||||
if (previewSourceKeyRef.current !== loadSourceKey) {
|
||||
return
|
||||
}
|
||||
if (shouldKeepDirtyDraftOnPreviewLoadResult(preserveDirtyDraft, result)) {
|
||||
setSaveError(result.message)
|
||||
return
|
||||
}
|
||||
const loadedContent =
|
||||
result.status === 'ready' && result.kind !== 'image'
|
||||
? result.content
|
||||
: result.status === 'empty'
|
||||
? ''
|
||||
: null
|
||||
if (loadedContent !== null) {
|
||||
if (!preserveDirtyDraft) {
|
||||
setDraftContent(loadedContent)
|
||||
setSavedContent(loadedContent)
|
||||
}
|
||||
draftSourceKeyRef.current = previewSourceKey
|
||||
}
|
||||
setPreview(result)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unable to load preview'
|
||||
if (preserveDirtyDraft) {
|
||||
setSaveError(message)
|
||||
return
|
||||
}
|
||||
setPreview(previewError(message))
|
||||
}
|
||||
}, [client, connState, previewParams, route])
|
||||
}, [
|
||||
client,
|
||||
connState,
|
||||
previewParams,
|
||||
previewSource,
|
||||
previewSourceKey,
|
||||
route,
|
||||
routePreviewSourceKey
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
void loadPreview()
|
||||
@@ -84,12 +170,70 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
void loadPreview()
|
||||
}, [connState, forceReconnect, loadPreview, preview, previewParams])
|
||||
|
||||
const title = previewParams?.name ?? displayNameFromPreviewPath(previewParams?.relativePath ?? '')
|
||||
const displayPath =
|
||||
previewParams?.source === 'terminalArtifact'
|
||||
? (previewParams.absolutePath ?? '')
|
||||
: (previewParams?.relativePath ?? '')
|
||||
const title = previewParams?.name ?? displayNameFromPreviewPath(displayPath)
|
||||
const worktreeLabel = getWorktreeLabel(
|
||||
previewParams?.worktreeName,
|
||||
previewParams?.worktreeId ?? ''
|
||||
)
|
||||
const meta = previewParams ? `${worktreeLabel} - ${previewParams.relativePath}` : 'Preview'
|
||||
const meta = previewParams ? `${worktreeLabel} - ${displayPath}` : 'Preview'
|
||||
const isEditableTerminalArtifact =
|
||||
previewSource?.source === 'terminalArtifact' && isEditableMobileTerminalArtifactPreview(preview)
|
||||
const canSaveArtifact =
|
||||
isEditableTerminalArtifact &&
|
||||
draftSourceKeyRef.current === previewSourceKey &&
|
||||
draftContent !== savedContent
|
||||
const hasUnsavedTerminalArtifactDraft = hasUnsavedMobileTerminalArtifactDraft({
|
||||
source: previewSource?.source,
|
||||
draftSourceKey: draftSourceKeyRef.current,
|
||||
previewSourceKey,
|
||||
draftContent,
|
||||
savedContent
|
||||
})
|
||||
|
||||
const saveArtifact = useCallback(async () => {
|
||||
if (!client || previewSource?.source !== 'terminalArtifact' || !canSaveArtifact || saving) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
const result = await saveMobileTerminalArtifactPreview(client, previewSource, draftContent, {
|
||||
baseContent: savedContent,
|
||||
onTerminalArtifactSourceRefreshed: setPreviewSource
|
||||
})
|
||||
if (result.status === 'saved') {
|
||||
setSavedContent(draftContent)
|
||||
} else {
|
||||
setSaveError(saveErrorMessageFromPreviewResult(result))
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unable to save file'
|
||||
setSaveError(message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [canSaveArtifact, client, draftContent, previewSource, savedContent, saving])
|
||||
|
||||
const requestBack = useCallback(() => {
|
||||
if (!hasUnsavedTerminalArtifactDraft) {
|
||||
router.back()
|
||||
return true
|
||||
}
|
||||
Alert.alert('Discard changes?', 'Unsaved edits will be lost.', [
|
||||
{ text: 'Stay', style: 'cancel' },
|
||||
{ text: 'Discard', style: 'destructive', onPress: () => router.back() }
|
||||
])
|
||||
return true
|
||||
}, [hasUnsavedTerminalArtifactDraft, router])
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', requestBack)
|
||||
return () => subscription.remove()
|
||||
}, [requestBack])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -97,7 +241,7 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
onPress={requestBack}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back to files"
|
||||
>
|
||||
@@ -111,110 +255,40 @@ export function MobileFilePreviewScreen({ route }: Props) {
|
||||
{meta}
|
||||
</Text>
|
||||
</View>
|
||||
{isEditableTerminalArtifact ? (
|
||||
<Pressable
|
||||
style={[styles.saveButton, (!canSaveArtifact || saving) && styles.saveButtonDisabled]}
|
||||
onPress={() => void saveArtifact()}
|
||||
disabled={!canSaveArtifact || saving}
|
||||
accessibilityLabel="Save terminal artifact"
|
||||
>
|
||||
<Save size={18} color={colors.textPrimary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
{renderPreviewBody(preview, {
|
||||
relativePath: previewParams?.relativePath ?? '',
|
||||
title: title || 'File',
|
||||
imageWidth: Math.max(1, width - spacing.md * 2),
|
||||
imageHeight: Math.max(240, height - 160),
|
||||
onImageError: () =>
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false }),
|
||||
onRetry: retry
|
||||
})}
|
||||
<MobileFilePreviewBody
|
||||
preview={preview}
|
||||
relativePath={displayPath}
|
||||
title={title || 'File'}
|
||||
editable={isEditableTerminalArtifact}
|
||||
draftContent={draftContent}
|
||||
saveError={saveError}
|
||||
lineColumn={lineColumn}
|
||||
imageWidth={Math.max(1, width - spacing.md * 2)}
|
||||
imageHeight={Math.max(240, height - 160)}
|
||||
onDraftChange={setDraftContent}
|
||||
onImageError={() =>
|
||||
setPreview({ status: 'error', message: 'Unable to load preview', reconnect: false })
|
||||
}
|
||||
onRetry={retry}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewBody(
|
||||
preview: MobileFilePreviewResult,
|
||||
options: {
|
||||
relativePath: string
|
||||
title: string
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
onImageError: () => void
|
||||
onRetry: () => void
|
||||
}
|
||||
) {
|
||||
if (preview.status === 'loading') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<Text style={styles.stateText}>{preview.message}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.status === 'error' || preview.status === 'waiting') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.errorText}>{preview.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={options.onRetry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.status === 'empty') {
|
||||
return (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateText}>Empty file</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'image') {
|
||||
return (
|
||||
<View style={styles.imageContainer}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.imageScrollContent}
|
||||
maximumZoomScale={4}
|
||||
minimumZoomScale={1}
|
||||
centerContent
|
||||
>
|
||||
<Image
|
||||
source={{ uri: preview.dataUri }}
|
||||
style={[styles.image, { width: options.imageWidth, height: options.imageHeight }]}
|
||||
resizeMode="contain"
|
||||
onError={options.onImageError}
|
||||
accessibilityLabel={`${options.title} image`}
|
||||
/>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'markdown') {
|
||||
return (
|
||||
<MobileFileMarkdownPreview
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (preview.kind === 'html') {
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MobileFilePreviewSourceText
|
||||
relativePath={options.relativePath}
|
||||
content={preview.content}
|
||||
truncated={preview.truncated}
|
||||
byteLength={preview.byteLength}
|
||||
/>
|
||||
)
|
||||
function saveErrorMessageFromPreviewResult(result: MobileFilePreviewResult): string {
|
||||
return result.status === 'error' || result.status === 'waiting'
|
||||
? result.message
|
||||
: 'Unable to save file'
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { ScrollView, Text } from 'react-native'
|
||||
import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments'
|
||||
import { formatPreviewByteLength } from './mobile-file-preview-request'
|
||||
import { scrollOffsetForPreviewLine } from './mobile-file-preview-line-column'
|
||||
import { buildMobileFilePreviewSyntax } from './mobile-file-preview-syntax'
|
||||
import { filePreviewStyles as styles } from './mobile-file-preview-styles'
|
||||
|
||||
@@ -9,19 +10,44 @@ export function MobileFilePreviewSourceText({
|
||||
relativePath,
|
||||
content,
|
||||
truncated,
|
||||
byteLength
|
||||
byteLength,
|
||||
initialLine
|
||||
}: {
|
||||
relativePath: string
|
||||
content: string
|
||||
truncated?: boolean
|
||||
byteLength?: number
|
||||
initialLine?: number
|
||||
}) {
|
||||
const scrollRef = useRef<ScrollView>(null)
|
||||
const revealedRef = useRef(false)
|
||||
const syntax = useMemo(
|
||||
() => buildMobileFilePreviewSyntax(relativePath, content),
|
||||
[content, relativePath]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
revealedRef.current = false
|
||||
}, [content, initialLine, relativePath])
|
||||
|
||||
const revealInitialLine = () => {
|
||||
if (!initialLine || revealedRef.current) {
|
||||
return
|
||||
}
|
||||
revealedRef.current = true
|
||||
scrollRef.current?.scrollTo({
|
||||
y: scrollOffsetForPreviewLine(initialLine),
|
||||
animated: false
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.scroll} contentContainerStyle={styles.textContent}>
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={styles.textContent}
|
||||
onContentSizeChange={revealInitialLine}
|
||||
>
|
||||
{truncated ? (
|
||||
<MobileFilePreviewTruncatedNote byteLength={byteLength ?? content.length} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
MobileFilePreviewResult,
|
||||
MobileFilePreviewSource
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
export function isEditableMobileTerminalArtifactPreview(preview: MobileFilePreviewResult): boolean {
|
||||
return (
|
||||
(preview.status === 'ready' && preview.kind !== 'image' && !preview.truncated) ||
|
||||
preview.status === 'empty'
|
||||
)
|
||||
}
|
||||
|
||||
export function hasUnsavedMobileTerminalArtifactDraft({
|
||||
source,
|
||||
draftSourceKey,
|
||||
previewSourceKey,
|
||||
draftContent,
|
||||
savedContent
|
||||
}: {
|
||||
source?: MobileFilePreviewSource['source']
|
||||
draftSourceKey: string | null
|
||||
previewSourceKey: string | null
|
||||
draftContent: string
|
||||
savedContent: string
|
||||
}): boolean {
|
||||
return (
|
||||
source === 'terminalArtifact' &&
|
||||
draftSourceKey === previewSourceKey &&
|
||||
draftContent !== savedContent
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldKeepDirtyDraftOnPreviewLoadResult(
|
||||
preserveDirtyDraft: boolean,
|
||||
result: MobileFilePreviewResult
|
||||
): result is Extract<MobileFilePreviewResult, { status: 'error' | 'waiting' }> {
|
||||
return preserveDirtyDraft && (result.status === 'error' || result.status === 'waiting')
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeMobileFilePreviewLineColumn,
|
||||
scrollOffsetForPreviewLine,
|
||||
textOffsetForLineColumn
|
||||
} from './mobile-file-preview-line-column'
|
||||
|
||||
describe('mobile-file-preview-line-column', () => {
|
||||
it('normalizes positive route params and rejects invalid lines', () => {
|
||||
expect(normalizeMobileFilePreviewLineColumn('12', '3')).toEqual({ line: 12, column: 3 })
|
||||
expect(normalizeMobileFilePreviewLineColumn('12', undefined)).toEqual({
|
||||
line: 12,
|
||||
column: null
|
||||
})
|
||||
expect(normalizeMobileFilePreviewLineColumn('0', '3')).toBeNull()
|
||||
expect(normalizeMobileFilePreviewLineColumn('12x', '3')).toBeNull()
|
||||
expect(normalizeMobileFilePreviewLineColumn(undefined, '3')).toBeNull()
|
||||
})
|
||||
|
||||
it('converts one-based line and column values to a text offset', () => {
|
||||
const content = 'alpha\nbravo\ncharlie'
|
||||
|
||||
expect(textOffsetForLineColumn(content, { line: 1, column: 1 })).toBe(0)
|
||||
expect(textOffsetForLineColumn(content, { line: 2, column: 3 })).toBe(8)
|
||||
expect(textOffsetForLineColumn(content, { line: 3, column: null })).toBe(12)
|
||||
})
|
||||
|
||||
it('caps out-of-range line and column values safely', () => {
|
||||
const content = 'alpha\nbravo'
|
||||
|
||||
expect(textOffsetForLineColumn(content, { line: 99, column: 1 })).toBe(content.length)
|
||||
expect(textOffsetForLineColumn(content, { line: 1, column: 99 })).toBe(5)
|
||||
})
|
||||
|
||||
it('maps line numbers to the mono preview scroll offset', () => {
|
||||
expect(scrollOffsetForPreviewLine(1)).toBe(0)
|
||||
expect(scrollOffsetForPreviewLine(4)).toBe(57)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
export type MobileFilePreviewLineColumn = {
|
||||
line: number
|
||||
column: number | null
|
||||
}
|
||||
|
||||
const MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT = 19
|
||||
|
||||
export function normalizeMobileFilePreviewLineColumn(
|
||||
line: string | undefined,
|
||||
column: string | undefined
|
||||
): MobileFilePreviewLineColumn | null {
|
||||
const normalizedLine = parsePositiveInteger(line)
|
||||
if (normalizedLine === null) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
line: normalizedLine,
|
||||
column: parsePositiveInteger(column)
|
||||
}
|
||||
}
|
||||
|
||||
export function textOffsetForLineColumn(
|
||||
content: string,
|
||||
target: MobileFilePreviewLineColumn
|
||||
): number {
|
||||
let lineStart = 0
|
||||
for (let currentLine = 1; currentLine < target.line; currentLine += 1) {
|
||||
const nextBreak = content.indexOf('\n', lineStart)
|
||||
if (nextBreak === -1) {
|
||||
return content.length
|
||||
}
|
||||
lineStart = nextBreak + 1
|
||||
}
|
||||
|
||||
const lineEnd = content.indexOf('\n', lineStart)
|
||||
const cappedLineEnd = lineEnd === -1 ? content.length : lineEnd
|
||||
const columnOffset = Math.max(0, (target.column ?? 1) - 1)
|
||||
return Math.min(cappedLineEnd, lineStart + columnOffset)
|
||||
}
|
||||
|
||||
export function scrollOffsetForPreviewLine(line: number): number {
|
||||
return Math.max(0, line - 1) * MOBILE_FILE_PREVIEW_TEXT_LINE_HEIGHT
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string | undefined): number | null {
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : null
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
createMobileFilePreviewRequest,
|
||||
formatPreviewByteLength,
|
||||
loadMobileFilePreview,
|
||||
normalizeMobileFilePreviewResponse
|
||||
normalizeMobileFilePreviewResponse,
|
||||
saveMobileTerminalArtifactPreview
|
||||
} from './mobile-file-preview-request'
|
||||
|
||||
function ok(result: unknown): RpcSuccess {
|
||||
@@ -21,6 +22,12 @@ function clientWith(response: RpcResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
function clientWithResponses(responses: RpcResponse[]) {
|
||||
return {
|
||||
sendRequest: vi.fn(async () => responses.shift()!)
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile-file-preview-request', () => {
|
||||
it('selects readPreview for raster images and read for text-like files', () => {
|
||||
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
|
||||
@@ -54,6 +61,471 @@ describe('mobile-file-preview-request', () => {
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('loads terminal artifacts through grant-scoped artifact RPCs', async () => {
|
||||
const client = clientWith(ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }))
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(client, {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.read', expect.anything())
|
||||
})
|
||||
|
||||
it('refreshes an expired terminal artifact grant and retries the read once', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
undefined,
|
||||
{ onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a stale terminal artifact grant and retries the read once', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 })
|
||||
])
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(client, {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
})
|
||||
).resolves.toMatchObject({ status: 'ready', kind: 'text', content: '{"ok":true}' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['terminal_file_grant_expired'],
|
||||
['terminal_file_grant_mismatch'],
|
||||
['terminal_file_grant_stale']
|
||||
])(
|
||||
'does not refresh a %s terminal artifact grant when disabled for a dirty editor',
|
||||
async (error) => {
|
||||
const client = clientWithResponses([fail(error)])
|
||||
|
||||
await expect(
|
||||
loadMobileFilePreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
},
|
||||
undefined,
|
||||
{ refreshGrant: false }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('saves terminal artifacts through the same exact-path grant', async () => {
|
||||
const client = clientWith(ok({ ok: true }))
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}'
|
||||
)
|
||||
).resolves.toEqual({ status: 'saved' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not refresh and retry a failed terminal artifact save without a base content check', async () => {
|
||||
const client = clientWithResponses([fail('terminal_file_grant_mismatch')])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}'
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes an expired terminal artifact grant and retries save when the file still matches the base content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":true}', truncated: false, byteLength: 11 }),
|
||||
ok({ ok: true })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({ status: 'saved' })
|
||||
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(2, 'files.resolveTerminalPath', {
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
terminal: 'term-1'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(3, 'files.readTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
})
|
||||
expect(client.sendRequest).toHaveBeenNthCalledWith(4, 'files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).toHaveBeenCalledWith({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the dirty draft when a refreshed terminal artifact save finds changed remote content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ content: '{"ok":"changed"}', truncated: false, byteLength: 16 })
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'File changed on desktop. Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed refreshed artifact read instead of treating it as changed desktop content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
fail('provider unavailable')
|
||||
])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}' }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Unable to reach the desktop filesystem',
|
||||
reconnect: true
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a malformed refreshed artifact read instead of treating it as changed desktop content', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_stale'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
}),
|
||||
ok({ truncated: false, byteLength: 11 })
|
||||
])
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}' }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Unable to load preview',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(3)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.writeTerminalArtifact', {
|
||||
worktree: 'id:wt-1',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-2',
|
||||
content: '{"ok":false}'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not retry a dirty save when grant refresh resolves to a different artifact', async () => {
|
||||
const client = clientWithResponses([
|
||||
fail('terminal_file_grant_expired'),
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/b.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
absolutePath: '/tmp/b.json',
|
||||
grantId: 'grant-2'
|
||||
}
|
||||
})
|
||||
])
|
||||
const onTerminalArtifactSourceRefreshed = vi.fn()
|
||||
|
||||
await expect(
|
||||
saveMobileTerminalArtifactPreview(
|
||||
client,
|
||||
{
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt-1',
|
||||
absolutePath: '/tmp/a.json',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'term-1',
|
||||
pathText: '/tmp/link.json'
|
||||
},
|
||||
'{"ok":false}',
|
||||
{ baseContent: '{"ok":true}', onTerminalArtifactSourceRefreshed }
|
||||
)
|
||||
).resolves.toEqual({
|
||||
status: 'error',
|
||||
message: 'Reload preview before saving',
|
||||
reconnect: false
|
||||
})
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(onTerminalArtifactSourceRefreshed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
|
||||
['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
|
||||
@@ -116,6 +588,7 @@ describe('mobile-file-preview-request', () => {
|
||||
true
|
||||
],
|
||||
['provider unavailable', 'Unable to reach the desktop filesystem', true],
|
||||
['terminal_file_grant_stale', 'Reload preview before saving', false],
|
||||
['permission denied', 'Unable to load preview', false]
|
||||
])('maps preview failure %s', (message, expected, reconnect) => {
|
||||
expect(normalizeMobileFilePreviewResponse('src/app.ts', fail(message))).toEqual({
|
||||
|
||||
@@ -1,195 +1,256 @@
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import type { RpcFailure, RpcResponse } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import {
|
||||
normalizeMobileFilePreviewResponse,
|
||||
previewError,
|
||||
type MobileFilePreviewResult
|
||||
} from './mobile-file-preview-response'
|
||||
import {
|
||||
refreshTerminalArtifactSourceAfterGrantFailure,
|
||||
type MobileTerminalArtifactPreviewSource,
|
||||
type TerminalArtifactRetryOptions
|
||||
} from './mobile-terminal-artifact-grant-refresh'
|
||||
|
||||
export {
|
||||
formatPreviewByteLength,
|
||||
normalizeMobileFilePreviewResponse,
|
||||
previewError
|
||||
} from './mobile-file-preview-response'
|
||||
export type {
|
||||
MobileFilePreviewResult,
|
||||
MobileFilePreviewTextKind
|
||||
} from './mobile-file-preview-response'
|
||||
|
||||
export type MobileFilePreviewReadMethod = 'files.read' | 'files.readPreview'
|
||||
export type MobileTerminalArtifactPreviewReadMethod =
|
||||
| 'files.readTerminalArtifact'
|
||||
| 'files.readTerminalArtifactPreview'
|
||||
|
||||
export type MobileFilePreviewSource =
|
||||
| {
|
||||
source: 'worktree'
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
}
|
||||
| MobileTerminalArtifactPreviewSource
|
||||
|
||||
export type MobileFilePreviewRequest = {
|
||||
method: MobileFilePreviewReadMethod
|
||||
method: MobileFilePreviewReadMethod | MobileTerminalArtifactPreviewReadMethod
|
||||
params: {
|
||||
worktree: string
|
||||
relativePath: string
|
||||
relativePath?: string
|
||||
absolutePath?: string
|
||||
grantId?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
|
||||
|
||||
export type MobileFilePreviewResult =
|
||||
| {
|
||||
status: 'loading'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'waiting'
|
||||
message: string
|
||||
reconnect: true
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: 'image'
|
||||
dataUri: string
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: MobileFilePreviewTextKind
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
| {
|
||||
status: 'empty'
|
||||
kind: MobileFilePreviewTextKind
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
reconnect: boolean
|
||||
}
|
||||
|
||||
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
|
||||
type TerminalArtifactSource = MobileTerminalArtifactPreviewSource
|
||||
type TerminalArtifactSaveOptions = TerminalArtifactRetryOptions & {
|
||||
baseContent?: string
|
||||
}
|
||||
|
||||
export function createMobileFilePreviewRequest(
|
||||
worktreeId: string,
|
||||
relativePath: string
|
||||
worktreeIdOrSource: string | MobileFilePreviewSource,
|
||||
relativePath?: string
|
||||
): MobileFilePreviewRequest {
|
||||
const source =
|
||||
typeof worktreeIdOrSource === 'string'
|
||||
? { source: 'worktree' as const, worktreeId: worktreeIdOrSource, relativePath: relativePath! }
|
||||
: worktreeIdOrSource
|
||||
if (source.source === 'terminalArtifact') {
|
||||
const method =
|
||||
classifyMobileArtifact(source.absolutePath) === 'image'
|
||||
? 'files.readTerminalArtifactPreview'
|
||||
: 'files.readTerminalArtifact'
|
||||
return {
|
||||
method,
|
||||
params: {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
absolutePath: source.absolutePath,
|
||||
grantId: source.grantId
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
method: classifyMobileArtifact(relativePath) === 'image' ? 'files.readPreview' : 'files.read',
|
||||
method:
|
||||
classifyMobileArtifact(source.relativePath) === 'image' ? 'files.readPreview' : 'files.read',
|
||||
params: {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
relativePath: source.relativePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMobileFilePreview(
|
||||
client: MobileFilePreviewClient,
|
||||
worktreeId: string,
|
||||
relativePath: string
|
||||
worktreeIdOrSource: string | MobileFilePreviewSource,
|
||||
relativePath?: string,
|
||||
options: TerminalArtifactRetryOptions = {}
|
||||
): Promise<MobileFilePreviewResult> {
|
||||
const request = createMobileFilePreviewRequest(worktreeId, relativePath)
|
||||
const response = await client.sendRequest(request.method, request.params)
|
||||
return normalizeMobileFilePreviewResponse(relativePath, response)
|
||||
let source = worktreeIdOrSource
|
||||
let request = createMobileFilePreviewRequest(source, relativePath)
|
||||
let response = await client.sendRequest(request.method, request.params)
|
||||
if (!response.ok && typeof source !== 'string' && source.source === 'terminalArtifact') {
|
||||
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
source,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (refreshed) {
|
||||
source = refreshed
|
||||
options.onTerminalArtifactSourceRefreshed?.(refreshed)
|
||||
request = createMobileFilePreviewRequest(source, relativePath)
|
||||
response = await client.sendRequest(request.method, request.params)
|
||||
}
|
||||
}
|
||||
const previewPath = typeof source === 'string' ? relativePath! : previewPathForSource(source)
|
||||
return normalizeMobileFilePreviewResponse(previewPath, response)
|
||||
}
|
||||
|
||||
export function normalizeMobileFilePreviewResponse(
|
||||
relativePath: string,
|
||||
response: RpcResponse
|
||||
): MobileFilePreviewResult {
|
||||
export async function saveMobileTerminalArtifactPreview(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
content: string,
|
||||
options: TerminalArtifactSaveOptions = {}
|
||||
): Promise<MobileFilePreviewResult | { status: 'saved' }> {
|
||||
let writeSource = source
|
||||
if (typeof options.baseContent === 'string') {
|
||||
const verified = await verifyTerminalArtifactBaseContent(
|
||||
client,
|
||||
writeSource,
|
||||
options.baseContent,
|
||||
options
|
||||
)
|
||||
if (verified.status === 'error') {
|
||||
return verified.error
|
||||
}
|
||||
writeSource = verified.source
|
||||
if (verified.refreshed) {
|
||||
options.onTerminalArtifactSourceRefreshed?.(verified.source)
|
||||
}
|
||||
}
|
||||
let response = await writeTerminalArtifactPreview(client, writeSource, content)
|
||||
if (response.ok) {
|
||||
return { status: 'saved' }
|
||||
}
|
||||
|
||||
if (typeof options.baseContent !== 'string') {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
const refreshed = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
writeSource,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (!refreshed) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
const verified = await verifyTerminalArtifactBaseContent(client, refreshed, options.baseContent, {
|
||||
refreshGrant: false
|
||||
})
|
||||
if (verified.status === 'error') {
|
||||
return verified.error
|
||||
}
|
||||
options.onTerminalArtifactSourceRefreshed?.(refreshed)
|
||||
writeSource = verified.source
|
||||
response = await writeTerminalArtifactPreview(client, writeSource, content)
|
||||
if (!response.ok) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
|
||||
const result = (response as RpcSuccess).result
|
||||
if (classifyMobileArtifact(relativePath) === 'image') {
|
||||
return normalizeImagePreviewResult(result)
|
||||
}
|
||||
return normalizeTextPreviewResult(relativePath, result)
|
||||
return { status: 'saved' }
|
||||
}
|
||||
|
||||
export function previewError(message: string): MobileFilePreviewResult {
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
|
||||
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
|
||||
async function verifyTerminalArtifactBaseContent(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
baseContent: string,
|
||||
options: TerminalArtifactRetryOptions
|
||||
): Promise<
|
||||
| { status: 'ok'; source: TerminalArtifactSource; refreshed: boolean }
|
||||
| { status: 'error'; error: MobileFilePreviewResult }
|
||||
> {
|
||||
let readSource = source
|
||||
let request = createMobileFilePreviewRequest(readSource)
|
||||
let response = await client.sendRequest(request.method, request.params)
|
||||
let refreshed = false
|
||||
if (!response.ok) {
|
||||
const nextSource = await refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client,
|
||||
readSource,
|
||||
response,
|
||||
options
|
||||
)
|
||||
if (!nextSource) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
}
|
||||
readSource = nextSource
|
||||
refreshed = true
|
||||
request = createMobileFilePreviewRequest(readSource)
|
||||
response = await client.sendRequest(request.method, request.params)
|
||||
}
|
||||
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
|
||||
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
|
||||
if (!response.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
normalized.includes('remote connection dropped') ||
|
||||
normalized.includes('provider unavailable') ||
|
||||
normalized.includes('disconnected')
|
||||
) {
|
||||
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
|
||||
const latest = normalizeMobileFilePreviewResponse(readSource.absolutePath, response)
|
||||
if (latest.status === 'error' || latest.status === 'waiting') {
|
||||
return { status: 'error', error: latest }
|
||||
}
|
||||
if (
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('no such file') ||
|
||||
normalized.includes('not found') ||
|
||||
normalized.includes('does not exist')
|
||||
) {
|
||||
return { status: 'error', message: 'File not found', reconnect: false }
|
||||
if (!terminalArtifactPreviewMatchesBase(latest, baseContent)) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: {
|
||||
status: 'error',
|
||||
message: 'File changed on desktop. Reload preview before saving',
|
||||
reconnect: false
|
||||
}
|
||||
}
|
||||
}
|
||||
return { status: 'error', message: 'Unable to load preview', reconnect: false }
|
||||
return { status: 'ok', source: readSource, refreshed }
|
||||
}
|
||||
|
||||
export function formatPreviewByteLength(byteLength: number): string {
|
||||
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (byteLength < 1024) {
|
||||
return `${byteLength} B`
|
||||
}
|
||||
if (byteLength < 1024 * 1024) {
|
||||
return `${Math.round(byteLength / 1024)} KB`
|
||||
}
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
|
||||
function writeTerminalArtifactPreview(
|
||||
client: MobileFilePreviewClient,
|
||||
source: TerminalArtifactSource,
|
||||
content: string
|
||||
): Promise<RpcResponse> {
|
||||
return client.sendRequest('files.writeTerminalArtifact', {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
absolutePath: source.absolutePath,
|
||||
grantId: source.grantId,
|
||||
content
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
isBinary?: unknown
|
||||
isImage?: unknown
|
||||
mimeType?: unknown
|
||||
}
|
||||
if (
|
||||
preview.isBinary !== true ||
|
||||
preview.isImage !== true ||
|
||||
typeof preview.mimeType !== 'string' ||
|
||||
preview.mimeType.length === 0 ||
|
||||
typeof preview.content !== 'string' ||
|
||||
preview.content.length === 0
|
||||
) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:${preview.mimeType};base64,${preview.content}`
|
||||
function terminalArtifactPreviewMatchesBase(
|
||||
preview: MobileFilePreviewResult,
|
||||
baseContent: string
|
||||
): boolean {
|
||||
if (preview.status === 'empty') {
|
||||
return baseContent.length === 0
|
||||
}
|
||||
return preview.status === 'ready' && preview.kind !== 'image' && preview.content === baseContent
|
||||
}
|
||||
|
||||
function normalizeTextPreviewResult(
|
||||
relativePath: string,
|
||||
result: unknown
|
||||
): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
truncated?: unknown
|
||||
byteLength?: unknown
|
||||
isBinary?: unknown
|
||||
}
|
||||
if (preview.isBinary === true) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
if (typeof preview.content !== 'string') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const kind = textKindForPreviewPath(relativePath)
|
||||
if (preview.content.length === 0) {
|
||||
return { status: 'empty', kind }
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind,
|
||||
content: preview.content,
|
||||
truncated: preview.truncated === true,
|
||||
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
|
||||
}
|
||||
}
|
||||
|
||||
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
|
||||
if (classifyMobileArtifact(relativePath) === 'html') {
|
||||
return 'html'
|
||||
}
|
||||
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
|
||||
function previewPathForSource(source: MobileFilePreviewSource): string {
|
||||
return source.source === 'terminalArtifact' ? source.absolutePath : source.relativePath
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isMarkdownPath } from './file-tree'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
|
||||
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
|
||||
|
||||
export type MobileFilePreviewResult =
|
||||
| {
|
||||
status: 'loading'
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
status: 'waiting'
|
||||
message: string
|
||||
reconnect: true
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: 'image'
|
||||
dataUri: string
|
||||
}
|
||||
| {
|
||||
status: 'ready'
|
||||
kind: MobileFilePreviewTextKind
|
||||
content: string
|
||||
truncated: boolean
|
||||
byteLength: number
|
||||
}
|
||||
| {
|
||||
status: 'empty'
|
||||
kind: MobileFilePreviewTextKind
|
||||
}
|
||||
| {
|
||||
status: 'error'
|
||||
message: string
|
||||
reconnect: boolean
|
||||
}
|
||||
|
||||
export function normalizeMobileFilePreviewResponse(
|
||||
relativePath: string,
|
||||
response: RpcResponse
|
||||
): MobileFilePreviewResult {
|
||||
if (!response.ok) {
|
||||
return previewError(
|
||||
(response as RpcFailure).error.message || (response as RpcFailure).error.code
|
||||
)
|
||||
}
|
||||
|
||||
const result = (response as RpcSuccess).result
|
||||
if (classifyMobileArtifact(relativePath) === 'image') {
|
||||
return normalizeImagePreviewResult(result)
|
||||
}
|
||||
return normalizeTextPreviewResult(relativePath, result)
|
||||
}
|
||||
|
||||
export function previewError(message: string): MobileFilePreviewResult {
|
||||
const normalized = message.toLowerCase()
|
||||
if (normalized === 'binary_file' || normalized.includes('binary_file')) {
|
||||
return { status: 'error', message: 'Binary preview unavailable', reconnect: false }
|
||||
}
|
||||
if (normalized === 'file_too_large' || normalized.includes('file_too_large')) {
|
||||
return { status: 'error', message: 'File too large for mobile preview', reconnect: false }
|
||||
}
|
||||
if (isTerminalArtifactGrantError(normalized)) {
|
||||
return { status: 'error', message: 'Reload preview before saving', reconnect: false }
|
||||
}
|
||||
if (
|
||||
normalized.includes('remote connection dropped') ||
|
||||
normalized.includes('provider unavailable') ||
|
||||
normalized.includes('disconnected') ||
|
||||
normalized.includes('reconnect the ssh target')
|
||||
) {
|
||||
return { status: 'error', message: 'Unable to reach the desktop filesystem', reconnect: true }
|
||||
}
|
||||
if (
|
||||
normalized.includes('enoent') ||
|
||||
normalized.includes('no such file') ||
|
||||
normalized.includes('not found') ||
|
||||
normalized.includes('does not exist')
|
||||
) {
|
||||
return { status: 'error', message: 'File not found', reconnect: false }
|
||||
}
|
||||
return { status: 'error', message: 'Unable to load preview', reconnect: false }
|
||||
}
|
||||
|
||||
export function formatPreviewByteLength(byteLength: number): string {
|
||||
if (!Number.isFinite(byteLength) || byteLength < 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (byteLength < 1024) {
|
||||
return `${byteLength} B`
|
||||
}
|
||||
if (byteLength < 1024 * 1024) {
|
||||
return `${Math.round(byteLength / 1024)} KB`
|
||||
}
|
||||
return `${(byteLength / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
isBinary?: unknown
|
||||
isImage?: unknown
|
||||
mimeType?: unknown
|
||||
}
|
||||
if (
|
||||
preview.isBinary !== true ||
|
||||
preview.isImage !== true ||
|
||||
typeof preview.mimeType !== 'string' ||
|
||||
preview.mimeType.length === 0 ||
|
||||
typeof preview.content !== 'string' ||
|
||||
preview.content.length === 0
|
||||
) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind: 'image',
|
||||
dataUri: `data:${preview.mimeType};base64,${preview.content}`
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTextPreviewResult(
|
||||
relativePath: string,
|
||||
result: unknown
|
||||
): MobileFilePreviewResult {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const preview = result as {
|
||||
content?: unknown
|
||||
truncated?: unknown
|
||||
byteLength?: unknown
|
||||
isBinary?: unknown
|
||||
}
|
||||
if (preview.isBinary === true) {
|
||||
return previewError('binary_file')
|
||||
}
|
||||
if (typeof preview.content !== 'string') {
|
||||
return previewError('Unable to load preview')
|
||||
}
|
||||
const kind = textKindForPreviewPath(relativePath)
|
||||
if (preview.content.length === 0) {
|
||||
return { status: 'empty', kind }
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
kind,
|
||||
content: preview.content,
|
||||
truncated: preview.truncated === true,
|
||||
byteLength: typeof preview.byteLength === 'number' ? preview.byteLength : preview.content.length
|
||||
}
|
||||
}
|
||||
|
||||
function textKindForPreviewPath(relativePath: string): MobileFilePreviewTextKind {
|
||||
if (classifyMobileArtifact(relativePath) === 'html') {
|
||||
return 'html'
|
||||
}
|
||||
return isMarkdownPath(relativePath) ? 'markdown' : 'text'
|
||||
}
|
||||
@@ -22,12 +22,70 @@ describe('mobile-file-preview-route', () => {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath,
|
||||
source: 'worktree',
|
||||
line: undefined,
|
||||
column: undefined,
|
||||
name: 'note.md',
|
||||
worktreeName: 'Orca'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes worktree preview line and column params', () => {
|
||||
const route = normalizeMobileFilePreviewRouteParams({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'src/app.ts',
|
||||
line: '120',
|
||||
column: '7'
|
||||
})
|
||||
|
||||
expect(route).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
relativePath: 'src/app.ts',
|
||||
source: 'worktree',
|
||||
line: '120',
|
||||
column: '7',
|
||||
name: undefined,
|
||||
worktreeName: undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes terminal artifact params without requiring a relative path', () => {
|
||||
const route = normalizeMobileFilePreviewRouteParams({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
|
||||
expect(route).toEqual({
|
||||
ok: true,
|
||||
params: {
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
terminal: 'term-1',
|
||||
pathText: 'result.json',
|
||||
cwd: '/tmp/run',
|
||||
line: '12',
|
||||
column: '3'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects missing, empty, or array-valued required params', () => {
|
||||
expect(normalizeMobileFilePreviewRouteParams({ hostId: 'h', worktreeId: 'w' })).toEqual({
|
||||
ok: false,
|
||||
|
||||
@@ -3,7 +3,15 @@ export type MobileFilePreviewParamValue = string | string[] | undefined
|
||||
export type MobileFilePreviewRouteParams = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
relativePath?: string
|
||||
source?: 'worktree' | 'terminalArtifact'
|
||||
absolutePath?: string
|
||||
grantId?: string
|
||||
terminal?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
line?: string
|
||||
column?: string
|
||||
name?: string
|
||||
worktreeName?: string
|
||||
}
|
||||
@@ -21,6 +29,14 @@ type RawPreviewRouteParams = {
|
||||
hostId?: MobileFilePreviewParamValue
|
||||
worktreeId?: MobileFilePreviewParamValue
|
||||
relativePath?: MobileFilePreviewParamValue
|
||||
source?: MobileFilePreviewParamValue
|
||||
absolutePath?: MobileFilePreviewParamValue
|
||||
grantId?: MobileFilePreviewParamValue
|
||||
terminal?: MobileFilePreviewParamValue
|
||||
pathText?: MobileFilePreviewParamValue
|
||||
cwd?: MobileFilePreviewParamValue
|
||||
line?: MobileFilePreviewParamValue
|
||||
column?: MobileFilePreviewParamValue
|
||||
name?: MobileFilePreviewParamValue
|
||||
worktreeName?: MobileFilePreviewParamValue
|
||||
}
|
||||
@@ -39,7 +55,35 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
const hostId = singleParam(params.hostId)
|
||||
const worktreeId = singleParam(params.worktreeId)
|
||||
const relativePath = singleParam(params.relativePath)
|
||||
if (!hostId || !worktreeId || !relativePath) {
|
||||
const source = singleParam(params.source)
|
||||
const absolutePath = singleParam(params.absolutePath)
|
||||
const grantId = singleParam(params.grantId)
|
||||
if (!hostId || !worktreeId) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
if (source === 'terminalArtifact') {
|
||||
if (!absolutePath || !grantId) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
params: {
|
||||
hostId,
|
||||
worktreeId,
|
||||
source,
|
||||
absolutePath,
|
||||
grantId,
|
||||
terminal: optionalSingleParam(params.terminal),
|
||||
pathText: optionalSingleParam(params.pathText),
|
||||
cwd: optionalSingleParam(params.cwd),
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
worktreeName: optionalSingleParam(params.worktreeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!relativePath) {
|
||||
return { ok: false, message: 'Unable to load preview' }
|
||||
}
|
||||
return {
|
||||
@@ -48,6 +92,9 @@ export function normalizeMobileFilePreviewRouteParams(
|
||||
hostId,
|
||||
worktreeId,
|
||||
relativePath,
|
||||
source: 'worktree',
|
||||
line: optionalSingleParam(params.line),
|
||||
column: optionalSingleParam(params.column),
|
||||
name: optionalSingleParam(params.name),
|
||||
worktreeName: optionalSingleParam(params.worktreeName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sourceKeyForPreview } from './mobile-file-preview-source'
|
||||
|
||||
describe('mobile-file-preview-source', () => {
|
||||
it('uses structured preview keys so colons in paths cannot collide', () => {
|
||||
const first = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt:1',
|
||||
absolutePath: '/tmp/a',
|
||||
grantId: 'grant-1',
|
||||
terminalHandle: 'b:c'
|
||||
})
|
||||
const second = sourceKeyForPreview({
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: 'wt',
|
||||
absolutePath: '1:/tmp/a:b',
|
||||
grantId: 'grant-2',
|
||||
terminalHandle: 'c'
|
||||
})
|
||||
|
||||
expect(first).toBe(JSON.stringify(['terminal', 'wt:1', '/tmp/a', 'b:c']))
|
||||
expect(second).toBe(JSON.stringify(['terminal', 'wt', '1:/tmp/a:b', 'c']))
|
||||
expect(first).not.toBe(second)
|
||||
expect(
|
||||
sourceKeyForPreview({ source: 'worktree', worktreeId: 'wt:1', relativePath: 'a:b.ts' })
|
||||
).toBe(JSON.stringify(['worktree', 'wt:1', 'a:b.ts']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { MobileFilePreviewRouteParams } from './mobile-file-preview-route'
|
||||
import type { MobileFilePreviewSource } from './mobile-file-preview-request'
|
||||
|
||||
export function previewSourceFromRoute(
|
||||
params: MobileFilePreviewRouteParams
|
||||
): MobileFilePreviewSource | null {
|
||||
if (params.source === 'terminalArtifact') {
|
||||
if (!params.absolutePath || !params.grantId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: params.worktreeId,
|
||||
absolutePath: params.absolutePath,
|
||||
grantId: params.grantId,
|
||||
...(params.terminal ? { terminalHandle: params.terminal } : {}),
|
||||
...(params.pathText ? { pathText: params.pathText } : {}),
|
||||
...(params.cwd ? { cwd: params.cwd } : {})
|
||||
}
|
||||
}
|
||||
if (!params.relativePath) {
|
||||
return null
|
||||
}
|
||||
return { source: 'worktree', worktreeId: params.worktreeId, relativePath: params.relativePath }
|
||||
}
|
||||
|
||||
export function sourceKeyForPreview(source: MobileFilePreviewSource | null): string | null {
|
||||
if (!source) {
|
||||
return null
|
||||
}
|
||||
return source.source === 'terminalArtifact'
|
||||
? JSON.stringify([
|
||||
'terminal',
|
||||
source.worktreeId,
|
||||
source.absolutePath,
|
||||
source.terminalHandle ?? ''
|
||||
])
|
||||
: JSON.stringify(['worktree', source.worktreeId, source.relativePath])
|
||||
}
|
||||
@@ -73,6 +73,17 @@ export const filePreviewStyles = StyleSheet.create({
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
saveButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.42
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.editorSurface
|
||||
@@ -136,5 +147,23 @@ export const filePreviewStyles = StyleSheet.create({
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.editorSurface
|
||||
},
|
||||
editContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.editorSurface,
|
||||
padding: spacing.md
|
||||
},
|
||||
saveErrorText: {
|
||||
marginBottom: spacing.sm,
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
editInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: 13,
|
||||
lineHeight: 19,
|
||||
padding: 0
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
|
||||
|
||||
type MobileFilePreviewClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
export type MobileTerminalArtifactPreviewSource = {
|
||||
source: 'terminalArtifact'
|
||||
worktreeId: string
|
||||
absolutePath: string
|
||||
grantId: string
|
||||
terminalHandle?: string
|
||||
pathText?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export type TerminalArtifactRetryOptions = {
|
||||
onTerminalArtifactSourceRefreshed?: (source: MobileTerminalArtifactPreviewSource) => void
|
||||
refreshGrant?: boolean
|
||||
}
|
||||
|
||||
export async function refreshTerminalArtifactSourceAfterGrantFailure(
|
||||
client: MobileFilePreviewClient,
|
||||
source: MobileTerminalArtifactPreviewSource,
|
||||
response: RpcResponse,
|
||||
options: TerminalArtifactRetryOptions = {}
|
||||
): Promise<MobileTerminalArtifactPreviewSource | null> {
|
||||
if (response.ok || !isTerminalArtifactGrantFailure(response, options)) {
|
||||
return null
|
||||
}
|
||||
const refreshed = await client.sendRequest('files.resolveTerminalPath', {
|
||||
worktree: `id:${source.worktreeId}`,
|
||||
pathText: source.pathText ?? source.absolutePath,
|
||||
...(source.cwd ? { cwd: source.cwd } : {}),
|
||||
...(source.terminalHandle ? { terminal: source.terminalHandle } : {})
|
||||
})
|
||||
if (!refreshed.ok) {
|
||||
return null
|
||||
}
|
||||
const result = (refreshed as RpcSuccess).result
|
||||
if (!isTerminalArtifactResolution(result)) {
|
||||
return null
|
||||
}
|
||||
if (result.openTarget.absolutePath !== source.absolutePath) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
source: 'terminalArtifact',
|
||||
worktreeId: source.worktreeId,
|
||||
absolutePath: result.openTarget.absolutePath,
|
||||
grantId: result.openTarget.grantId,
|
||||
...(source.terminalHandle ? { terminalHandle: source.terminalHandle } : {}),
|
||||
...(source.pathText ? { pathText: source.pathText } : {}),
|
||||
...(source.cwd ? { cwd: source.cwd } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalArtifactGrantFailure(
|
||||
response: RpcFailure,
|
||||
options: TerminalArtifactRetryOptions
|
||||
): boolean {
|
||||
if (options.refreshGrant === false) {
|
||||
return false
|
||||
}
|
||||
return isTerminalArtifactGrantError(`${response.error.code} ${response.error.message}`)
|
||||
}
|
||||
|
||||
function isTerminalArtifactResolution(result: unknown): result is {
|
||||
exists: true
|
||||
isDirectory: false
|
||||
openTarget: { kind: 'absolute-file'; absolutePath: string; grantId: string }
|
||||
} {
|
||||
if (!result || typeof result !== 'object') {
|
||||
return false
|
||||
}
|
||||
const resolution = result as {
|
||||
exists?: unknown
|
||||
isDirectory?: unknown
|
||||
openTarget?: { kind?: unknown; absolutePath?: unknown; grantId?: unknown }
|
||||
}
|
||||
return (
|
||||
resolution.exists === true &&
|
||||
resolution.isDirectory === false &&
|
||||
resolution.openTarget?.kind === 'absolute-file' &&
|
||||
typeof resolution.openTarget.absolutePath === 'string' &&
|
||||
typeof resolution.openTarget.grantId === 'string'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Why: the runtime rejects stale terminal-artifact grants with these codes, but
|
||||
// the RPC mux flattens error shapes to code+message strings, so mobile matches
|
||||
// by substring. Single source so grant refresh and preview errors can't drift.
|
||||
export function isTerminalArtifactGrantError(text: string): boolean {
|
||||
const normalized = text.toLowerCase()
|
||||
return (
|
||||
normalized.includes('terminal_file_grant_expired') ||
|
||||
normalized.includes('terminal_file_grant_mismatch') ||
|
||||
normalized.includes('terminal_file_grant_stale')
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('../../scripts/prepare-android-release.mjs', import.meta.url)
|
||||
)
|
||||
|
||||
const appConfig = {
|
||||
expo: {
|
||||
version: '0.0.22',
|
||||
android: {
|
||||
versionCode: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let tempDirs: string[] = []
|
||||
|
||||
function createAppConfig() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-android-release-'))
|
||||
tempDirs.push(dir)
|
||||
const configPath = join(dir, 'app.json')
|
||||
const contents = `${JSON.stringify(appConfig, null, 2)}\n`
|
||||
writeFileSync(configPath, contents)
|
||||
return { configPath, contents }
|
||||
}
|
||||
|
||||
describe('prepare Android release script', () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { force: true, recursive: true })
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
it('uses committed Android release identity without mutating app config', () => {
|
||||
const { configPath, contents } = createAppConfig()
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_PUBLISH_RELEASE: 'true'
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('Prepared Orca Mobile Android 0.0.22 (4)')
|
||||
expect(output).toContain('Release tag: mobile-android-v0.0.22')
|
||||
expect(readFileSync(configPath, 'utf8')).toBe(contents)
|
||||
})
|
||||
|
||||
it('rejects release-only Android versionCode bumps', () => {
|
||||
const { configPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_BUMP_VERSION_CODE: 'true'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'Android versionCode changes must be committed in mobile/app.json before release'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects release versions that do not match committed app config', () => {
|
||||
const { configPath } = createAppConfig()
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
MOBILE_APP_CONFIG_PATH: configPath,
|
||||
MOBILE_ANDROID_RELEASE_VERSION: '0.0.23'
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'MOBILE_ANDROID_RELEASE_VERSION must match the committed mobile app version'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -27,9 +27,11 @@ export function MobileTerminalLiveInputStatus({
|
||||
? 'Tap mic to stop'
|
||||
: dictation.isProcessing
|
||||
? 'Transcribing on desktop'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Keyboard goes directly to terminal'
|
||||
: dictation.isStarting
|
||||
? 'Preparing microphone'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Tap to show keyboard'
|
||||
|
||||
return (
|
||||
<View style={styles.status}>
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function createClient(responses: unknown[]) {
|
||||
return {
|
||||
sendRequest: vi.fn(async () => responses.shift())
|
||||
}
|
||||
}
|
||||
|
||||
function activeTerminalState(activated: boolean) {
|
||||
return {
|
||||
activated,
|
||||
activationSeq: 1,
|
||||
latestActivationSeq: 1,
|
||||
sourceTerminalHandle: 'terminal-1',
|
||||
activeTerminalHandle: 'terminal-1',
|
||||
activeTabType: 'terminal'
|
||||
}
|
||||
}
|
||||
|
||||
describe('openMobileTerminalFileTap', () => {
|
||||
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
}
|
||||
})
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: 12,
|
||||
column: 3,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback,
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminal: 'terminal-1',
|
||||
line: '12',
|
||||
column: '3'
|
||||
})
|
||||
})
|
||||
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('preserves the worktree-contained files.open flow', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const scheduleDelayedAction = vi.fn((callback: () => void) => callback())
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [openedTab],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab,
|
||||
scheduleDelayedAction
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.open',
|
||||
{ worktree: 'id:wt-1', relativePath: 'src/index.ts' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
|
||||
})
|
||||
|
||||
it('opens worktree-contained line references through the preview route', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
})
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'Orca',
|
||||
pathText: 'src/index.ts:120:7',
|
||||
line: 120,
|
||||
column: 7,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback,
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
source: 'worktree',
|
||||
relativePath: 'src/index.ts',
|
||||
line: '120',
|
||||
column: '7',
|
||||
worktreeName: 'Orca'
|
||||
})
|
||||
})
|
||||
expect(triggerOpenFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('encodes worktree HTML paths before opening a browser tab', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'public/report #1?.html',
|
||||
absolutePath: '/repo/public/report #1?.html',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'public/report #1?.html',
|
||||
absolutePath: '/repo/public/report #1?.html'
|
||||
}
|
||||
})
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'public/report #1?.html',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser,
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(openBrowser).toHaveBeenCalledWith('file:///repo/public/report%20%231%3F.html')
|
||||
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
|
||||
})
|
||||
|
||||
it('passes the terminal cwd when resolving relative taps', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'index.ts',
|
||||
terminalHandle: 'term-1',
|
||||
cwd: '/repo/src',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open SSH worktree HTML paths as local browser file URLs', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'report.html',
|
||||
absolutePath: '/home/me/repo/report.html',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'ssh',
|
||||
relativePath: 'report.html',
|
||||
absolutePath: '/home/me/repo/report.html'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'report.html',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser,
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(openBrowser).not.toHaveBeenCalled()
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.open',
|
||||
{ worktree: 'id:wt-1', relativePath: 'report.html' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not navigate an absolute artifact after the user leaves the source terminal', async () => {
|
||||
let resolveRequest: (value: unknown) => void = () => {}
|
||||
const client = {
|
||||
sendRequest: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
)
|
||||
}
|
||||
let activeTerminalHandle: string | null = 'terminal-1'
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminalHandle: 'terminal-1',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute,
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
activeTerminalHandle
|
||||
}),
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn()
|
||||
})
|
||||
|
||||
activeTerminalHandle = 'terminal-2'
|
||||
resolveRequest(
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: '/tmp/result.json',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'absolute-file',
|
||||
provider: 'local',
|
||||
absolutePath: '/tmp/result.json',
|
||||
grantId: 'grant-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(pushPreviewRoute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
ok({ opened: true })
|
||||
])
|
||||
const callbacks: (() => void)[] = []
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [openedTab],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
latestActivationSeq: 2
|
||||
}),
|
||||
switchSessionTab,
|
||||
scheduleDelayedAction: (callback) => callbacks.push(callback)
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
callbacks.forEach((callback) => callback())
|
||||
await Promise.resolve()
|
||||
|
||||
expect(switchSessionTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
|
||||
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
|
||||
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
|
||||
import { classifyMobileArtifact } from './mobile-artifact-kind'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
|
||||
|
||||
type TerminalFileTapSessionTab = {
|
||||
id: string
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName?: string
|
||||
terminalHandle?: string | null
|
||||
pathText: string
|
||||
cwd?: string | null
|
||||
line: number | null
|
||||
column: number | null
|
||||
pushPreviewRoute: (href: ReturnType<typeof createMobileFilePreviewHref>) => void
|
||||
openBrowser: (url: string) => void
|
||||
triggerOpenFeedback: () => void
|
||||
fetchSessionTabs: () => Promise<void>
|
||||
getSessionTabs: () => readonly T[]
|
||||
getActiveSessionTabId: () => string | null
|
||||
getActivationState: (activated: boolean) => {
|
||||
activated: boolean
|
||||
activationSeq: number
|
||||
latestActivationSeq: number
|
||||
sourceTerminalHandle: string
|
||||
activeTerminalHandle: string | null
|
||||
activeTabType: string | null
|
||||
}
|
||||
switchSessionTab: (tab: T) => void
|
||||
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
|
||||
}
|
||||
|
||||
export function openMobileTerminalFileTap<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
): void {
|
||||
void openMobileTerminalFileTapAsync(options).catch(() => {
|
||||
// Terminal file taps are best-effort: a failed host resolution should leave
|
||||
// terminal focus/input untouched, matching the existing silent miss behavior.
|
||||
})
|
||||
}
|
||||
|
||||
async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
): Promise<void> {
|
||||
const worktree = `id:${options.worktreeId}`
|
||||
const response = await options.client.sendRequest(
|
||||
'files.resolveTerminalPath',
|
||||
{
|
||||
worktree,
|
||||
pathText: options.pathText,
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {})
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
return
|
||||
}
|
||||
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory) {
|
||||
return
|
||||
}
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.openTarget?.kind === 'absolute-file') {
|
||||
options.triggerOpenFeedback()
|
||||
options.pushPreviewRoute(
|
||||
createMobileFilePreviewHref({
|
||||
hostId: options.hostId,
|
||||
worktreeId: options.worktreeId,
|
||||
source: 'terminalArtifact',
|
||||
absolutePath: resolved.openTarget.absolutePath,
|
||||
grantId: resolved.openTarget.grantId,
|
||||
pathText: options.pathText,
|
||||
...(options.cwd && options.cwd.trim().length > 0 ? { cwd: options.cwd } : {}),
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
name: displayNameFromPath(resolved.openTarget.absolutePath),
|
||||
...(options.line !== null ? { line: String(options.line) } : {}),
|
||||
...(options.column !== null ? { column: String(options.column) } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const openedPath =
|
||||
resolved.openTarget?.kind === 'worktree-file'
|
||||
? resolved.openTarget.relativePath
|
||||
: resolved.relativePath
|
||||
if (!openedPath) {
|
||||
return
|
||||
}
|
||||
options.triggerOpenFeedback()
|
||||
if (options.line !== null || options.column !== null) {
|
||||
options.pushPreviewRoute(
|
||||
createMobileFilePreviewHref({
|
||||
hostId: options.hostId,
|
||||
worktreeId: options.worktreeId,
|
||||
source: 'worktree',
|
||||
relativePath: openedPath,
|
||||
name: displayNameFromPath(openedPath),
|
||||
...(options.line !== null ? { line: String(options.line) } : {}),
|
||||
...(options.column !== null ? { column: String(options.column) } : {}),
|
||||
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
if (
|
||||
classifyMobileArtifact(openedPath) === 'html' &&
|
||||
resolved.openTarget?.kind === 'worktree-file' &&
|
||||
resolved.openTarget.provider === 'local'
|
||||
) {
|
||||
options.openBrowser(filesystemPathToFileUri(resolved.openTarget.absolutePath))
|
||||
return
|
||||
}
|
||||
const openResponse = await options.client.sendRequest(
|
||||
'files.open',
|
||||
{ worktree, relativePath: openedPath },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!openResponse.ok) {
|
||||
return
|
||||
}
|
||||
scheduleOpenedWorktreeTabActivation(options, openedPath)
|
||||
}
|
||||
|
||||
function scheduleOpenedWorktreeTabActivation<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>,
|
||||
openedPath: string
|
||||
): void {
|
||||
let activated = false
|
||||
const activateOpenedTab = async (): Promise<void> => {
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
|
||||
return
|
||||
}
|
||||
await options.fetchSessionTabs()
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(activated))) {
|
||||
return
|
||||
}
|
||||
const opened = options.getSessionTabs().find((tab) => tab.relativePath === openedPath)
|
||||
if (!opened) {
|
||||
return
|
||||
}
|
||||
if (options.getActiveSessionTabId() !== opened.id) {
|
||||
options.switchSessionTab(opened)
|
||||
}
|
||||
activated = true
|
||||
}
|
||||
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 300)
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 900)
|
||||
options.scheduleDelayedAction(() => void activateOpenedTab(), 1800)
|
||||
}
|
||||
|
||||
function displayNameFromPath(path: string): string | undefined {
|
||||
return path.split(/[\\/]/).filter(Boolean).at(-1)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
type DisabledTerminalLiveInputHandlesPreference
|
||||
} from '../storage/preferences'
|
||||
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
|
||||
saveDisabledTerminalLiveInputHandles: vi.fn()
|
||||
}))
|
||||
|
||||
type TerminalLiveInputModePreferenceHarness = {
|
||||
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: ((value: T) => void) | null = null
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
if (!resolve) {
|
||||
throw new Error('deferred resolver was not initialized')
|
||||
}
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
|
||||
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
current = useTerminalLiveInputModePreference({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'worktree-1'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!current || !renderer) {
|
||||
throw new Error('terminal live input mode preference hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
get current() {
|
||||
if (!current) {
|
||||
throw new Error('terminal live input mode preference hook is not mounted')
|
||||
}
|
||||
return current
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input mode preference hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
|
||||
})
|
||||
|
||||
it('merges pre-hydration edits with loaded disabled handles', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
|
||||
})
|
||||
act(() => {
|
||||
expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(['pty-2']), loaded: true })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
|
||||
'host-1',
|
||||
'worktree-1',
|
||||
new Set(['pty-2'])
|
||||
)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not persist fallback-empty storage reads during clean hydration', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(), loaded: false })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles
|
||||
} from '../storage/preferences'
|
||||
import {
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
pruneTerminalLiveInputHandles
|
||||
} from '../terminal/terminal-live-input'
|
||||
|
||||
type UseTerminalLiveInputModePreferenceOptions = {
|
||||
readonly hostId: string
|
||||
readonly worktreeId: string
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputModePreference({
|
||||
hostId,
|
||||
worktreeId
|
||||
}: UseTerminalLiveInputModePreferenceOptions) {
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputHydratedRef = useRef(false)
|
||||
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
|
||||
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
|
||||
// before the async persisted-disabled load on worktree re-entry.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
for (const handle of handles) {
|
||||
pendingLiveInputDefaultHandlesRef.current.add(handle)
|
||||
}
|
||||
return
|
||||
}
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
handles,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
)
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
defaultableHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const persistDisabledLiveInputHandles = useCallback(() => {
|
||||
void saveDisabledTerminalLiveInputHandles(
|
||||
hostId,
|
||||
worktreeId,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
).catch(() => {})
|
||||
}, [hostId, worktreeId])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback(
|
||||
(liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
let prunedDisabledHandles = false
|
||||
for (const handle of disabledLiveInputTerminalHandlesRef.current) {
|
||||
if (liveHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
prunedDisabledHandles = true
|
||||
}
|
||||
if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
}
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const toggleTerminalLiveInput = useCallback(
|
||||
(handle: string): boolean => {
|
||||
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
|
||||
if (nextEnabled) {
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
} else {
|
||||
disabledLiveInputTerminalHandlesRef.current.add(handle)
|
||||
}
|
||||
// Why: pre-hydration edits must patch the loaded set per handle; replacing
|
||||
// the loaded set would erase other persisted opt-outs for this worktree.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
|
||||
}
|
||||
// Why: only persist after hydration; an earlier write would use the
|
||||
// reset-empty ref and overwrite other handles' opt-outs for the worktree.
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(handle)
|
||||
} else {
|
||||
next.delete(handle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
return nextEnabled
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputHydratedRef.current = false
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
pendingLiveInputDefaultHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
|
||||
let disposed = false
|
||||
// Why: load the persisted opt-outs first so defaulting logic (which can
|
||||
// fire immediately from subscriptions) respects prior user choices.
|
||||
void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
|
||||
const hydratedDisabledHandles = new Set(preference.handles)
|
||||
for (const [handle, disabled] of pendingEdits) {
|
||||
if (disabled) {
|
||||
hydratedDisabledHandles.add(handle)
|
||||
} else {
|
||||
hydratedDisabledHandles.delete(handle)
|
||||
}
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
|
||||
disabledLiveInputHydratedRef.current = true
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
if (preference.loaded && pendingEdits.size > 0) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
hydratedDisabledHandles
|
||||
)
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
|
||||
pendingLiveInputDefaultHandlesRef.current.clear()
|
||||
defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
|
||||
|
||||
return {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
HOST_SIDEBAR_MIN_WIDTH,
|
||||
clampHostDockWidth,
|
||||
clampHostSidebarWidth,
|
||||
loadDisabledTerminalLiveInputHandles,
|
||||
loadHostSidebarWidth,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
saveHostSidebarWidth,
|
||||
saveTerminalAutocompleteEnabled,
|
||||
saveTerminalLinkOpenMode
|
||||
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live input disabled handles preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
it('defaults to no disabled handles when unset', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: true })
|
||||
})
|
||||
|
||||
it('loads only string terminal handles from storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set(['pty-1', 'pty-2'])
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to no disabled handles for invalid or unreadable storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
|
||||
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: false })
|
||||
})
|
||||
|
||||
it('persists disabled handles per host and worktree', async () => {
|
||||
await saveDisabledTerminalLiveInputHandles(
|
||||
'host/one',
|
||||
'folder:C:\\repo',
|
||||
new Set(['pty-2', 'pty-1'])
|
||||
)
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
|
||||
JSON.stringify(['pty-2', 'pty-1'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host sidebar width preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
|
||||
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
|
||||
}
|
||||
|
||||
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
|
||||
|
||||
export type DisabledTerminalLiveInputHandlesPreference = {
|
||||
readonly handles: Set<string>
|
||||
readonly loaded: boolean
|
||||
}
|
||||
|
||||
function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
|
||||
return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
|
||||
worktreeId
|
||||
)}`
|
||||
}
|
||||
|
||||
export async function readDisabledTerminalLiveInputHandlesPreference(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<DisabledTerminalLiveInputHandlesPreference> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
|
||||
if (!raw) {
|
||||
return { handles: new Set(), loaded: true }
|
||||
}
|
||||
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
|
||||
} catch {
|
||||
return { handles: new Set(), loaded: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<Set<string>> {
|
||||
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
|
||||
return preference.handles
|
||||
}
|
||||
|
||||
export async function saveDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string,
|
||||
handles: ReadonlySet<string>
|
||||
): Promise<void> {
|
||||
await AsyncStorage.setItem(
|
||||
terminalLiveInputDisabledKey(hostId, worktreeId),
|
||||
JSON.stringify([...handles])
|
||||
)
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
|
||||
|
||||
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
|
||||
|
||||
@@ -1,76 +1,30 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
useTerminalWebViewEngineErrorState
|
||||
} from './terminal-webview-engine-error-state'
|
||||
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
|
||||
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
|
||||
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
type TerminalOscLinks = TerminalOscLinkRange[]
|
||||
|
||||
export type TerminalModes = {
|
||||
bracketedPasteMode: boolean
|
||||
altScreen: boolean
|
||||
mouseTrackingMode: TerminalMouseTrackingMode
|
||||
sgrMouseMode: boolean
|
||||
sgrMousePixelsMode: boolean
|
||||
}
|
||||
|
||||
export type TerminalKeyboardAvoidanceMetrics = {
|
||||
cursorY: number
|
||||
rows: number
|
||||
altScreen: boolean
|
||||
}
|
||||
|
||||
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
|
||||
|
||||
export type TerminalSelectionEvents = {
|
||||
onSelectionMode?: (active: boolean) => void
|
||||
onSelectionCopy?: (text: string) => void
|
||||
onSelectionEvicted?: () => void
|
||||
onModesChanged?: (modes: TerminalModes) => void
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
// WebView-detected URL tap; RN chooses the mobile routing destination.
|
||||
onOpenUrl?: (url: string) => void
|
||||
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
|
||||
// here so the app persists it and keeps Settings + other panes in sync.
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinks
|
||||
) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
// Why: reflow the local xterm buffer (scrollback included) to a new width
|
||||
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
|
||||
// latest output. No-op on the alternate screen.
|
||||
reflow: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
export type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
|
||||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
@@ -79,18 +33,16 @@ type Props = {
|
||||
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
// Why: WebView treats source identity as page identity on some platforms; keep
|
||||
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
|
||||
const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
style,
|
||||
terminalTheme,
|
||||
textScale = 1,
|
||||
onWebReady,
|
||||
onEngineError,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
@@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
// race ahead of term.open() / renderService population.
|
||||
const readyPromiseRef = useRef<Promise<void> | null>(null)
|
||||
const readyResolveRef = useRef<(() => void) | null>(null)
|
||||
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
|
||||
useTerminalWebViewEngineErrorState(onEngineError)
|
||||
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef,
|
||||
reportEngineError
|
||||
)
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
@@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
} else if (msg.type === 'ready') {
|
||||
@@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(tag, msg.payload)
|
||||
} else if (msg.type === 'error') {
|
||||
const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error'
|
||||
reportEngineError(message, msg.fatal !== false)
|
||||
} else if (msg.type === 'set-select-mode') {
|
||||
onSelectionMode?.(!!msg.enabled)
|
||||
} else if (msg.type === 'selection') {
|
||||
@@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
},
|
||||
[
|
||||
flushPendingMessages,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
reportEngineError,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
@@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
// Why: messages queued for a previous WebView generation are stale after a reload;
|
||||
// dropping them avoids replaying terminal chunks before the next init snapshot.
|
||||
pendingMessages.clear()
|
||||
}, [pendingMessages])
|
||||
}, [armWebReadyWatchdog, pendingMessages])
|
||||
|
||||
const handleReload = useCallback(() => {
|
||||
clearEngineError()
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
@@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinks
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) {
|
||||
// Why: arm a fresh ready promise BEFORE posting init. The WebView
|
||||
// resolves it via the 'ready' notify at the end of its rAF chain.
|
||||
@@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
)
|
||||
|
||||
return (
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={[styles.webview, style]}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
/>
|
||||
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
|
||||
<WebView
|
||||
ref={webViewRef}
|
||||
source={XTERM_WEBVIEW_SOURCE}
|
||||
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
|
||||
originWhitelist={['*']}
|
||||
javaScriptEnabled
|
||||
scrollEnabled={false}
|
||||
// Why: Android parent gesture containers can intercept vertical drags
|
||||
// before the injected xterm scroll router sees them.
|
||||
nestedScrollEnabled
|
||||
scalesPageToFit={false}
|
||||
// Why: Android WebView defaults textZoom to the system font scale, inflating
|
||||
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
|
||||
textZoom={100}
|
||||
onLoadStart={handleLoadStart}
|
||||
onMessage={handleMessage}
|
||||
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
|
||||
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={(event) =>
|
||||
reportNativeEngineError('Terminal WebView content process ended', event)
|
||||
}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { TappedFilePath } from './terminal-path-tap'
|
||||
import { parsePathWithOptionalLineColumn } from './terminal-path-tap'
|
||||
|
||||
export function resolveTerminalFileUrlTap(uri: string): TappedFilePath | null {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(uri)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const filePath = terminalFileUriToPathText(parsed)
|
||||
if (!filePath) {
|
||||
return null
|
||||
}
|
||||
const hashTarget = parseFileUrlLineHash(parsed.hash)
|
||||
if (hashTarget) {
|
||||
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column }
|
||||
}
|
||||
if (/%3a/i.test(parsed.pathname)) {
|
||||
return { pathText: filePath, line: null, column: null }
|
||||
}
|
||||
return (
|
||||
parseFilePathTrailingLineTarget(filePath) ?? { pathText: filePath, line: null, column: null }
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveTerminalOscFileTap(uri: string): TappedFilePath | null {
|
||||
return resolveTerminalFileUrlTap(uri) ?? parseOscPathLikeTarget(uri)
|
||||
}
|
||||
|
||||
function terminalFileUriToPathText(url: URL): string | null {
|
||||
if (url.protocol !== 'file:') {
|
||||
return null
|
||||
}
|
||||
let decodedPath: string
|
||||
try {
|
||||
decodedPath = decodeURIComponent(url.pathname)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.hostname && !isLocalFileUriHostname(url.hostname)) {
|
||||
return `//${url.hostname}${decodedPath}`
|
||||
}
|
||||
if (/^\/[A-Za-z]:\//.test(decodedPath)) {
|
||||
return decodedPath.slice(1)
|
||||
}
|
||||
return decodedPath || null
|
||||
}
|
||||
|
||||
function isLocalFileUriHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase()
|
||||
return (
|
||||
normalized === 'localhost' ||
|
||||
normalized === '127.0.0.1' ||
|
||||
normalized === '::1' ||
|
||||
normalized === '[::1]'
|
||||
)
|
||||
}
|
||||
|
||||
function parseOscPathLikeTarget(value: string): TappedFilePath | null {
|
||||
if (
|
||||
!/^(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return parsePathWithOptionalLineColumn(value)
|
||||
}
|
||||
|
||||
function parseFileUrlLineHash(hash: string): { line: number; column: number | null } | null {
|
||||
const match = /^#?L(\d+)(?:C(\d+))?$/i.exec(hash)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const line = Number.parseInt(match[1]!, 10)
|
||||
const column = match[2] ? Number.parseInt(match[2], 10) : null
|
||||
if (line < 1 || (column !== null && column < 1)) {
|
||||
return null
|
||||
}
|
||||
return { line, column }
|
||||
}
|
||||
|
||||
function parseFilePathTrailingLineTarget(filePath: string): TappedFilePath | null {
|
||||
const match = /^(.*?)(?::(\d+))(?::(\d+))?$/.exec(filePath)
|
||||
if (!match || !match[1] || match[1].endsWith('/') || match[1].endsWith('\\')) {
|
||||
return null
|
||||
}
|
||||
const line = Number.parseInt(match[2]!, 10)
|
||||
const column = match[3] ? Number.parseInt(match[3], 10) : null
|
||||
if (line < 1 || (column !== null && column < 1)) {
|
||||
return null
|
||||
}
|
||||
return { pathText: match[1], line, column }
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
isTerminalLiveHangulCodePoint,
|
||||
type TerminalLiveMirrorStep
|
||||
} from './terminal-live-hangul-mirror'
|
||||
|
||||
type MirrorRun = {
|
||||
readonly payloads: readonly string[]
|
||||
readonly sentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
function runMirrorSequence(
|
||||
fieldStates: readonly string[],
|
||||
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
|
||||
): MirrorRun {
|
||||
const payloads: string[] = []
|
||||
let sentText = ''
|
||||
let heldText = ''
|
||||
for (const fieldText of fieldStates) {
|
||||
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
if (options.commitAtEnd) {
|
||||
const lastField = sentText + heldText
|
||||
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
return { payloads, sentText, heldText }
|
||||
}
|
||||
|
||||
describe('terminal live hangul mirror', () => {
|
||||
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한'])
|
||||
expect(run.sentText).toBe('한')
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한', '글'])
|
||||
expect(run.sentText).toBe('한글')
|
||||
})
|
||||
|
||||
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['가', '나'])
|
||||
expect(run.sentText).toBe('가나')
|
||||
})
|
||||
|
||||
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
|
||||
// Given: '하' was committed by the settle timer
|
||||
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
|
||||
expect(commit.nextSentText).toBe('하')
|
||||
|
||||
// When: user keeps composing '하' → '한'
|
||||
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then: one DEL erases the stale syllable; the new one is held again
|
||||
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
|
||||
expect(correction.nextSentText).toBe('')
|
||||
expect(correction.heldText).toBe('한')
|
||||
|
||||
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
|
||||
})
|
||||
|
||||
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['a', 'ab', 'abc'])
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['a', 'b', 'c'])
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
|
||||
// Given: '한글' typed, '한' streamed, '글' held
|
||||
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
|
||||
expect(beforeSpace.sentText).toBe('한')
|
||||
expect(beforeSpace.heldText).toBe('글')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
|
||||
expect(step.heldText).toBe('')
|
||||
expect(step.nextSentText).toBe('한글 ')
|
||||
})
|
||||
|
||||
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
|
||||
// Given
|
||||
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
|
||||
expect(held.heldText).toBe('한')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 3,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
|
||||
})
|
||||
|
||||
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 0,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
|
||||
// Given / When
|
||||
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
|
||||
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
|
||||
expect(chinese.heldText).toBe('')
|
||||
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
|
||||
expect(vietnamese.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
|
||||
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
|
||||
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Why: paused composition should still reach the PTY quickly; corrections make
|
||||
// a premature commit safe, so this can be short without leaking jamo forever.
|
||||
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
|
||||
|
||||
const TERMINAL_DEL_BYTE = '\x7f'
|
||||
|
||||
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalLiveMirrorStep = {
|
||||
readonly eraseCount: number
|
||||
readonly appendText: string
|
||||
readonly nextSentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
// Why: React Native exposes no composition events, but Hangul composition only
|
||||
// mutates the trailing syllable. Holding just that code point keeps the PTY
|
||||
// echo live while preedit jamo never leak; DEL corrections repair any commit
|
||||
// that later turns out to be premature.
|
||||
export function computeTerminalLiveMirrorStep(
|
||||
sentText: string,
|
||||
fieldText: string,
|
||||
options: { readonly commitHeld: boolean }
|
||||
): TerminalLiveMirrorStep {
|
||||
const fieldCodePoints = Array.from(fieldText)
|
||||
const lastCodePoint = fieldCodePoints.at(-1)
|
||||
const holdLast =
|
||||
!options.commitHeld &&
|
||||
lastCodePoint !== undefined &&
|
||||
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
|
||||
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
|
||||
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
|
||||
const sentCodePoints = Array.from(sentText)
|
||||
|
||||
let commonPrefixLength = 0
|
||||
while (
|
||||
commonPrefixLength < sentCodePoints.length &&
|
||||
commonPrefixLength < targetCodePoints.length &&
|
||||
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
|
||||
) {
|
||||
commonPrefixLength += 1
|
||||
}
|
||||
|
||||
return {
|
||||
eraseCount: sentCodePoints.length - commonPrefixLength,
|
||||
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
|
||||
nextSentText: targetCodePoints.join(''),
|
||||
heldText
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
|
||||
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const sessionRouteSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const liveInputStatusSource = readFileSync(
|
||||
new URL('../session/MobileTerminalLiveInputStatus.tsx', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const commandInputStylesSource = readFileSync(
|
||||
new URL('../../app/h/[hostId]/session/mobile-session-command-input-styles.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function liveInputBarBlock(): string {
|
||||
const start = sessionRouteSource.indexOf('{liveInputEnabled ? (')
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = sessionRouteSource.indexOf(') : (', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return sessionRouteSource.slice(start, end)
|
||||
}
|
||||
|
||||
describe('terminal live input affordance', () => {
|
||||
it('keeps the live status row wired as the keyboard focus control', () => {
|
||||
const block = liveInputBarBlock()
|
||||
|
||||
expect(block).toContain('onPress={focusLiveInput}')
|
||||
expect(block).toContain('accessibilityRole="button"')
|
||||
expect(block).toContain('accessibilityLabel="Show keyboard for live terminal input"')
|
||||
expect(block).toContain(
|
||||
'accessibilityHint="Typed text is sent directly to the active terminal"'
|
||||
)
|
||||
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
|
||||
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
|
||||
expect(block).toContain('showSoftInputOnFocus')
|
||||
expect(sessionRouteSource).toContain('focusTerminalLiveInputTarget(liveInputRef.current')
|
||||
expect(sessionRouteSource).toContain('keyboardHeight')
|
||||
expect(sessionRouteSource).toContain('scheduleTerminalLiveInputFocus(liveInputFocusTimerRef')
|
||||
})
|
||||
|
||||
it('makes the live keyboard target visible instead of status-only chrome', () => {
|
||||
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
|
||||
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
|
||||
expect(commandInputStylesSource).toContain('borderWidth: 1')
|
||||
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
|
||||
})
|
||||
})
|
||||
@@ -1,12 +1,16 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_INPUT_MAX_BYTES,
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
focusTerminalLiveInputTarget,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus,
|
||||
type TerminalLiveInputFocusTarget,
|
||||
type TerminalLiveInputFocusTimerRef
|
||||
} from './terminal-live-input'
|
||||
|
||||
@@ -14,6 +18,14 @@ function createTimerRef(): TerminalLiveInputFocusTimerRef {
|
||||
return { current: null }
|
||||
}
|
||||
|
||||
function createFocusTarget(isFocused: () => boolean): TerminalLiveInputFocusTarget {
|
||||
return {
|
||||
blur: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
isFocused
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
@@ -101,6 +113,35 @@ describe('terminal live input', () => {
|
||||
expect(result.defaultedHandles).toBe(defaulted)
|
||||
})
|
||||
|
||||
it('does not default persisted buffered-mode handles back to live input on reentry', () => {
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
['pty-1', 'pty-2'],
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
new Set(),
|
||||
new Set(['pty-1']),
|
||||
defaultableHandles
|
||||
)
|
||||
|
||||
expect(defaultableHandles).toEqual(['pty-2'])
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
|
||||
})
|
||||
|
||||
it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-2']),
|
||||
new Set(['pty-2']),
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
|
||||
})
|
||||
|
||||
it('prunes terminal handles that disappear from session snapshots', () => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-stale']),
|
||||
@@ -150,4 +191,37 @@ describe('terminal live input', () => {
|
||||
expect(focus).not.toHaveBeenCalled()
|
||||
expect(timerRef.current).toBeNull()
|
||||
})
|
||||
|
||||
it('refocuses an already-focused capture input when the keyboard is closed', () => {
|
||||
const input = createFocusTarget(() => true)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
|
||||
|
||||
expect(input.blur).toHaveBeenCalledTimes(1)
|
||||
expect(input.focus).not.toHaveBeenCalled()
|
||||
expect(refocus).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses the capture input directly when the keyboard is open', () => {
|
||||
const input = createFocusTarget(() => true)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 240, refocus })
|
||||
|
||||
expect(input.blur).not.toHaveBeenCalled()
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
expect(refocus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses the capture input directly when it is not already focused', () => {
|
||||
const input = createFocusTarget(() => false)
|
||||
const refocus = vi.fn()
|
||||
|
||||
focusTerminalLiveInputTarget(input, { keyboardHeight: 0, refocus })
|
||||
|
||||
expect(input.blur).not.toHaveBeenCalled()
|
||||
expect(input.focus).toHaveBeenCalledTimes(1)
|
||||
expect(refocus).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,6 +66,17 @@ export type TerminalLiveInputFocusTimerRef = {
|
||||
current: ReturnType<typeof setTimeout> | null
|
||||
}
|
||||
|
||||
export type TerminalLiveInputFocusTarget = {
|
||||
readonly focus: () => void
|
||||
readonly blur: () => void
|
||||
readonly isFocused?: () => boolean
|
||||
}
|
||||
|
||||
type FocusTerminalLiveInputTargetOptions = {
|
||||
readonly keyboardHeight: number
|
||||
readonly refocus: () => void
|
||||
}
|
||||
|
||||
export type TerminalLiveInputDefaultResult = {
|
||||
enabledHandles: ReadonlySet<string>
|
||||
defaultedHandles: ReadonlySet<string>
|
||||
@@ -118,6 +129,48 @@ export function defaultTerminalLiveInputHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export function filterTerminalLiveInputDefaultCandidates(
|
||||
terminalHandles: readonly string[],
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): string[] {
|
||||
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
|
||||
}
|
||||
|
||||
export function applyDisabledTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): TerminalLiveInputDefaultResult {
|
||||
let nextEnabledHandles: Set<string> | null = null
|
||||
let nextDefaultedHandles: Set<string> | null = null
|
||||
|
||||
for (const handle of enabledHandles) {
|
||||
if (!disabledHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextEnabledHandles ??= new Set(enabledHandles)
|
||||
nextEnabledHandles.delete(handle)
|
||||
}
|
||||
|
||||
for (const handle of disabledHandles) {
|
||||
if (defaultedHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextDefaultedHandles ??= new Set(defaultedHandles)
|
||||
nextDefaultedHandles.add(handle)
|
||||
}
|
||||
|
||||
if (!nextEnabledHandles && !nextDefaultedHandles) {
|
||||
return { enabledHandles, defaultedHandles, changed: false }
|
||||
}
|
||||
|
||||
return {
|
||||
enabledHandles: nextEnabledHandles ?? enabledHandles,
|
||||
defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
@@ -175,4 +228,23 @@ export function scheduleTerminalLiveInputFocus(
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
export function focusTerminalLiveInputTarget(
|
||||
input: TerminalLiveInputFocusTarget | null,
|
||||
{ keyboardHeight, refocus }: FocusTerminalLiveInputTargetOptions
|
||||
): void {
|
||||
if (!input) {
|
||||
return
|
||||
}
|
||||
|
||||
if (keyboardHeight <= 0 && input.isFocused?.()) {
|
||||
// Why: Android can keep a hidden TextInput focused after the IME is dismissed;
|
||||
// focus() is then a no-op, so force a new focus session to reopen the keyboard.
|
||||
input.blur()
|
||||
refocus()
|
||||
return
|
||||
}
|
||||
|
||||
input.focus()
|
||||
}
|
||||
|
||||
export { TERMINAL_LIVE_INPUT_MAX_BYTES }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush,
|
||||
type TerminalLivePendingFlushState
|
||||
} from './terminal-live-pending-flush-state'
|
||||
@@ -64,83 +64,54 @@ describe('terminal live pending flush state', () => {
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('Given a queued pending flush When it resolves or rejects Then clears the barrier', async () => {
|
||||
describe('terminal live mirror send queue', () => {
|
||||
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
|
||||
// Given
|
||||
const resolvedState: TerminalLivePendingFlushState = { current: null }
|
||||
const rejectedState: TerminalLivePendingFlushState = { current: null }
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const order: string[] = []
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
order.push('first')
|
||||
return false
|
||||
})
|
||||
|
||||
// When
|
||||
await expect(queueTerminalLivePendingFlush(resolvedState, async () => true)).resolves.toBe(true)
|
||||
await expect(
|
||||
queueTerminalLivePendingFlush(rejectedState, async () => {
|
||||
throw new Error('send failed')
|
||||
})
|
||||
).resolves.toBe(false)
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => {
|
||||
order.push('second')
|
||||
return true
|
||||
})
|
||||
|
||||
// Then
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
expect(order).toEqual(['first', 'second'])
|
||||
})
|
||||
|
||||
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
// When
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => true)
|
||||
|
||||
// Then
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
|
||||
// When
|
||||
await queueTerminalLiveMirrorSend(state, async () => true)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(resolvedState.current).toBeNull()
|
||||
expect(rejectedState.current).toBeNull()
|
||||
})
|
||||
|
||||
it('Given a current pending snapshot while another flush is in flight When queued Then sends current text after prior success', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(events).toEqual([])
|
||||
resolveFirstFlush(true)
|
||||
await expect(secondFlush).resolves.toBe(true)
|
||||
await expect(controlSend).resolves.toBe(true)
|
||||
expect(events).toEqual(['second-flush', 'control'])
|
||||
})
|
||||
|
||||
it('Given a prior pending flush fails When another snapshot is queued Then skips the current text and control', async () => {
|
||||
// Given
|
||||
const events: string[] = []
|
||||
let resolveFirstFlush: (value: boolean) => void = () => {}
|
||||
const firstFlush = new Promise<boolean>((resolve) => {
|
||||
resolveFirstFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: firstFlush }
|
||||
|
||||
// When
|
||||
const secondFlush = queueTerminalLivePendingFlush(state, async () => {
|
||||
events.push('second-flush')
|
||||
return true
|
||||
})
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
() => waitForTerminalLivePendingFlush(state),
|
||||
async () => {
|
||||
events.push('control')
|
||||
return true
|
||||
}
|
||||
)
|
||||
resolveFirstFlush(false)
|
||||
|
||||
// Then
|
||||
await expect(secondFlush).resolves.toBe(false)
|
||||
await expect(controlSend).resolves.toBe(false)
|
||||
expect(events).toEqual([])
|
||||
expect(state.current).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,22 +8,26 @@ export function waitForTerminalLivePendingFlush(
|
||||
return state.current ?? Promise.resolve(true)
|
||||
}
|
||||
|
||||
export function queueTerminalLivePendingFlush(
|
||||
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
|
||||
// delta desyncs every later diff, so this chain runs each send even when the
|
||||
// previous one failed. state.current should never reject; the catch keeps a
|
||||
// future raw assignment from skipping a delta.
|
||||
export function queueTerminalLiveMirrorSend(
|
||||
state: TerminalLivePendingFlushState,
|
||||
sendPendingText: () => Promise<boolean>
|
||||
sendMirrorPayload: () => Promise<boolean>
|
||||
): Promise<boolean> {
|
||||
const previousFlush = state.current
|
||||
const flushPromise = (async () => {
|
||||
if (previousFlush && !(await previousFlush)) {
|
||||
return false
|
||||
const previousSend = state.current
|
||||
const sendPromise = (async () => {
|
||||
if (previousSend) {
|
||||
await previousSend.catch(() => false)
|
||||
}
|
||||
return sendPendingText()
|
||||
return sendMirrorPayload()
|
||||
})().catch(() => false)
|
||||
state.current = flushPromise
|
||||
void flushPromise.then(() => {
|
||||
if (state.current === flushPromise) {
|
||||
state.current = sendPromise
|
||||
void sendPromise.then(() => {
|
||||
if (state.current === sendPromise) {
|
||||
state.current = null
|
||||
}
|
||||
})
|
||||
return flushPromise
|
||||
return sendPromise
|
||||
}
|
||||
|
||||
@@ -1,234 +1,118 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS,
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs,
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision,
|
||||
isTerminalLiveTextHangulCandidate,
|
||||
isTerminalLiveTextImeCandidate
|
||||
getTerminalLiveSpecialKeyDecision
|
||||
} from './terminal-live-text-commit'
|
||||
|
||||
describe('terminal live text commit', () => {
|
||||
it('Given Korean IME changes When live text changes Then defers candidates and submits only final text before carriage return', () => {
|
||||
// Given
|
||||
const koreanCompositionSteps = ['ㅎ', '하', '한'] as const
|
||||
|
||||
// When
|
||||
const decisions = koreanCompositionSteps.map(getTerminalLiveTextChangeDecision)
|
||||
const sentImmediately = decisions.filter((decision) => decision.kind === 'send-now')
|
||||
const submitSequence = getTerminalLiveSubmitSequence('한')
|
||||
const composedWordDecision = getTerminalLiveTextChangeDecision('한글')
|
||||
const composedWordSubmitSequence = getTerminalLiveSubmitSequence('한글')
|
||||
|
||||
// Then
|
||||
expect(decisions).toEqual([
|
||||
{ kind: 'defer', text: 'ㅎ', delayMs: null },
|
||||
{ kind: 'defer', text: '하', delayMs: null },
|
||||
{ kind: 'defer', text: '한', delayMs: null }
|
||||
])
|
||||
expect(sentImmediately).toEqual([])
|
||||
expect(isTerminalLiveTextHangulCandidate('ㅎ')).toBe(true)
|
||||
expect(getTerminalLiveDeferredTextDelayMs('ㅎ')).toBeNull()
|
||||
expect(submitSequence).toEqual(['한', '\r'])
|
||||
expect(composedWordDecision).toEqual({
|
||||
kind: 'defer',
|
||||
text: '한글',
|
||||
delayMs: null
|
||||
describe('terminal live special key decision', () => {
|
||||
it('Given an unmapped key Then ignores it', () => {
|
||||
expect(getTerminalLiveSpecialKeyDecision({ key: 'ㅎ', heldText: '', sentText: '' })).toEqual({
|
||||
kind: 'ignore'
|
||||
})
|
||||
expect(composedWordSubmitSequence).toEqual(['한글', '\r'])
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When live text changes Then keeps the bounded settle timer', () => {
|
||||
// Given
|
||||
const text = 'あ'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({ kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS })
|
||||
it('Given Backspace with any field text Then edits locally so the mirror diff handles the PTY erase', () => {
|
||||
// Held syllable present
|
||||
expect(
|
||||
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '한', sentText: '' })
|
||||
).toEqual({ kind: 'local-edit' })
|
||||
// Only mirrored text present — native edit fires onChangeText and the diff erases
|
||||
expect(
|
||||
getTerminalLiveSpecialKeyDecision({ key: 'Backspace', heldText: '', sentText: 'abc' })
|
||||
).toEqual({ kind: 'local-edit' })
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When live text changes Then does not use Hangul-only indefinite deferral', () => {
|
||||
// Given
|
||||
const nonHangulImeTexts = ['你好', 'tiếng Việt'] as const
|
||||
|
||||
for (const text of nonHangulImeTexts) {
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(true)
|
||||
expect(isTerminalLiveTextHangulCandidate(text)).toBe(false)
|
||||
expect(getTerminalLiveDeferredTextDelayMs(text)).toBe(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
expect(decision).toEqual({
|
||||
kind: 'defer',
|
||||
text,
|
||||
delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('Given ASCII text When live text changes Then sends immediately', () => {
|
||||
// Given
|
||||
const text = 'abc123'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'send-now', text })
|
||||
})
|
||||
|
||||
it('Given empty text When live text changes Then ignores the change', () => {
|
||||
// Given
|
||||
const text = ''
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveTextChangeDecision(text)
|
||||
|
||||
// Then
|
||||
expect(isTerminalLiveTextImeCandidate(text)).toBe(false)
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
})
|
||||
|
||||
it('Given pending text When Backspace or Delete is pressed Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit' })
|
||||
})
|
||||
|
||||
it('Given no pending text When Backspace or Delete is pressed Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveSpecialKeyDecision({ key: 'Backspace', pendingText })
|
||||
const deleteDecision = getTerminalLiveSpecialKeyDecision({ key: 'Delete', pendingText })
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'send-now', bytes: '\x7f' })
|
||||
expect(deleteDecision).toEqual({ kind: 'send-now', bytes: '\x1b[3~' })
|
||||
})
|
||||
|
||||
it('Given pending text When a terminal special key is pressed Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한'
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key: 'Tab', pendingText })
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory control bytes are requested Then flushes pending text before bytes', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
const escapeDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\x1b', pendingText })
|
||||
const enterDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\r', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\t' })
|
||||
expect(escapeDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\x1b' })
|
||||
expect(enterDecision).toEqual({ kind: 'flush-then-send', pendingText, bytes: '\r' })
|
||||
})
|
||||
|
||||
it('Given pending text When accessory Backspace or Delete bytes are requested Then keeps edits local', () => {
|
||||
// Given
|
||||
const pendingText = '한글'
|
||||
|
||||
// When
|
||||
const backspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
it('Given Backspace with an empty field Then sends terminal backspace bytes', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'Backspace',
|
||||
heldText: '',
|
||||
sentText: ''
|
||||
})
|
||||
const ctrlBackspaceDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\b',
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b[3~',
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
const customDeleteByteDecision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
pendingText
|
||||
})
|
||||
const backspaceText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'backspace',
|
||||
pendingText
|
||||
})
|
||||
const deleteText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: 'delete',
|
||||
pendingText
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(backspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(ctrlBackspaceDecision).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(deleteDecision).toEqual({ kind: 'local-edit', localEdit: 'delete' })
|
||||
expect(customDeleteByteDecision).toEqual({
|
||||
kind: 'flush-then-send',
|
||||
pendingText,
|
||||
bytes: '\x7f'
|
||||
})
|
||||
expect(backspaceText).toBe('한')
|
||||
expect(deleteText).toBe('한글')
|
||||
expect(getTerminalLiveDeferredTextDelayMs(backspaceText)).toBeNull()
|
||||
expect(getTerminalLiveDeferredTextDelayMs(deleteText)).toBeNull()
|
||||
expect(decision.kind).toBe('send-now')
|
||||
})
|
||||
|
||||
it('Given no pending text When accessory bytes are requested Then sends terminal bytes', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
|
||||
// When
|
||||
const tabDecision = getTerminalLiveAccessoryBytesDecision({ bytes: '\t', pendingText })
|
||||
|
||||
// Then
|
||||
expect(tabDecision).toEqual({ kind: 'send-now', bytes: '\t' })
|
||||
it('Given a control key with a held syllable Then commits the held text before the bytes', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'Tab',
|
||||
heldText: '글',
|
||||
sentText: '한'
|
||||
})
|
||||
expect(decision.kind).toBe('commit-held-then-send')
|
||||
})
|
||||
|
||||
it('Given a non-special key When key decision is requested Then ignores it', () => {
|
||||
// Given
|
||||
const key = 'a'
|
||||
it('Given a control key with no held syllable Then sends immediately', () => {
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: 'ArrowUp',
|
||||
heldText: '',
|
||||
sentText: 'ls'
|
||||
})
|
||||
expect(decision.kind).toBe('send-now')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live accessory bytes decision', () => {
|
||||
it('Given a local-edit accessory key with field text Then edits locally', () => {
|
||||
expect(
|
||||
getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '한',
|
||||
sentText: ''
|
||||
})
|
||||
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
expect(
|
||||
getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '',
|
||||
sentText: 'abc'
|
||||
})
|
||||
).toEqual({ kind: 'local-edit', localEdit: 'backspace' })
|
||||
})
|
||||
|
||||
// When
|
||||
const decision = getTerminalLiveSpecialKeyDecision({ key, pendingText: '한' })
|
||||
it('Given raw accessory bytes with a held syllable Then commits held text first', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b',
|
||||
heldText: '한',
|
||||
sentText: ''
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'commit-held-then-send', bytes: '\x1b' })
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(decision).toEqual({ kind: 'ignore' })
|
||||
it('Given raw accessory bytes with nothing held Then sends immediately', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x1b',
|
||||
heldText: '',
|
||||
sentText: 'abc'
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'send-now', bytes: '\x1b' })
|
||||
})
|
||||
|
||||
it('Given no pending text When submit is requested Then sends only carriage return', () => {
|
||||
// Given
|
||||
const pendingText = ''
|
||||
it('Given a local-edit accessory key with an empty field Then sends the raw bytes', () => {
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({
|
||||
bytes: '\x7f',
|
||||
localEdit: 'backspace',
|
||||
heldText: '',
|
||||
sentText: ''
|
||||
})
|
||||
expect(decision).toEqual({ kind: 'send-now', bytes: '\x7f' })
|
||||
})
|
||||
})
|
||||
|
||||
// When
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
describe('terminal live accessory local edit text', () => {
|
||||
it('Given backspace Then drops the last code point of the field text', () => {
|
||||
expect(
|
||||
getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한글' })
|
||||
).toBe('한')
|
||||
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'backspace', fieldText: '한' })).toBe(
|
||||
''
|
||||
)
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(sequence).toEqual(['\r'])
|
||||
it('Given forward delete Then keeps the field text unchanged', () => {
|
||||
expect(getTerminalLiveAccessoryLocalEditText({ localEdit: 'delete', fieldText: '한글' })).toBe(
|
||||
'한글'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { getTerminalLiveSpecialKeyBytes } from './terminal-live-input'
|
||||
|
||||
// Why: React Native does not expose portable composition events here, so
|
||||
// non-Hangul IME text gets a short settle window before being sent to the PTY.
|
||||
export const TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS = 150
|
||||
|
||||
export type TerminalLiveTextChangeDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'send-now'; readonly text: string }
|
||||
| { readonly kind: 'defer'; readonly text: string; readonly delayMs: number | null }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecision =
|
||||
| { readonly kind: 'ignore' }
|
||||
| { readonly kind: 'local-edit' }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveSpecialKeyDecisionInput = {
|
||||
readonly key: string
|
||||
readonly pendingText: string
|
||||
readonly heldText: string
|
||||
readonly sentText: string
|
||||
}
|
||||
|
||||
export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
|
||||
@@ -25,78 +17,33 @@ export type TerminalLiveAccessoryLocalEdit = 'backspace' | 'delete'
|
||||
export type TerminalLiveAccessoryBytesDecision =
|
||||
| { readonly kind: 'local-edit'; readonly localEdit: TerminalLiveAccessoryLocalEdit }
|
||||
| { readonly kind: 'send-now'; readonly bytes: string }
|
||||
| { readonly kind: 'flush-then-send'; readonly pendingText: string; readonly bytes: string }
|
||||
| { readonly kind: 'commit-held-then-send'; readonly bytes: string }
|
||||
|
||||
export type TerminalLiveAccessoryBytesDecisionInput = {
|
||||
readonly bytes: string
|
||||
readonly localEdit?: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextImeCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && codePoint > 0x7f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export function isTerminalLiveTextHangulCandidate(text: string): boolean {
|
||||
for (const character of text) {
|
||||
const codePoint = character.codePointAt(0)
|
||||
if (codePoint !== undefined && isHangulCodePoint(codePoint)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getTerminalLiveTextChangeDecision(text: string): TerminalLiveTextChangeDecision {
|
||||
if (text.length === 0) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextHangulCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: null }
|
||||
}
|
||||
|
||||
if (isTerminalLiveTextImeCandidate(text)) {
|
||||
return { kind: 'defer', text, delayMs: TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', text }
|
||||
}
|
||||
|
||||
export function getTerminalLiveDeferredTextDelayMs(text: string): number | null {
|
||||
return isTerminalLiveTextHangulCandidate(text) ? null : TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS
|
||||
readonly heldText: string
|
||||
readonly sentText: string
|
||||
}
|
||||
|
||||
export function getTerminalLiveSpecialKeyDecision({
|
||||
key,
|
||||
pendingText
|
||||
heldText,
|
||||
sentText
|
||||
}: TerminalLiveSpecialKeyDecisionInput): TerminalLiveSpecialKeyDecision {
|
||||
const bytes = getTerminalLiveSpecialKeyBytes(key)
|
||||
if (bytes === null) {
|
||||
return { kind: 'ignore' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0 && (key === 'Backspace' || key === 'Delete')) {
|
||||
// Why: native field edits fire onChangeText and the mirror diff emits the
|
||||
// matching PTY erase; sending raw DEL here as well would double-erase.
|
||||
if ((key === 'Backspace' || key === 'Delete') && (heldText.length > 0 || sentText.length > 0)) {
|
||||
return { kind: 'local-edit' }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
if (heldText.length > 0) {
|
||||
return { kind: 'commit-held-then-send', bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
@@ -105,14 +52,15 @@ export function getTerminalLiveSpecialKeyDecision({
|
||||
export function getTerminalLiveAccessoryBytesDecision({
|
||||
bytes,
|
||||
localEdit,
|
||||
pendingText
|
||||
heldText,
|
||||
sentText
|
||||
}: TerminalLiveAccessoryBytesDecisionInput): TerminalLiveAccessoryBytesDecision {
|
||||
if (pendingText.length > 0 && localEdit) {
|
||||
if (localEdit && (heldText.length > 0 || sentText.length > 0)) {
|
||||
return { kind: 'local-edit', localEdit }
|
||||
}
|
||||
|
||||
if (pendingText.length > 0) {
|
||||
return { kind: 'flush-then-send', pendingText, bytes }
|
||||
if (heldText.length > 0) {
|
||||
return { kind: 'commit-held-then-send', bytes }
|
||||
}
|
||||
|
||||
return { kind: 'send-now', bytes }
|
||||
@@ -120,26 +68,16 @@ export function getTerminalLiveAccessoryBytesDecision({
|
||||
|
||||
export function getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit,
|
||||
pendingText
|
||||
fieldText
|
||||
}: {
|
||||
readonly localEdit: TerminalLiveAccessoryLocalEdit
|
||||
readonly pendingText: string
|
||||
readonly fieldText: string
|
||||
}): string {
|
||||
if (localEdit === 'delete') {
|
||||
// Why: accessory Delete mirrors forward-delete at the hidden input's end;
|
||||
// it stays local but does not remove the pending IME text.
|
||||
return pendingText
|
||||
// it stays local but does not remove the field text.
|
||||
return fieldText
|
||||
}
|
||||
|
||||
return Array.from(pendingText).slice(0, -1).join('')
|
||||
}
|
||||
|
||||
export type TerminalLiveSubmitSequence = readonly ['\r'] | readonly [string, '\r']
|
||||
|
||||
export function getTerminalLiveSubmitSequence(pendingText: string): TerminalLiveSubmitSequence {
|
||||
if (pendingText.length === 0) {
|
||||
return ['\r']
|
||||
}
|
||||
|
||||
return [pendingText, '\r']
|
||||
return Array.from(fieldText).slice(0, -1).join('')
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
// often print a bare filename (the markdown link target is consumed, leaving
|
||||
// only the label text), so requiring a slash would miss the common case.
|
||||
export const TERMINAL_PATH_TAP_JS = String.raw`
|
||||
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
|
||||
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
|
||||
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
|
||||
var FILE_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-\/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g;
|
||||
var SPACED_PATH_RE = /(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|\`\r\n]+(?::\d+)?(?::\d+)?/g;
|
||||
var PATH_LEADING_TRIM = { '(': 1, '[': 1, '{': 1, '"': 1, "'": 1 };
|
||||
var PATH_TRAILING_TRIM = { ')': 1, ']': 1, '}': 1, '"': 1, "'": 1, ',': 1, ';': 1, '.': 1 };
|
||||
|
||||
function parsePathLineCol(value) {
|
||||
function parsePathLineCol(value) {
|
||||
var m = /^(.*?)(?::(\d+))?(?::(\d+))?$/.exec(value);
|
||||
if (!m) return null;
|
||||
var pathText = m[1];
|
||||
@@ -27,24 +28,91 @@ export const TERMINAL_PATH_TAP_JS = String.raw`
|
||||
var line = m[2] ? parseInt(m[2], 10) : null;
|
||||
var column = m[3] ? parseInt(m[3], 10) : null;
|
||||
if ((line !== null && line < 1) || (column !== null && column < 1)) return null;
|
||||
return { pathText: pathText, line: line, column: column };
|
||||
}
|
||||
return { pathText: pathText, line: line, column: column };
|
||||
}
|
||||
|
||||
function matchFilePathAtColumn(lineText, col) {
|
||||
FILE_PATH_RE.lastIndex = 0;
|
||||
function trimPathBoundaryPunctuation(raw, rawStart) {
|
||||
var start = 0, end = raw.length;
|
||||
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
|
||||
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
|
||||
if (start >= end) return null;
|
||||
return { text: raw.slice(start, end), startIndex: rawStart + start, endIndex: rawStart + end };
|
||||
}
|
||||
|
||||
function hasSeparatorAfterWhitespace(text) {
|
||||
var sawWhitespace = false;
|
||||
for (var i = 0; i < text.length; i++) {
|
||||
var ch = text.charAt(i);
|
||||
if (/\s/.test(ch)) { sawWhitespace = true; continue; }
|
||||
if (sawWhitespace && (ch === '/' || ch === '\\')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function trimSpacedPathTrailingProse(range, col) {
|
||||
// A line-end extension token only extends the span when the added segment
|
||||
// is path-like (contains a separator) — prose must not be swallowed.
|
||||
var selected = null;
|
||||
var extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g;
|
||||
var match;
|
||||
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
|
||||
var end = match.index + match[0].length;
|
||||
var text = range.text.slice(0, end);
|
||||
if (countPathStarts(text) > 1) continue;
|
||||
if (end < range.text.length || selected === null || /[\\/]/.test(range.text.slice(selected.length, end))) {
|
||||
selected = text;
|
||||
}
|
||||
}
|
||||
if (selected) {
|
||||
if (col !== undefined && col >= range.startIndex + selected.length) return null;
|
||||
return { text: selected, startIndex: range.startIndex, endIndex: range.startIndex + selected.length };
|
||||
}
|
||||
var text = range.text.replace(/\s+$/, '');
|
||||
return { text: text, startIndex: range.startIndex, endIndex: range.startIndex + text.length };
|
||||
}
|
||||
|
||||
function countPathStarts(text) {
|
||||
var count = 0;
|
||||
var pathStartPattern = /(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g;
|
||||
while (pathStartPattern.exec(text) !== null) count += 1;
|
||||
return count;
|
||||
}
|
||||
|
||||
function hasSpacedPathExtension(text) {
|
||||
var range = trimSpacedPathTrailingProse({ text: text, startIndex: 0, endIndex: text.length });
|
||||
if (!range) return false;
|
||||
var trimmed = range.text.replace(/\s+$/, '');
|
||||
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed);
|
||||
}
|
||||
|
||||
function matchSpacedFilePathAtColumn(lineText, col) {
|
||||
SPACED_PATH_RE.lastIndex = 0;
|
||||
var match;
|
||||
while ((match = SPACED_PATH_RE.exec(lineText)) !== null) {
|
||||
var trimmed = trimPathBoundaryPunctuation(match[0], match.index);
|
||||
if (!trimmed || (!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))) continue;
|
||||
var candidate = trimSpacedPathTrailingProse(trimmed, col);
|
||||
if (!candidate) continue;
|
||||
if (col < candidate.startIndex || col >= candidate.endIndex) continue;
|
||||
var parsed = parsePathLineCol(candidate.text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchFilePathAtColumn(lineText, col) {
|
||||
var spaced = matchSpacedFilePathAtColumn(lineText, col);
|
||||
if (spaced) return spaced;
|
||||
FILE_PATH_RE.lastIndex = 0;
|
||||
var match;
|
||||
while ((match = FILE_PATH_RE.exec(lineText)) !== null) {
|
||||
var raw = match[0];
|
||||
if (raw.length === 0) { FILE_PATH_RE.lastIndex += 1; continue; }
|
||||
var start = 0, end = raw.length;
|
||||
while (start < end && PATH_LEADING_TRIM[raw.charAt(start)]) start += 1;
|
||||
while (end > start && PATH_TRAILING_TRIM[raw.charAt(end - 1)]) end -= 1;
|
||||
if (start >= end) continue;
|
||||
var spanStart = match.index + start;
|
||||
var spanEnd = match.index + end;
|
||||
if (col < spanStart || col > spanEnd) continue;
|
||||
var parsed = parsePathLineCol(raw.slice(start, end));
|
||||
if (parsed) return parsed;
|
||||
var trimmed = trimPathBoundaryPunctuation(raw, match.index);
|
||||
if (!trimmed) continue;
|
||||
if (col < trimmed.startIndex || col >= trimmed.endIndex) continue;
|
||||
var parsed = parsePathLineCol(trimmed.text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Script, createContext } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES,
|
||||
columnForTerminalFileLinkTap
|
||||
} from '../../../src/shared/terminal-file-link-conformance'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { matchFilePathAtColumn, parsePathWithOptionalLineColumn } from './terminal-path-tap'
|
||||
|
||||
type InjectedPathMatcher = typeof matchFilePathAtColumn
|
||||
|
||||
// Returns the column of the first occurrence of `needle` in `line` (+offset).
|
||||
function colOf(line: string, needle: string, offset = 0): number {
|
||||
return line.indexOf(needle) + offset
|
||||
@@ -32,6 +40,15 @@ describe('parsePathWithOptionalLineColumn', () => {
|
||||
})
|
||||
|
||||
describe('matchFilePathAtColumn', () => {
|
||||
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
|
||||
'matches shared terminal file-link tap case: $name',
|
||||
(testCase) => {
|
||||
expect(
|
||||
matchFilePathAtColumn(testCase.lineText, columnForTerminalFileLinkTap(testCase))
|
||||
).toEqual(testCase.expected)
|
||||
}
|
||||
)
|
||||
|
||||
it('matches an absolute path under the tap', () => {
|
||||
const line = 'created /tmp/out/report.html for you'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'report'))
|
||||
@@ -50,12 +67,68 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(result?.pathText).toBe('~/Documents/notes.md')
|
||||
})
|
||||
|
||||
it('yields the tight whitespace-bounded segment under the tap', () => {
|
||||
// On a path whose dir name has a space, tapping the file segment yields the
|
||||
// openable sub-path after the space (still resolves against the worktree).
|
||||
it('matches a path whose directory name contains a space', () => {
|
||||
const line = '/Users/me/My Project/readme.md done'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'readme'))
|
||||
expect(result?.pathText).toBe('Project/readme.md')
|
||||
expect(result?.pathText).toBe('/Users/me/My Project/readme.md')
|
||||
})
|
||||
|
||||
it('matches a spaced path with line and column suffixes', () => {
|
||||
const line = 'wrote /tmp/orca report/result.json:12:3 for you'
|
||||
const result = matchFilePathAtColumn(line, colOf(line, 'result'))
|
||||
expect(result).toEqual({
|
||||
pathText: '/tmp/orca report/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a spaced path when an earlier directory segment contains a dot', () => {
|
||||
const line = 'wrote /tmp/v1.2 reports/result.json for you'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'v1.2'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'reports'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'result'))).toEqual({
|
||||
pathText: '/tmp/v1.2 reports/result.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a path whose final filename contains a space', () => {
|
||||
const line = 'wrote /tmp/final report.json for you'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'final'))).toEqual({
|
||||
pathText: '/tmp/final report.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'report'))).toEqual({
|
||||
pathText: '/tmp/final report.json',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('does not merge two spaced path candidates through prose', () => {
|
||||
const line = 'see /tmp/a.txt and /tmp/b.txt done'
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'a.txt'))).toEqual({
|
||||
pathText: '/tmp/a.txt',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'b.txt'))).toEqual({
|
||||
pathText: '/tmp/b.txt',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('trims surrounding punctuation', () => {
|
||||
@@ -74,6 +147,11 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(matchFilePathAtColumn(line, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when the tap is immediately after the path span', () => {
|
||||
const line = 'prefix /tmp/x.ts next'
|
||||
expect(matchFilePathAtColumn(line, line.indexOf(' next'))).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a bare filename with an extension (no slash)', () => {
|
||||
// Why: agents commonly print a bare filename (e.g. a markdown link whose
|
||||
// target was consumed). The host existence-check rejects non-files.
|
||||
@@ -87,3 +165,24 @@ describe('matchFilePathAtColumn', () => {
|
||||
expect(matchFilePathAtColumn(line, colOf(line, 'Here'))).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('injected matchFilePathAtColumn', () => {
|
||||
const matcher = createInjectedPathMatcher()
|
||||
|
||||
it.each(TERMINAL_FILE_LINK_TAP_CONFORMANCE_CASES)(
|
||||
'matches shared terminal file-link tap case: $name',
|
||||
(testCase) => {
|
||||
expect(matcher(testCase.lineText, columnForTerminalFileLinkTap(testCase))).toEqual(
|
||||
testCase.expected
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
function createInjectedPathMatcher(): InjectedPathMatcher {
|
||||
const context = createContext({})
|
||||
new Script(
|
||||
`${TERMINAL_PATH_TAP_JS}\nthis.__matchFilePathAtColumn = matchFilePathAtColumn;`
|
||||
).runInContext(context)
|
||||
return (context as { __matchFilePathAtColumn: InjectedPathMatcher }).__matchFilePathAtColumn
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@ export type TappedFilePath = {
|
||||
// a bare filename with an extension (README.md, index.ts), optionally suffixed
|
||||
// with :line or :line:col. Like desktop, we propose candidates and let the host
|
||||
// existence-check reject non-files — agents often print a bare filename, so
|
||||
// requiring a slash would miss the common case. The desktop's spaced-path
|
||||
// variants are intentionally not ported: a tap always lands inside one
|
||||
// whitespace-bounded segment, so this already covers the real cases.
|
||||
// requiring a slash would miss the common case.
|
||||
const LOCAL_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/]|(?=[A-Za-z0-9._-]*\.[A-Za-z0-9]))[A-Za-z0-9._~\-/%+@\\()[\]]*(?::\d+)?(?::\d+)?/g
|
||||
const SPACED_PATH_REGEX =
|
||||
/(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/]|[A-Za-z0-9._-]+[\\/])[^()[\]{}'",;<>|`\r\n]+(?::\d+)?(?::\d+)?/g
|
||||
|
||||
const LEADING_TRIM_CHARS = new Set(['(', '[', '{', '"', "'"])
|
||||
const TRAILING_TRIM_CHARS = new Set([')', ']', '}', '"', "'", ',', ';', '.'])
|
||||
@@ -64,9 +64,116 @@ export function parsePathWithOptionalLineColumn(value: string): TappedFilePath |
|
||||
return { pathText, line, column }
|
||||
}
|
||||
|
||||
function hasSeparatorAfterWhitespace(text: string): boolean {
|
||||
let sawWhitespace = false
|
||||
for (const char of text) {
|
||||
if (/\s/.test(char)) {
|
||||
sawWhitespace = true
|
||||
continue
|
||||
}
|
||||
if (sawWhitespace && (char === '/' || char === '\\')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function trimSpacedPathTrailingProse(
|
||||
range: Span & { text: string },
|
||||
col?: number
|
||||
): (Span & { text: string }) | null {
|
||||
// Why: a line-end extension token only extends the span when the added
|
||||
// segment is path-like (contains a separator) — "v1.2 reports/result.json"
|
||||
// extends, prose like "failed to start app.py" must not be swallowed.
|
||||
let selected: string | null = null
|
||||
const extensionPrefixPattern = /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?(?=\s+|$)/g
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = extensionPrefixPattern.exec(range.text)) !== null) {
|
||||
const end = match.index + match[0].length
|
||||
const text = range.text.slice(0, end)
|
||||
if (countPathStarts(text) > 1) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
end < range.text.length ||
|
||||
selected === null ||
|
||||
/[\\/]/.test(range.text.slice(selected.length, end))
|
||||
) {
|
||||
selected = text
|
||||
}
|
||||
}
|
||||
if (!selected) {
|
||||
const text = range.text.trimEnd()
|
||||
return {
|
||||
...range,
|
||||
text,
|
||||
endIndex: range.startIndex + text.length
|
||||
}
|
||||
}
|
||||
if (col !== undefined && col >= range.startIndex + selected.length) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text: selected,
|
||||
startIndex: range.startIndex,
|
||||
endIndex: range.startIndex + selected.length
|
||||
}
|
||||
}
|
||||
|
||||
function countPathStarts(text: string): number {
|
||||
let count = 0
|
||||
for (const match of text.matchAll(/(?:^|\s)(?:~[\\/]|[\\/]|\.{1,2}[\\/]|[A-Za-z]:[\\/])/g)) {
|
||||
void match
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function hasSpacedPathExtension(text: string): boolean {
|
||||
const trimmed = trimSpacedPathTrailingProse({
|
||||
text,
|
||||
startIndex: 0,
|
||||
endIndex: text.length
|
||||
})?.text.trimEnd()
|
||||
if (!trimmed) {
|
||||
return false
|
||||
}
|
||||
return /\s/.test(trimmed) && /\.[A-Za-z0-9_+-]+(?::\d+)?(?::\d+)?$/.test(trimmed)
|
||||
}
|
||||
|
||||
function matchSpacedFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
|
||||
SPACED_PATH_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = SPACED_PATH_REGEX.exec(lineText)) !== null) {
|
||||
const trimmed = trimBoundaryPunctuation(match[0], match.index)
|
||||
if (
|
||||
!trimmed ||
|
||||
(!hasSeparatorAfterWhitespace(trimmed.text) && !hasSpacedPathExtension(trimmed.text))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const candidate = trimSpacedPathTrailingProse(trimmed, col)
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
if (col < candidate.startIndex || col >= candidate.endIndex) {
|
||||
continue
|
||||
}
|
||||
const parsed = parsePathWithOptionalLineColumn(candidate.text)
|
||||
if (parsed) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Returns the file-path span (after punctuation trim) that contains `col`, or
|
||||
// null when the tap isn't on a path.
|
||||
export function matchFilePathAtColumn(lineText: string, col: number): TappedFilePath | null {
|
||||
const spaced = matchSpacedFilePathAtColumn(lineText, col)
|
||||
if (spaced) {
|
||||
return spaced
|
||||
}
|
||||
LOCAL_PATH_REGEX.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = LOCAL_PATH_REGEX.exec(lineText)) !== null) {
|
||||
@@ -78,8 +185,7 @@ export function matchFilePathAtColumn(lineText: string, col: number): TappedFile
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
// Inclusive of the trailing edge so a tap on the last glyph still counts.
|
||||
if (col < trimmed.startIndex || col > trimmed.endIndex) {
|
||||
if (col < trimmed.startIndex || col >= trimmed.endIndex) {
|
||||
continue
|
||||
}
|
||||
const parsed = parsePathWithOptionalLineColumn(trimmed.text)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
|
||||
export type TerminalModes = {
|
||||
bracketedPasteMode: boolean
|
||||
altScreen: boolean
|
||||
mouseTrackingMode: TerminalMouseTrackingMode
|
||||
sgrMouseMode: boolean
|
||||
sgrMousePixelsMode: boolean
|
||||
}
|
||||
|
||||
export type TerminalKeyboardAvoidanceMetrics = {
|
||||
cursorY: number
|
||||
rows: number
|
||||
altScreen: boolean
|
||||
}
|
||||
|
||||
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
|
||||
|
||||
export type TerminalSelectionEvents = {
|
||||
onSelectionMode?: (active: boolean) => void
|
||||
onSelectionCopy?: (text: string) => void
|
||||
onSelectionEvicted?: () => void
|
||||
onModesChanged?: (modes: TerminalModes) => void
|
||||
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
|
||||
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
|
||||
onTerminalInput?: (bytes: string) => void
|
||||
onTerminalTap?: () => void
|
||||
// Tap landed on a detected file path; RN resolves + opens it.
|
||||
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
|
||||
// WebView-detected URL tap; RN chooses the mobile routing destination.
|
||||
onOpenUrl?: (url: string) => void
|
||||
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
|
||||
// here so the app persists it and keeps Settings + other panes in sync.
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
rows: number,
|
||||
initialData?: string,
|
||||
preserveScroll?: boolean,
|
||||
oscLinks?: TerminalOscLinkRange[]
|
||||
) => void
|
||||
resize: (cols: number, rows: number) => void
|
||||
// Why: reflow the local xterm buffer (scrollback included) to a new width
|
||||
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
|
||||
// latest output. No-op on the alternate screen.
|
||||
reflow: (cols: number, rows: number) => void
|
||||
clear: () => void
|
||||
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
|
||||
resetZoom: () => void
|
||||
cancelSelect: () => void
|
||||
doSelectAll: () => void
|
||||
// Why: lets callers await the WebView-side `init` rAF chain (term.open
|
||||
// → renderService population → first paint) so a follow-up measure
|
||||
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
|
||||
// the next 'ready' notify after the most recent init.
|
||||
awaitReady: () => Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { RefreshCw } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export type NativeWebViewEngineEvent = {
|
||||
readonly nativeEvent?: object
|
||||
}
|
||||
|
||||
type TerminalWebViewEngineErrorOverlayProps = {
|
||||
readonly message: string
|
||||
readonly onReload: () => void
|
||||
}
|
||||
|
||||
type NativeWebViewEngineFields = {
|
||||
readonly description?: unknown
|
||||
readonly code?: unknown
|
||||
readonly statusCode?: unknown
|
||||
readonly domain?: unknown
|
||||
readonly didCrash?: unknown
|
||||
}
|
||||
|
||||
export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) {
|
||||
const [engineError, setEngineError] = useState<string | null>(null)
|
||||
const clearEngineError = useCallback(() => setEngineError(null), [])
|
||||
const reportEngineError = useCallback(
|
||||
(message: string, fatal: boolean) => {
|
||||
onEngineError?.(message)
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[terminal-webview] engine error', message)
|
||||
if (fatal) {
|
||||
// Why: the first fatal report is the root cause; later cascades (e.g. the
|
||||
// web-ready watchdog firing after a process-crash report) must not
|
||||
// overwrite its more specific diagnostics. clearEngineError resets.
|
||||
setEngineError((previous) => previous ?? message)
|
||||
}
|
||||
},
|
||||
[onEngineError]
|
||||
)
|
||||
const reportNativeEngineError = useCallback(
|
||||
(context: string, event?: NativeWebViewEngineEvent) => {
|
||||
reportEngineError(describeNativeWebViewEngineError(context, event), true)
|
||||
},
|
||||
[reportEngineError]
|
||||
)
|
||||
return { clearEngineError, engineError, reportEngineError, reportNativeEngineError }
|
||||
}
|
||||
|
||||
export function describeNativeWebViewEngineError(
|
||||
context: string,
|
||||
event?: NativeWebViewEngineEvent
|
||||
): string {
|
||||
const native = event?.nativeEvent as NativeWebViewEngineFields | undefined
|
||||
const parts = [context]
|
||||
const description = native?.description
|
||||
const statusCode = native?.statusCode
|
||||
const code = native?.code
|
||||
const domain = native?.domain
|
||||
if (typeof description === 'string') {
|
||||
parts.push(description)
|
||||
}
|
||||
if (typeof statusCode === 'number') {
|
||||
parts.push(`status ${statusCode}`)
|
||||
}
|
||||
if (typeof code === 'number') {
|
||||
parts.push(`code ${code}`)
|
||||
}
|
||||
if (typeof domain === 'string') {
|
||||
parts.push(domain)
|
||||
}
|
||||
if (native?.didCrash === true) {
|
||||
parts.push('renderer crashed')
|
||||
}
|
||||
return parts.join(' - ')
|
||||
}
|
||||
|
||||
export function TerminalWebViewEngineErrorOverlay({
|
||||
message,
|
||||
onReload
|
||||
}: TerminalWebViewEngineErrorOverlayProps) {
|
||||
return (
|
||||
<View style={styles.errorOverlay}>
|
||||
<Text style={styles.errorTitle}>Terminal failed to load</Text>
|
||||
<Text style={styles.errorDetail} numberOfLines={4}>
|
||||
{message}
|
||||
</Text>
|
||||
<Pressable accessibilityRole="button" style={styles.reloadButton} onPress={onReload}>
|
||||
<RefreshCw size={16} color={colors.terminalBg} />
|
||||
<Text style={styles.reloadButtonText}>Reload</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
errorOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: 24,
|
||||
backgroundColor: colors.terminalBg
|
||||
},
|
||||
errorTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center'
|
||||
},
|
||||
errorDetail: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center'
|
||||
},
|
||||
reloadButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
minHeight: 36,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.surfaceBright
|
||||
},
|
||||
reloadButtonText: {
|
||||
color: colors.terminalBg,
|
||||
fontSize: 14,
|
||||
fontWeight: '700'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0
|
||||
},
|
||||
create: (styles: unknown) => styles
|
||||
},
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', () => ({
|
||||
WebView: 'WebView',
|
||||
default: 'WebView'
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
}))
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
// Why: mounting TerminalWebView arms the web-ready watchdog; tests must
|
||||
// unmount so the timer can't survive into later tests and fire in teardown.
|
||||
let activeRenderer: ReactTestRenderer | null = null
|
||||
|
||||
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!renderer) {
|
||||
throw new Error('TerminalWebView did not render')
|
||||
}
|
||||
activeRenderer = renderer
|
||||
return { onEngineError, renderer }
|
||||
}
|
||||
|
||||
function postWebViewMessage(renderer: ReactTestRenderer, payload: Record<string, unknown>) {
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
act(() => {
|
||||
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
|
||||
})
|
||||
}
|
||||
|
||||
function renderedText(renderer: ReactTestRenderer): string {
|
||||
return renderer.root
|
||||
.findAllByType('Text')
|
||||
.flatMap((node) => node.props.children)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
describe('TerminalWebView engine errors', () => {
|
||||
afterEach(() => {
|
||||
if (activeRenderer) {
|
||||
act(() => {
|
||||
activeRenderer?.unmount()
|
||||
})
|
||||
activeRenderer = null
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders the reload overlay for fatal engine errors from the WebView', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'terminal engine missing - SyntaxError - Chrome 74',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74')
|
||||
expect(renderedText(renderer)).toContain('Terminal failed to load')
|
||||
expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74')
|
||||
expect(renderedText(renderer)).toContain('Reload')
|
||||
})
|
||||
|
||||
it('reports non-fatal engine errors without covering a live terminal', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: false,
|
||||
message: 'terminal message failed - malformed chunk',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk')
|
||||
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
|
||||
})
|
||||
|
||||
it('keeps the first fatal diagnostics when later fatal reports cascade', () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'terminal engine missing - root cause',
|
||||
type: 'error'
|
||||
})
|
||||
postWebViewMessage(renderer, {
|
||||
fatal: true,
|
||||
message: 'Terminal did not initialize - watchdog cascade',
|
||||
type: 'error'
|
||||
})
|
||||
|
||||
expect(renderedText(renderer)).toContain('terminal engine missing - root cause')
|
||||
expect(renderedText(renderer)).not.toContain('watchdog cascade')
|
||||
})
|
||||
|
||||
// Why: if the document dies before the glue can post anything (or the RN
|
||||
// bridge never comes up), no message and no native error handler fires —
|
||||
// the watchdog must convert that silence into the visible fatal overlay.
|
||||
it('paints the fatal overlay when web-ready never arrives', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(15000)
|
||||
})
|
||||
|
||||
expect(onEngineError).toHaveBeenCalledWith(
|
||||
'Terminal did not initialize - no ready signal from the terminal view'
|
||||
)
|
||||
expect(renderedText(renderer)).toContain('Terminal failed to load')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fire the watchdog once web-ready has arrived', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer()
|
||||
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60000)
|
||||
})
|
||||
|
||||
expect(onEngineError).not.toHaveBeenCalled()
|
||||
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { Script } from 'node:vm'
|
||||
import { parse } from 'acorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
const terminalHtmlSource = readFileSync(
|
||||
new URL('./terminal-webview-html.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
describe('terminal WebView bundled engine', () => {
|
||||
it('keeps the assembled terminal HTML free of external engine URLs', () => {
|
||||
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
|
||||
expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net')
|
||||
expect(XTERM_HTML).not.toContain('<script src=')
|
||||
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
|
||||
})
|
||||
|
||||
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
|
||||
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
|
||||
})
|
||||
|
||||
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
|
||||
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
|
||||
// the engine must evaluate on older WebViews via its own guarded runtime shims,
|
||||
// which are the linchpin of the old-WebView support (esbuild lowers syntax only).
|
||||
it('exposes the xterm globals and installs the old-WebView runtime shims', () => {
|
||||
const window: Record<string, unknown> = {}
|
||||
class ElementStub {}
|
||||
const context = {
|
||||
window,
|
||||
self: window,
|
||||
document: {},
|
||||
Element: ElementStub,
|
||||
navigator: {
|
||||
platform: 'Linux armv8l',
|
||||
userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157'
|
||||
},
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
queueMicrotask,
|
||||
URL
|
||||
}
|
||||
|
||||
new Script(XTERM_ENGINE_JS).runInNewContext(context)
|
||||
|
||||
expect(window).toMatchObject({
|
||||
Terminal: expect.any(Function),
|
||||
Unicode11Addon: { Unicode11Addon: expect.any(Function) },
|
||||
WebglAddon: { WebglAddon: expect.any(Function) }
|
||||
})
|
||||
|
||||
const weakRef = window.WeakRef as (new (target: unknown) => { deref(): unknown }) | undefined
|
||||
expect(typeof weakRef).toBe('function')
|
||||
const token = {}
|
||||
expect(new weakRef!(token).deref()).toBe(token)
|
||||
expect(typeof window.structuredClone).toBe('function')
|
||||
expect(typeof (ElementStub.prototype as { replaceChildren?: unknown }).replaceChildren).toBe(
|
||||
'function'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the bundled engine from breaking out of its inline script/style tags', () => {
|
||||
// Why: the engine JS/CSS are inlined into <script>/<style> blocks. </script
|
||||
// and </style are neutralized at build time; the tokenizer-escape openers that
|
||||
// could swallow the rest of the document must also be absent from the bundle.
|
||||
expect(XTERM_ENGINE_JS).not.toMatch(/<\/script/i)
|
||||
expect(XTERM_ENGINE_JS).not.toMatch(/<script/i)
|
||||
expect(XTERM_ENGINE_JS).not.toContain('<!--')
|
||||
expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i)
|
||||
})
|
||||
|
||||
it('reports WebView message handler failures instead of swallowing them', () => {
|
||||
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
|
||||
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const handlerSource = terminalHtmlSource.slice(start, end)
|
||||
|
||||
expect(handlerSource).toContain('reportEngineError(')
|
||||
expect(handlerSource).toContain("'terminal init failed'")
|
||||
expect(handlerSource).toContain("'terminal message failed'")
|
||||
expect(handlerSource).not.toContain('catch(ex) {}')
|
||||
})
|
||||
|
||||
it('classifies runtime errors by a document-scoped ever-ready latch', () => {
|
||||
// Why: init() flips `ready` false on every re-init (live width reflow keeps the
|
||||
// old surface visible meanwhile), so the fatal default and the init-catch must
|
||||
// key off `everReady` — otherwise a transient reflow error blanks a live
|
||||
// terminal behind the fatal overlay. The latch stays set for the document.
|
||||
expect(terminalHtmlSource).toContain('var everReady = false;')
|
||||
expect(terminalHtmlSource).toContain('everReady = true;')
|
||||
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
|
||||
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
|
||||
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
|
||||
})
|
||||
|
||||
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
|
||||
// Why: a constructed-but-broken engine can throw per render frame; both
|
||||
// onerror capture sites must cap the buffer and non-fatal notifies must
|
||||
// stop flooding RN while fatal reports always emit.
|
||||
const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? []
|
||||
expect(capSites.length).toBe(2)
|
||||
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
},
|
||||
webview: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.terminalBg
|
||||
}
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
@@ -47,7 +48,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/css/xterm.min.css">
|
||||
<script>
|
||||
window.__engineErrors = [];
|
||||
window.onerror = function(msg) {
|
||||
// Why: a degraded engine can throw per frame; cap so the capture buffer
|
||||
// and downstream reporting stay bounded for the document's lifetime.
|
||||
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
|
||||
};
|
||||
</script>
|
||||
<style>${XTERM_ENGINE_CSS}</style>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body {
|
||||
@@ -192,7 +201,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
<button id="sel-menu-all">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/lib/xterm.min.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.10.0-beta.285/lib/addon-unicode11.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.20.0-beta.284/lib/addon-webgl.js"></script>
|
||||
<script>${XTERM_ENGINE_JS}</script>
|
||||
<script>
|
||||
(function() {
|
||||
var surface = document.getElementById('terminal-surface');
|
||||
@@ -214,6 +223,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
var afterDrainCallbacks = [];
|
||||
var termObserverDisposables = [];
|
||||
var ready = false;
|
||||
// Why: init() flips ready false on every re-init (live width reflow included)
|
||||
// while the old surface stays visible; a document-scoped latch drives the
|
||||
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
|
||||
var everReady = false;
|
||||
var currentScale = 1;
|
||||
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
|
||||
// gesture only; it resets to 1 on release. The persistent "text size" is the
|
||||
@@ -240,6 +253,14 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
function fontPxForScale(scale) {
|
||||
return Math.max(MIN_FONT_PX, Math.round(BASE_FONT_PX * scale));
|
||||
}
|
||||
function isIOSWebView() {
|
||||
if (/iP(ad|hone|od)/.test(navigator.userAgent)) return true;
|
||||
return navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
||||
}
|
||||
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
|
||||
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
|
||||
var TERMINAL_FONT_FALLBACKS = '"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace';
|
||||
var terminalFontFamily = (isIOSWebView() ? 'ui-monospace, ' : '"SF Mono", ') + TERMINAL_FONT_FALLBACKS;
|
||||
// Why: change the real font size, then resize the grid to fit the viewport at
|
||||
// the new cell metrics so the text shows at its true size immediately. RN's
|
||||
// refit (measure → updateViewport) then makes the server reflow the PTY to the
|
||||
@@ -716,7 +737,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
cols: cols || 80,
|
||||
rows: rows || 24,
|
||||
theme: terminalTheme,
|
||||
fontFamily: '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace',
|
||||
fontFamily: terminalFontFamily,
|
||||
fontSize: fontPxForScale(currentTextScale),
|
||||
fontWeight: '300',
|
||||
fontWeightBold: '500',
|
||||
@@ -745,6 +766,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
requestAnimationFrame(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
ready = true;
|
||||
everReady = true;
|
||||
afterWritesDrained(function() {
|
||||
if (gen !== terminalGeneration) return;
|
||||
if (nextSurface && oldSurface) {
|
||||
@@ -804,6 +826,47 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
}
|
||||
}
|
||||
|
||||
function engineErrorText(err) {
|
||||
if (!err) return '';
|
||||
if (typeof err === 'string') return err;
|
||||
if (err && typeof err.message === 'string') return err.message;
|
||||
try { return String(err); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function chromeVersionText() {
|
||||
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
|
||||
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
|
||||
}
|
||||
|
||||
var nonFatalErrorNotifies = 0;
|
||||
|
||||
function reportEngineError(context, err, fatal) {
|
||||
var isFatal = fatal === undefined ? !everReady : !!fatal;
|
||||
if (!isFatal) {
|
||||
// Why: a constructed-but-degraded engine can throw per frame; cap
|
||||
// non-fatal notifies so RN isn't flooded. Fatal reports always emit.
|
||||
nonFatalErrorNotifies++;
|
||||
if (nonFatalErrorNotifies > 5) return;
|
||||
}
|
||||
var parts = [context];
|
||||
var errText = engineErrorText(err);
|
||||
if (errText) parts.push(errText);
|
||||
if (window.__engineErrors && window.__engineErrors.length) {
|
||||
parts.push('captured: ' + window.__engineErrors.join(' | '));
|
||||
}
|
||||
parts.push(chromeVersionText());
|
||||
notify({
|
||||
type: 'error',
|
||||
fatal: isFatal,
|
||||
message: parts.join(' - ')
|
||||
});
|
||||
}
|
||||
|
||||
window.onerror = function(msg, source, line, column, err) {
|
||||
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
|
||||
reportEngineError('terminal runtime error', err || msg);
|
||||
};
|
||||
|
||||
function measureFitDimensions(containerHeightPx, retriesLeft) {
|
||||
if (typeof retriesLeft !== 'number') retriesLeft = 30;
|
||||
// Why: init and measure are posted back-to-back from React, but
|
||||
@@ -1801,17 +1864,27 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
|
||||
attachSurfaceEventHandlers(surface);
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
function handleIncomingMessage(e) {
|
||||
var msg;
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
|
||||
} catch (ex) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
handleMsg(msg);
|
||||
} catch(ex) {
|
||||
reportEngineError(
|
||||
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
|
||||
ex,
|
||||
msg && msg.type === 'init' && !everReady
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('message', function(e) {
|
||||
try {
|
||||
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
|
||||
} catch(ex) {}
|
||||
});
|
||||
window.addEventListener('message', handleIncomingMessage);
|
||||
|
||||
document.addEventListener('message', handleIncomingMessage);
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
// Why: viewport changed (keyboard open/close, orientation, RN container
|
||||
@@ -1828,9 +1901,13 @@ export const XTERM_HTML = `<!DOCTYPE html>
|
||||
if (window.Terminal) {
|
||||
notify({ type: 'web-ready' });
|
||||
} else {
|
||||
notify({ type: 'error', message: 'xterm failed to load' });
|
||||
reportEngineError('terminal engine missing', 'xterm failed to load', true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Why: WebView treats source identity as page identity on some platforms; keep
|
||||
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
|
||||
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { AppState } from 'react-native'
|
||||
|
||||
// Why: if the document dies before the glue can post anything (or the RN
|
||||
// message bridge never comes up), no webview error and no native handler
|
||||
// fires — without a native watchdog that failure is a silent blank pane.
|
||||
const WEB_READY_WATCHDOG_MS = 15000
|
||||
|
||||
export function useTerminalWebReadyWatchdog(
|
||||
isWebReadyRef: RefObject<boolean>,
|
||||
reportEngineError: (message: string, fatal: boolean) => void
|
||||
) {
|
||||
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const clearWebReadyWatchdog = useCallback(() => {
|
||||
if (watchdogRef.current) {
|
||||
clearTimeout(watchdogRef.current)
|
||||
watchdogRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const armWebReadyWatchdog = useCallback(() => {
|
||||
clearWebReadyWatchdog()
|
||||
const fire = () => {
|
||||
watchdogRef.current = null
|
||||
if (isWebReadyRef.current) {
|
||||
return
|
||||
}
|
||||
if (AppState.currentState !== 'active') {
|
||||
// Why: backgrounded WebViews legitimately stall; only judge foreground loads.
|
||||
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
|
||||
return
|
||||
}
|
||||
reportEngineError(
|
||||
'Terminal did not initialize - no ready signal from the terminal view',
|
||||
true
|
||||
)
|
||||
}
|
||||
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
|
||||
}, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError])
|
||||
|
||||
useEffect(() => {
|
||||
armWebReadyWatchdog()
|
||||
return clearWebReadyWatchdog
|
||||
}, [armWebReadyWatchdog, clearWebReadyWatchdog])
|
||||
|
||||
return { armWebReadyWatchdog, clearWebReadyWatchdog }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
// @vitest-environment happy-dom
|
||||
// Exercises the in-WebView touch dispatcher end-to-end: a surface tap on a
|
||||
// printed http(s) URL must post an `open-url` message (which RN routes to the
|
||||
// in-app/phone browser). Regression guard for taps that jitter a few pixels —
|
||||
@@ -14,7 +14,7 @@ function iifeSource(): string {
|
||||
|
||||
function bodyMarkup(): string {
|
||||
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
|
||||
const end = XTERM_HTML.indexOf('<script src=')
|
||||
const end = XTERM_HTML.indexOf('<script>', start)
|
||||
return XTERM_HTML.slice(start, end)
|
||||
}
|
||||
|
||||
@@ -174,6 +174,39 @@ describe('terminal WebView tap routing', () => {
|
||||
expect(posted.find((m) => m.type === 'open-url')?.url).toBe('https://example.com/issue/1234')
|
||||
})
|
||||
|
||||
it('opens first-load file OSC links through terminal file taps', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 5, endCol: 15, uri: 'file:///tmp/result.json#L12C3' }]
|
||||
const { posted } = boot('open artifact here', oscLinks)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(7), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: '/tmp/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('opens plain file URLs through terminal file taps', async () => {
|
||||
const line = 'open file:///tmp/result.json#L12C3 now'
|
||||
const { posted } = boot(line)
|
||||
await settle()
|
||||
|
||||
fireTouch('touchstart', [{ x: screenXForCol(line.indexOf('result')), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toMatchObject({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: '/tmp/result.json',
|
||||
line: 12,
|
||||
column: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('does not open snapshot OSC links from adjacent terminal cells', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
|
||||
const { posted } = boot('issue #1234 done', oscLinks)
|
||||
@@ -185,6 +218,36 @@ describe('terminal WebView tap routing', () => {
|
||||
expect(posted.find((m) => m.type === 'open-url')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not parse off-cell snapshot file OSC links while routing a tap', async () => {
|
||||
const oscLinks = Array.from({ length: 50 }, (_, index) => ({
|
||||
row: index + 1,
|
||||
startCol: 0,
|
||||
endCol: 10,
|
||||
uri: `file:///tmp/result-${index}.json#L1`
|
||||
}))
|
||||
const { posted } = boot('plain text', oscLinks)
|
||||
await settle()
|
||||
|
||||
const OriginalURL = window.URL
|
||||
let parseCount = 0
|
||||
const CountingURL = class extends OriginalURL {
|
||||
constructor(url: string | URL, base?: string | URL) {
|
||||
parseCount += 1
|
||||
super(url, base)
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'URL', { value: CountingURL, configurable: true })
|
||||
try {
|
||||
fireTouch('touchstart', [{ x: screenXForCol(5), y: tapY }])
|
||||
fireTouch('touchend', [])
|
||||
} finally {
|
||||
Object.defineProperty(window, 'URL', { value: OriginalURL, configurable: true })
|
||||
}
|
||||
|
||||
expect(parseCount).toBe(0)
|
||||
expect(posted.find((m) => m.type === 'terminal-file-tap')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not open stale snapshot OSC links after the row text changes', async () => {
|
||||
const oscLinks = [{ row: 0, startCol: 6, endCol: 11, uri: 'https://example.com/issue/1234' }]
|
||||
const { posted, setLine } = boot('issue #1234 done', oscLinks)
|
||||
|
||||
@@ -32,6 +32,29 @@ output = chunks.map(function(chunk) { return normalizeStatusDotPresentation(chun
|
||||
return context.output ?? ''
|
||||
}
|
||||
|
||||
function resolveTerminalFontFamily(navigatorValue: {
|
||||
userAgent: string
|
||||
platform: string
|
||||
maxTouchPoints: number
|
||||
}) {
|
||||
// Slice only the font block itself (isIOSWebView + terminalFontFamily), anchored
|
||||
// on font-related markers so unrelated edits below it can't break this extraction.
|
||||
const functionStart = terminalHtmlSource.indexOf(' function isIOSWebView()')
|
||||
const declarationLine = terminalHtmlSource.indexOf(' var terminalFontFamily =', functionStart)
|
||||
const declarationEnd = terminalHtmlSource.indexOf('\n', declarationLine)
|
||||
expect(functionStart).toBeGreaterThanOrEqual(0)
|
||||
expect(declarationLine).toBeGreaterThan(functionStart)
|
||||
expect(declarationEnd).toBeGreaterThan(declarationLine)
|
||||
const context: { navigator: typeof navigatorValue; output?: string } = {
|
||||
navigator: navigatorValue
|
||||
}
|
||||
new Script(`
|
||||
${terminalHtmlSource.slice(functionStart, declarationEnd)}
|
||||
output = terminalFontFamily;
|
||||
`).runInNewContext(context)
|
||||
return context.output ?? ''
|
||||
}
|
||||
|
||||
describe('TerminalWebView text zoom', () => {
|
||||
it('pins textZoom to 100 so Android system font scale cannot inflate glyphs past xterm cell metrics', () => {
|
||||
const start = terminalWebViewSource.indexOf('<WebView')
|
||||
@@ -48,7 +71,7 @@ describe('TerminalWebView text zoom', () => {
|
||||
const end = terminalWebViewSource.indexOf('/>', start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
const webViewProps = terminalWebViewSource.slice(start, end)
|
||||
expect(terminalWebViewSource).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
|
||||
expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
|
||||
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
|
||||
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
|
||||
})
|
||||
@@ -113,8 +136,8 @@ describe('TerminalWebView text zoom', () => {
|
||||
})
|
||||
|
||||
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
|
||||
expect(terminalHtmlSource).toContain('@xterm/xterm@6.1.0-beta.285')
|
||||
expect(terminalHtmlSource).toContain('@xterm/addon-unicode11@0.10.0-beta.285')
|
||||
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
|
||||
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
|
||||
const open = terminalHtmlSource.indexOf('term.open(surface)')
|
||||
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
|
||||
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
|
||||
@@ -123,11 +146,55 @@ describe('TerminalWebView text zoom', () => {
|
||||
expect(replay).toBeGreaterThan(unicode)
|
||||
})
|
||||
|
||||
it('uses the newer WebGL-capable xterm stack and desktop font fallbacks', () => {
|
||||
expect(terminalHtmlSource).toContain('@xterm/addon-webgl@0.20.0-beta.284')
|
||||
expect(terminalHtmlSource).toContain('"SF Mono", "Menlo", "Monaco", "Cascadia Mono"')
|
||||
it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
|
||||
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
|
||||
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
|
||||
expect(terminalHtmlSource).toContain('function isIOSWebView()')
|
||||
expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily')
|
||||
expect(terminalHtmlSource).toContain("fontWeight: '300'")
|
||||
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
|
||||
expect(terminalHtmlSource).toContain('new window.WebglAddon.WebglAddon()')
|
||||
})
|
||||
|
||||
const IOS_IPHONE_NAVIGATOR = {
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||
platform: 'iPhone',
|
||||
maxTouchPoints: 5
|
||||
}
|
||||
const ANDROID_NAVIGATOR = {
|
||||
userAgent: 'Mozilla/5.0 (Linux; Android 16)',
|
||||
platform: 'Linux armv8l',
|
||||
maxTouchPoints: 5
|
||||
}
|
||||
|
||||
it('starts iOS WebViews on ui-monospace, never SF Mono, still ending in a generic monospace guarantee', () => {
|
||||
const fontFamily = resolveTerminalFontFamily(IOS_IPHONE_NAVIGATOR)
|
||||
expect(fontFamily.startsWith('ui-monospace, "Menlo"')).toBe(true)
|
||||
expect(fontFamily.startsWith('"SF Mono"')).toBe(false)
|
||||
// The chain must always terminate in the generic so it can never fall back to
|
||||
// a script/proportional system face — the actual iOS bug being fixed.
|
||||
expect(fontFamily.endsWith(', monospace')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats touch iPadOS WebViews that report MacIntel as iOS for font fallback', () => {
|
||||
const fontFamily = resolveTerminalFontFamily({
|
||||
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0) AppleWebKit/605.1.15',
|
||||
platform: 'MacIntel',
|
||||
maxTouchPoints: 5
|
||||
})
|
||||
expect(fontFamily.startsWith('ui-monospace, "Menlo"')).toBe(true)
|
||||
expect(fontFamily.startsWith('"SF Mono"')).toBe(false)
|
||||
expect(fontFamily.endsWith(', monospace')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the SF Mono lead outside iOS WebViews and shares the identical fallback tail', () => {
|
||||
const androidFontFamily = resolveTerminalFontFamily(ANDROID_NAVIGATOR)
|
||||
expect(androidFontFamily.startsWith('"SF Mono", "Menlo"')).toBe(true)
|
||||
expect(androidFontFamily.endsWith(', monospace')).toBe(true)
|
||||
// Only the lead family may differ across platforms; the rest of the chain is
|
||||
// shared so the two platforms cannot silently drift apart.
|
||||
const iosFontFamily = resolveTerminalFontFamily(IOS_IPHONE_NAVIGATOR)
|
||||
const tailFrom = (family: string) => family.slice(family.indexOf('"Menlo"'))
|
||||
expect(tailFrom(androidFontFamily)).toBe(tailFrom(iosFontFamily))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,140 @@
|
||||
import { createContext, Script } from 'node:vm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { TappedFilePath } from './terminal-path-tap'
|
||||
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import {
|
||||
TERMINAL_HTTP_URL_MAX_LENGTH,
|
||||
TERMINAL_HTTP_URL_REGEX_SOURCE,
|
||||
findUrlAtColumn
|
||||
URL_TAP_WEBVIEW_JS,
|
||||
findFileUrlAtColumn,
|
||||
findUrlAtColumn,
|
||||
resolveTerminalOscFileTap,
|
||||
resolveTerminalFileUrlTap
|
||||
} from './terminal-webview-url-tap'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
|
||||
type FileTapResolverCase = {
|
||||
name: string
|
||||
uri: string
|
||||
expected: TappedFilePath | null
|
||||
}
|
||||
|
||||
const FILE_URL_TAP_CASES: FileTapResolverCase[] = [
|
||||
{
|
||||
name: 'plain file URL',
|
||||
uri: 'file:///tmp/result.json',
|
||||
expected: { pathText: '/tmp/result.json', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'hash line and column',
|
||||
uri: 'file:///tmp/result.json#L12C3',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: 3 }
|
||||
},
|
||||
{
|
||||
name: 'trailing line and column suffix',
|
||||
uri: 'file:///tmp/result.json:8:2',
|
||||
expected: { pathText: '/tmp/result.json', line: 8, column: 2 }
|
||||
},
|
||||
{
|
||||
name: 'percent-encoded colon suffix stays in the path',
|
||||
uri: 'file:///tmp/report%3A8%3A2',
|
||||
expected: { pathText: '/tmp/report:8:2', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'Windows drive path with hash line',
|
||||
uri: 'file:///C:/repo/src/app.ts#L4',
|
||||
expected: { pathText: 'C:/repo/src/app.ts', line: 4, column: null }
|
||||
},
|
||||
{
|
||||
name: 'host-qualified POSIX authority is preserved',
|
||||
uri: 'file://remote-host/tmp/result.json#L12',
|
||||
expected: { pathText: '//remote-host/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'bracketed IPv6 loopback is local',
|
||||
uri: 'file://[::1]/tmp/result.json#L12',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'IPv4 loopback is local',
|
||||
uri: 'file://127.0.0.1/tmp/result.json#L12',
|
||||
expected: { pathText: '/tmp/result.json', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'UNC authority is preserved',
|
||||
uri: 'file://server/share/repo/app.ts#L12',
|
||||
expected: { pathText: '//server/share/repo/app.ts', line: 12, column: null }
|
||||
},
|
||||
{
|
||||
name: 'non-file scheme is rejected',
|
||||
uri: 'https://example.com/result.json',
|
||||
expected: null
|
||||
}
|
||||
]
|
||||
|
||||
const OSC_FILE_TAP_CASES: FileTapResolverCase[] = [
|
||||
{
|
||||
name: 'relative path',
|
||||
uri: 'docs/README.md',
|
||||
expected: { pathText: 'docs/README.md', line: null, column: null }
|
||||
},
|
||||
{
|
||||
name: 'tilde path with line and column',
|
||||
uri: '~/notes.md:4:2',
|
||||
expected: { pathText: '~/notes.md', line: 4, column: 2 }
|
||||
},
|
||||
{
|
||||
name: 'mailto target is rejected',
|
||||
uri: 'mailto:team@example.com',
|
||||
expected: null
|
||||
}
|
||||
]
|
||||
|
||||
type InjectedFileTapResolver = (uri: string) => TappedFilePath | null
|
||||
|
||||
// Why: the WebView blob hand-translates terminal-file-url-tap.ts into plain JS
|
||||
// with re-escaped regexes; executing it against the same cases as the TS module
|
||||
// keeps the two copies from drifting (mirrors createInjectedPathMatcher).
|
||||
function createInjectedFileTapResolvers(): {
|
||||
resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
} {
|
||||
const context = createContext({ URL })
|
||||
new Script(
|
||||
`${TERMINAL_PATH_TAP_JS}\n${URL_TAP_WEBVIEW_JS}\n` +
|
||||
'this.__resolveTerminalFileUrlTap = resolveTerminalFileUrlTap;\n' +
|
||||
'this.__resolveTerminalOscFileTap = resolveTerminalOscFileTap;'
|
||||
).runInContext(context)
|
||||
const injected = context as {
|
||||
__resolveTerminalFileUrlTap: InjectedFileTapResolver
|
||||
__resolveTerminalOscFileTap: InjectedFileTapResolver
|
||||
}
|
||||
return {
|
||||
resolveTerminalFileUrlTap: injected.__resolveTerminalFileUrlTap,
|
||||
resolveTerminalOscFileTap: injected.__resolveTerminalOscFileTap
|
||||
}
|
||||
}
|
||||
|
||||
describe.each([
|
||||
['module', { resolveTerminalFileUrlTap, resolveTerminalOscFileTap }],
|
||||
['injected', createInjectedFileTapResolvers()]
|
||||
] as const)('terminal file tap resolvers (%s)', (_variant, resolvers) => {
|
||||
it.each(FILE_URL_TAP_CASES)('resolves file URL: $name', ({ uri, expected }) => {
|
||||
expect(resolvers.resolveTerminalFileUrlTap(uri)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(OSC_FILE_TAP_CASES)('resolves OSC target: $name', ({ uri, expected }) => {
|
||||
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
|
||||
})
|
||||
|
||||
it.each(FILE_URL_TAP_CASES)('OSC resolver accepts file URL: $name', ({ uri, expected }) => {
|
||||
if (expected === null) {
|
||||
return
|
||||
}
|
||||
expect(resolvers.resolveTerminalOscFileTap(uri)).toEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findUrlAtColumn', () => {
|
||||
it('returns the URL when the tapped column falls inside it', () => {
|
||||
const line = 'see https://example.com/path for details'
|
||||
@@ -46,6 +175,13 @@ describe('findUrlAtColumn', () => {
|
||||
expect(findUrlAtColumn(line, line.indexOf('etc'))).toBeNull()
|
||||
})
|
||||
|
||||
it('finds file URLs separately so taps route to file opens', () => {
|
||||
const line = 'open file:///tmp/result.json#L12C3 please'
|
||||
|
||||
expect(findFileUrlAtColumn(line, line.indexOf('result'))).toBe('file:///tmp/result.json#L12C3')
|
||||
expect(findFileUrlAtColumn(line, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('matches desktop URL boundary and length guards', () => {
|
||||
expect(findUrlAtColumn('prefixhttps://example.com/path', 'prefix'.length)).toBeNull()
|
||||
expect(findUrlAtColumn('prefix https://example.com/path', 'prefix '.length)).toBe(
|
||||
@@ -58,9 +194,15 @@ describe('findUrlAtColumn', () => {
|
||||
|
||||
it('injects URL and OSC tap handling into the WebView document', () => {
|
||||
expect(XTERM_HTML).toContain('function findUrlAtColumn(')
|
||||
expect(XTERM_HTML).toContain('function findFileUrlAtColumn(')
|
||||
expect(XTERM_HTML).toContain('function fileUrlAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain('function urlAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain(JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE))
|
||||
expect(XTERM_HTML).toContain('function oscLinkAtViewportPoint(')
|
||||
expect(XTERM_HTML).toContain('function resolveTerminalOscFileTap(')
|
||||
expect(XTERM_HTML).toContain('function resolveTerminalFileUrlTap(')
|
||||
expect(XTERM_HTML).toContain('function isLocalFileUriHostname(')
|
||||
expect(XTERM_HTML).toContain('return parsePathLineCol(value);')
|
||||
expect(XTERM_HTML).toContain('function notifyTerminalSurfaceTap(')
|
||||
expect(XTERM_HTML).toContain("notify({ type: 'open-url', url: tappedUrl });")
|
||||
})
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
export { resolveTerminalFileUrlTap, resolveTerminalOscFileTap } from './terminal-file-url-tap'
|
||||
|
||||
export const TERMINAL_HTTP_URL_REGEX_SOURCE =
|
||||
String.raw`\bhttps?:\/\/[^\s"'!*(){}|\\^<>` +
|
||||
'`' +
|
||||
String.raw`]*[^\s"':,.!?{}|\\^~[\]` +
|
||||
'`' +
|
||||
String.raw`()<>]`
|
||||
export const TERMINAL_FILE_URL_REGEX_SOURCE =
|
||||
String.raw`\bfile:\/\/[^\s"'!*(){}|\\^<>` +
|
||||
'`' +
|
||||
String.raw`]*[^\s"',!?{}|\\^~[\]` +
|
||||
'`' +
|
||||
String.raw`()<>]`
|
||||
export const TERMINAL_HTTP_URL_MAX_LENGTH = 2048
|
||||
|
||||
export function findUrlAtColumn(lineText: string, col: number): string | null {
|
||||
return findTerminalUrlAtColumn(lineText, col, TERMINAL_HTTP_URL_REGEX_SOURCE)
|
||||
}
|
||||
|
||||
export function findFileUrlAtColumn(lineText: string, col: number): string | null {
|
||||
return findTerminalUrlAtColumn(lineText, col, TERMINAL_FILE_URL_REGEX_SOURCE)
|
||||
}
|
||||
|
||||
function findTerminalUrlAtColumn(lineText: string, col: number, source: string): string | null {
|
||||
if (typeof lineText !== 'string' || lineText.length === 0) {
|
||||
return null
|
||||
}
|
||||
const re = new RegExp(TERMINAL_HTTP_URL_REGEX_SOURCE, 'gi')
|
||||
const re = new RegExp(source, 'gi')
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = re.exec(lineText)) !== null) {
|
||||
const start = match.index
|
||||
@@ -30,10 +46,17 @@ export function findUrlAtColumn(lineText: string, col: number): string | null {
|
||||
|
||||
export const URL_TAP_WEBVIEW_JS = `
|
||||
var URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_HTTP_URL_REGEX_SOURCE)};
|
||||
var FILE_URL_TAP_RE_SOURCE = ${JSON.stringify(TERMINAL_FILE_URL_REGEX_SOURCE)};
|
||||
var URL_TAP_MAX_LENGTH = ${TERMINAL_HTTP_URL_MAX_LENGTH};
|
||||
function findUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findFileUrlAtColumn(lineText, col) {
|
||||
return findTerminalUrlAtColumn(lineText, col, FILE_URL_TAP_RE_SOURCE);
|
||||
}
|
||||
function findTerminalUrlAtColumn(lineText, col, source) {
|
||||
if (typeof lineText !== 'string' || lineText.length === 0) return null;
|
||||
var re = new RegExp(URL_TAP_RE_SOURCE, 'gi');
|
||||
var re = new RegExp(source, 'gi');
|
||||
var match;
|
||||
while ((match = re.exec(lineText)) !== null) {
|
||||
var end = match.index + match[0].length;
|
||||
@@ -42,6 +65,11 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function fileUrlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
return findFileUrlAtColumn(getLineText(cell.row), cellColToStringIndex(cell.row, cell.col));
|
||||
}
|
||||
function urlAtViewportPoint(clientX, clientY) {
|
||||
var cell = viewportToCell(clientX, clientY);
|
||||
if (!cell) return null;
|
||||
@@ -73,19 +101,81 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
if (!svc || !svc.getLinkData) return initialOscLinkAtCell(cell.row, cell.col);
|
||||
var data = svc.getLinkData(urlId);
|
||||
var uri = data && data.uri;
|
||||
return uri && /^https?:/i.test(uri) ? uri : null;
|
||||
return terminalOscLinkTarget(uri);
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
function initialOscLinkAtCell(row, col) {
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
var link = initialOscLinks[i];
|
||||
if (!link || typeof link.uri !== 'string' || !/^https?:/i.test(link.uri)) continue;
|
||||
if (!link || typeof link.uri !== 'string') continue;
|
||||
if (link.row < initialOscLinkRowOffset) continue;
|
||||
var shiftedRow = link.row - initialOscLinkRowOffset;
|
||||
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return link.uri;
|
||||
if (shiftedRow === row && col >= link.startCol && col < link.endCol && initialOscLinkTextStillMatches(link, shiftedRow)) return terminalOscLinkTarget(link.uri);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function terminalOscLinkTarget(uri) {
|
||||
if (typeof uri !== 'string') return null;
|
||||
if (/^https?:/i.test(uri)) return { kind: 'url', url: uri };
|
||||
var fileTap = resolveTerminalOscFileTap(uri);
|
||||
return fileTap ? { kind: 'file', fileTap: fileTap } : null;
|
||||
}
|
||||
function resolveTerminalOscFileTap(uri) {
|
||||
return resolveTerminalFileUrlTap(uri) || parseOscPathLikeTarget(uri);
|
||||
}
|
||||
function resolveTerminalFileUrlTap(uri) {
|
||||
var parsed;
|
||||
try {
|
||||
parsed = new URL(uri);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'file:') return null;
|
||||
var filePath;
|
||||
try {
|
||||
filePath = decodeURIComponent(parsed.pathname || '');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.hostname && !isLocalFileUriHostname(parsed.hostname)) {
|
||||
filePath = '//' + parsed.hostname + filePath;
|
||||
} else if (/^\\/[A-Za-z]:\\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
if (!filePath) return null;
|
||||
var hashTarget = parseFileUrlLineHash(parsed.hash || '');
|
||||
if (hashTarget) {
|
||||
return { pathText: filePath, line: hashTarget.line, column: hashTarget.column };
|
||||
}
|
||||
if (/%3a/i.test(parsed.pathname || '')) {
|
||||
return { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
return parseFilePathTrailingLineTarget(filePath) || { pathText: filePath, line: null, column: null };
|
||||
}
|
||||
function isLocalFileUriHostname(hostname) {
|
||||
var normalized = String(hostname).toLowerCase();
|
||||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1' || normalized === '[::1]';
|
||||
}
|
||||
function parseOscPathLikeTarget(value) {
|
||||
if (!/^(?:~[\\\\/]|[\\\\/]|\\.{1,2}[\\\\/]|[A-Za-z]:[\\\\/]|[A-Za-z0-9._-]+[\\\\/]|(?=[A-Za-z0-9._-]*\\.[A-Za-z0-9]))/.test(value)) return null;
|
||||
return parsePathLineCol(value);
|
||||
}
|
||||
function parseFileUrlLineHash(hash) {
|
||||
var match = /^#?L(\\d+)(?:C(\\d+))?$/i.exec(hash);
|
||||
if (!match) return null;
|
||||
var line = parseInt(match[1], 10);
|
||||
var column = match[2] ? parseInt(match[2], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { line: line, column: column };
|
||||
}
|
||||
function parseFilePathTrailingLineTarget(filePath) {
|
||||
var match = /^(.*?)(?::(\\d+))(?::(\\d+))?$/.exec(filePath);
|
||||
if (!match || !match[1] || match[1].charAt(match[1].length - 1) === '/' || match[1].charAt(match[1].length - 1) === '\\\\') return null;
|
||||
var line = parseInt(match[2], 10);
|
||||
var column = match[3] ? parseInt(match[3], 10) : null;
|
||||
if (line < 1 || (column !== null && column < 1)) return null;
|
||||
return { pathText: match[1], line: line, column: column };
|
||||
}
|
||||
function captureInitialOscLinkTexts() {
|
||||
if (!Array.isArray(initialOscLinks)) return;
|
||||
for (var i = 0; i < initialOscLinks.length; i++) {
|
||||
@@ -116,7 +206,28 @@ export const URL_TAP_WEBVIEW_JS = `
|
||||
}
|
||||
|
||||
function notifyTerminalSurfaceTap(originX, originY) {
|
||||
var tappedUrl = oscLinkAtViewportPoint(originX, originY) || urlAtViewportPoint(originX, originY);
|
||||
var tappedOscLink = oscLinkAtViewportPoint(originX, originY);
|
||||
if (tappedOscLink && tappedOscLink.kind === 'file') {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedOscLink.fileTap.pathText,
|
||||
line: tappedOscLink.fileTap.line,
|
||||
column: tappedOscLink.fileTap.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedFileUrl = fileUrlAtViewportPoint(originX, originY);
|
||||
var tappedFileUrlPath = tappedFileUrl ? resolveTerminalFileUrlTap(tappedFileUrl) : null;
|
||||
if (tappedFileUrlPath) {
|
||||
notify({
|
||||
type: 'terminal-file-tap',
|
||||
pathText: tappedFileUrlPath.pathText,
|
||||
line: tappedFileUrlPath.line,
|
||||
column: tappedFileUrlPath.column
|
||||
});
|
||||
return;
|
||||
}
|
||||
var tappedUrl = tappedOscLink && tappedOscLink.kind === 'url' ? tappedOscLink.url : urlAtViewportPoint(originX, originY);
|
||||
if (tappedUrl) {
|
||||
notify({ type: 'open-url', url: tappedUrl });
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getTerminalLiveAccessoryInactiveInputCommitResult } from './use-terminal-live-accessory-input-commit'
|
||||
import { createElement, type RefObject } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
getTerminalLiveAccessoryInactiveInputCommitResult,
|
||||
useTerminalLiveAccessoryInputCommit,
|
||||
type TerminalLiveAccessoryInputCommitResult
|
||||
} from './use-terminal-live-accessory-input-commit'
|
||||
|
||||
type DeferredBoolean = {
|
||||
readonly promise: Promise<boolean>
|
||||
@@ -16,7 +25,111 @@ function createDeferredBoolean(): DeferredBoolean {
|
||||
return { promise, resolve: resolvePromise }
|
||||
}
|
||||
|
||||
describe('terminal live accessory input commit', () => {
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarnessOptions = {
|
||||
readonly heldText?: string
|
||||
readonly sentText?: string
|
||||
readonly pendingHandle?: string | null
|
||||
readonly sendResult?: boolean
|
||||
readonly flushResult?: boolean
|
||||
readonly waitResult?: boolean
|
||||
}
|
||||
|
||||
type AccessoryInputCommitHarness = {
|
||||
readonly commit: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly sent: readonly string[]
|
||||
readonly applyLiveInputMirror: ReturnType<typeof vi.fn>
|
||||
readonly flushPendingLiveInputText: ReturnType<typeof vi.fn>
|
||||
readonly waitForPendingLiveInputFlush: ReturnType<typeof vi.fn>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
function createAccessoryInputCommitHarness({
|
||||
heldText = '',
|
||||
sentText = '',
|
||||
pendingHandle = null,
|
||||
sendResult = true,
|
||||
flushResult = true,
|
||||
waitResult = true
|
||||
}: AccessoryInputCommitHarnessOptions = {}): AccessoryInputCommitHarness {
|
||||
const activeHandle = 'terminal-a'
|
||||
const heldLiveInputTextRef: RefObject<string> = { current: heldText }
|
||||
const sentLiveInputTextRef: RefObject<string> = { current: sentText }
|
||||
const pendingLiveInputHandleRef: RefObject<string | null> = { current: pendingHandle }
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const sent: string[] = []
|
||||
const sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender> = {
|
||||
current: async (_handle, bytes) => {
|
||||
sent.push(bytes)
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
const applyLiveInputMirror = vi.fn((_handle: string, _fieldText: string) => {})
|
||||
const clearPendingLiveInputCommit = vi.fn(() => {})
|
||||
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
|
||||
const waitForPendingLiveInputFlush = vi.fn(async () => waitResult)
|
||||
const setLiveInputCapture = vi.fn((_text: string) => {})
|
||||
|
||||
let commit: AccessoryInputCommitHarness['commit'] | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
commit = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!commit || !renderer) {
|
||||
throw new Error('terminal live accessory input hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
commit,
|
||||
sent,
|
||||
applyLiveInputMirror,
|
||||
flushPendingLiveInputText,
|
||||
waitForPendingLiveInputFlush,
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live accessory inactive input commit result', () => {
|
||||
it('Given live input is disabled with an active flush When accessory raw fallback is requested Then waits before allowing raw send', async () => {
|
||||
// Given
|
||||
const deferredFlush = createDeferredBoolean()
|
||||
@@ -50,3 +163,68 @@ describe('terminal live accessory input commit', () => {
|
||||
expect(result).toEqual({ kind: 'suppress-raw' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live accessory input commit hook', () => {
|
||||
it('Given raw accessory bytes with a held syllable When committed Then flushes held text before sending bytes', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
// Then
|
||||
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
|
||||
expect(harness.sent).toEqual(['\x1b'])
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
|
||||
it('Given raw accessory bytes with no held text When committed Then allows the raw send without flushing', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({ pendingHandle: null })
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x1b' })
|
||||
|
||||
// Then
|
||||
expect(result).toEqual({ kind: 'allow-raw' })
|
||||
expect(harness.flushPendingLiveInputText).not.toHaveBeenCalled()
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with a held syllable When committed Then mirrors the emptied field without terminal bytes', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '한',
|
||||
sentText: '',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', '')
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
expect(harness.sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given accessory backspace with mirrored sent text When committed Then mirrors the shortened field so the diff emits DEL', async () => {
|
||||
// Given
|
||||
const harness = createAccessoryInputCommitHarness({
|
||||
heldText: '',
|
||||
sentText: 'ab',
|
||||
pendingHandle: 'terminal-a'
|
||||
})
|
||||
|
||||
// When
|
||||
const result = await harness.commit({ bytes: '\x7f', localEdit: 'backspace' })
|
||||
|
||||
// Then
|
||||
expect(harness.applyLiveInputMirror).toHaveBeenCalledWith('terminal-a', 'a')
|
||||
expect(result).toEqual({ kind: 'handled' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,8 +2,7 @@ import { useCallback, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveAccessoryBytesDecision,
|
||||
getTerminalLiveAccessoryLocalEditText,
|
||||
getTerminalLiveDeferredTextDelayMs
|
||||
getTerminalLiveAccessoryLocalEditText
|
||||
} from './terminal-live-text-commit'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
@@ -14,12 +13,6 @@ export type TerminalLiveAccessoryInputCommitResult =
|
||||
| { readonly kind: 'handled' }
|
||||
| { readonly kind: 'suppress-raw' }
|
||||
|
||||
type TerminalLiveInputCommitScheduler = (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
|
||||
export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
): Promise<TerminalLiveAccessoryInputCommitResult> {
|
||||
@@ -28,13 +21,14 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
|
||||
|
||||
type TerminalLiveAccessoryInputCommitOptions = {
|
||||
readonly activeHandle: string | null
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly liveInputRef: RefObject<TextInput | null>
|
||||
readonly liveInputTerminalHandles: ReadonlySet<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: TerminalLiveInputCommitScheduler
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly sendLiveTerminalInputRef: RefObject<TerminalLiveInputSender>
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
@@ -42,13 +36,14 @@ type TerminalLiveAccessoryInputCommitOptions = {
|
||||
|
||||
export function useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -63,40 +58,33 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
if (!liveInputTerminalHandles.has(activeHandle)) {
|
||||
return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush)
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, pendingText })
|
||||
const heldText = ownsPendingState ? heldLiveInputTextRef.current : ''
|
||||
const sentText = ownsPendingState ? sentLiveInputTextRef.current : ''
|
||||
const decision = getTerminalLiveAccessoryBytesDecision({ ...input, heldText, sentText })
|
||||
switch (decision.kind) {
|
||||
case 'send-now':
|
||||
// Why: raw accessory bytes must wait behind any in-flight IME text
|
||||
// flush so composed Hangul reaches the PTY before follow-up controls.
|
||||
// Why: raw accessory bytes must wait behind any in-flight mirror send
|
||||
// so composed Hangul reaches the PTY before follow-up controls.
|
||||
return (await waitForPendingLiveInputFlush())
|
||||
? { kind: 'allow-raw' }
|
||||
: { kind: 'suppress-raw' }
|
||||
case 'local-edit': {
|
||||
const editedText = getTerminalLiveAccessoryLocalEditText({
|
||||
localEdit: decision.localEdit,
|
||||
pendingText
|
||||
fieldText: sentText + heldText
|
||||
})
|
||||
if (editedText.length === 0) {
|
||||
clearPendingLiveInputCommit()
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
// Why: accessory buttons do not emit native TextInput edits, so the
|
||||
// pending IME buffer must be edited and rescheduled here.
|
||||
// field is edited here and the mirror diff syncs the PTY echo.
|
||||
setLiveInputCapture(editedText)
|
||||
liveInputRef.current?.setNativeProps({ text: editedText })
|
||||
schedulePendingLiveInputCommit(
|
||||
activeHandle,
|
||||
editedText,
|
||||
getTerminalLiveDeferredTextDelayMs(editedText)
|
||||
)
|
||||
applyLiveInputMirror(activeHandle, editedText)
|
||||
return { kind: 'handled' }
|
||||
}
|
||||
case 'flush-then-send':
|
||||
case 'commit-held-then-send':
|
||||
await sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
@@ -109,13 +97,14 @@ export function useTerminalLiveAccessoryInputCommit({
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
|
||||
@@ -3,13 +3,14 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS } from './terminal-live-text-commit'
|
||||
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
|
||||
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
readonly sent: readonly string[]
|
||||
readonly setActiveSessionTabType: (next: string | undefined) => void
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
@@ -36,6 +37,9 @@ function createTerminalLiveInputCommitHarness({
|
||||
const activeHandleRef: RefObject<string | null> = { current: activeHandle }
|
||||
const activeSessionTabTypeRef: RefObject<string | null> = { current: 'terminal' }
|
||||
const captures: string[] = []
|
||||
const setLiveInputCapture = (text: string): void => {
|
||||
captures.push(text)
|
||||
}
|
||||
const liveInputRef: RefObject<TextInput | null> = { current: null }
|
||||
const liveInputTerminalHandles = new Set([activeHandle])
|
||||
const liveInputTerminalHandlesRef: RefObject<Set<string>> = {
|
||||
@@ -48,6 +52,9 @@ function createTerminalLiveInputCommitHarness({
|
||||
return sendResult
|
||||
}
|
||||
}
|
||||
// The hook keeps live-input state in refs, so a change handler alone never
|
||||
// re-renders; only a prop change (this variable) re-runs the pending-clear effect.
|
||||
let currentActiveSessionTabType: string | undefined = 'terminal'
|
||||
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
@@ -55,13 +62,13 @@ function createTerminalLiveInputCommitHarness({
|
||||
handlers = useTerminalLiveInputCommit({
|
||||
activeHandle,
|
||||
activeHandleRef,
|
||||
activeSessionTabType: 'terminal',
|
||||
activeSessionTabType: currentActiveSessionTabType,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture: (text) => captures.push(text)
|
||||
setLiveInputCapture
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -82,6 +89,15 @@ function createTerminalLiveInputCommitHarness({
|
||||
captures,
|
||||
handlers,
|
||||
sent,
|
||||
setActiveSessionTabType: (next: string | undefined): void => {
|
||||
currentActiveSessionTabType = next
|
||||
// Ref and prop derive from the same activeSessionTab in the real route, so
|
||||
// they go null together during tab-list lag — keep the harness coupled.
|
||||
activeSessionTabTypeRef.current = next ?? null
|
||||
act(() => {
|
||||
renderer?.update(createElement(Harness))
|
||||
})
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
@@ -93,18 +109,48 @@ describe('terminal live input commit hook', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When the old idle window elapses Then does not send jamo to the terminal', async () => {
|
||||
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
|
||||
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
|
||||
handlers.handleLiveInputChange(fieldText)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
|
||||
// Then: only the stable prefix went out; the trailing syllable is held
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('ㅎ')
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['ㅎ'])
|
||||
expect(sent).toEqual([])
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given a timer-committed syllable When composition continues Then corrects with DEL and recommits', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('하')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하']))
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
@@ -119,6 +165,55 @@ describe('terminal live input commit hook', () => {
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given no pending text When submit is requested Then sends only carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['\r']))
|
||||
})
|
||||
|
||||
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// Then: the held commit went out but was not accepted, so no \r follows
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
})
|
||||
|
||||
it('Given ASCII typing When changes arrive Then mirrors immediately', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('a')
|
||||
handlers.handleLiveInputChange('ab')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한 ')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
@@ -145,34 +240,22 @@ describe('terminal live input commit hook', () => {
|
||||
expect(sent).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('Given Chinese and Vietnamese IME text When the settle window elapses Then sends the committed text', async () => {
|
||||
it('Given non-Hangul IME text When changes arrive Then mirrors immediately without a settle window', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS - 1)
|
||||
|
||||
// Then
|
||||
expect(captures).toEqual(['你好'])
|
||||
expect(sent).toEqual([])
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
handlers.handleLiveInputChange('tiếng Việt')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_TEXT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好', 'tiếng Việt']))
|
||||
})
|
||||
|
||||
it('Given deferred IME text When the hook unmounts Then cancels the pending commit timer', async () => {
|
||||
it('Given a held syllable When the hook unmounts Then cancels the settle timer', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('é')
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
@@ -181,4 +264,54 @@ describe('terminal live input commit hook', () => {
|
||||
// Then
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual([]))
|
||||
})
|
||||
|
||||
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\t']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When: the mobile tab list momentarily yields no active tab object
|
||||
setActiveSessionTabType(undefined)
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then: an unknown tab type is not "left the terminal", so pending still flushes
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한', '\r']))
|
||||
})
|
||||
|
||||
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
|
||||
// When: the active tab actually becomes a non-terminal (chat) tab
|
||||
setActiveSessionTabType('chat')
|
||||
handlers.handleLiveInputSubmit()
|
||||
|
||||
// Then: pending was dropped, so submit sends only the carriage return
|
||||
await vi.waitFor(() => expect(sent).toEqual(['\r']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useCallback, useEffect, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import {
|
||||
getTerminalLiveSpecialKeyDecision,
|
||||
getTerminalLiveSubmitSequence,
|
||||
getTerminalLiveTextChangeDecision
|
||||
} from './terminal-live-text-commit'
|
||||
import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import type { TerminalLiveAccessoryInput } from './terminal-live-accessory-input'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
@@ -56,11 +52,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
setLiveInputCapture
|
||||
}: TerminalLiveInputCommitOptions<TTabType>): TerminalLiveInputCommitHandlers {
|
||||
const {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
} = useTerminalLivePendingInputFlush({
|
||||
activeHandleRef,
|
||||
@@ -76,10 +73,13 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!pendingHandle) {
|
||||
return
|
||||
}
|
||||
// Why: a lagging mobile tab list briefly yields no active tab object; a
|
||||
// null/undefined type is "unknown", not "left the terminal" — flush guards
|
||||
// still block sends if the tab truly changed.
|
||||
if (
|
||||
!activeHandle ||
|
||||
pendingHandle !== activeHandle ||
|
||||
activeSessionTabType !== 'terminal' ||
|
||||
(activeSessionTabType != null && activeSessionTabType !== 'terminal') ||
|
||||
!liveInputTerminalHandles.has(activeHandle)
|
||||
) {
|
||||
clearPendingLiveInputCommit()
|
||||
@@ -93,7 +93,9 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (pendingHandle === handle && pendingLiveInputTextRef.current.length > 0) {
|
||||
// Why: external bytes (dictation/paste) land after the field's echo on the
|
||||
// PTY; the field session must fully end or later diffs would erase them.
|
||||
if (pendingHandle === handle) {
|
||||
return flushPendingLiveInputText(handle)
|
||||
}
|
||||
return waitForPendingLiveInputFlush()
|
||||
@@ -108,35 +110,15 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
return
|
||||
}
|
||||
const normalizedText = normalizeTerminalTextInput(text)
|
||||
const decision = getTerminalLiveTextChangeDecision(normalizedText)
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.text)
|
||||
)
|
||||
return
|
||||
case 'defer':
|
||||
// Why: React Native does not expose composition events here, so keep
|
||||
// probable IME text in the native field until the commit timer settles.
|
||||
setLiveInputCapture(decision.text)
|
||||
schedulePendingLiveInputCommit(activeHandle, decision.text, decision.delayMs)
|
||||
return
|
||||
default:
|
||||
decision satisfies never
|
||||
}
|
||||
setLiveInputCapture(normalizedText)
|
||||
applyLiveInputMirror(activeHandle, normalizedText)
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
liveInputTerminalHandles,
|
||||
schedulePendingLiveInputCommit,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
setLiveInputCapture
|
||||
]
|
||||
)
|
||||
|
||||
@@ -145,26 +127,25 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
if (pendingLiveInputHandleRef.current && pendingLiveInputHandleRef.current !== activeHandle) {
|
||||
const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle
|
||||
if (pendingLiveInputHandleRef.current && !ownsPendingState) {
|
||||
clearPendingLiveInputCommit()
|
||||
}
|
||||
const decision = getTerminalLiveSpecialKeyDecision({
|
||||
key: event.nativeEvent.key,
|
||||
pendingText
|
||||
heldText: ownsPendingState ? heldLiveInputTextRef.current : '',
|
||||
sentText: ownsPendingState ? sentLiveInputTextRef.current : ''
|
||||
})
|
||||
switch (decision.kind) {
|
||||
case 'ignore':
|
||||
case 'local-edit':
|
||||
return
|
||||
case 'send-now':
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
)
|
||||
return
|
||||
case 'flush-then-send':
|
||||
case 'commit-held-then-send':
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
|
||||
@@ -186,13 +167,14 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
|
||||
const handleLiveInputAccessoryBytes = useTerminalLiveAccessoryInputCommit({
|
||||
activeHandle,
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
liveInputRef,
|
||||
liveInputTerminalHandles,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture,
|
||||
waitForPendingLiveInputFlush
|
||||
@@ -202,28 +184,11 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
return
|
||||
}
|
||||
const pendingText =
|
||||
pendingLiveInputHandleRef.current === activeHandle ? pendingLiveInputTextRef.current : ''
|
||||
const sequence = getTerminalLiveSubmitSequence(pendingText)
|
||||
if (sequence.length === 2) {
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, sequence[1])
|
||||
)
|
||||
return
|
||||
}
|
||||
clearPendingLiveInputCommit()
|
||||
void sendTerminalLiveControlAfterPendingFlush(waitForPendingLiveInputFlush, () =>
|
||||
sendLiveTerminalInputRef.current(activeHandle, sequence[0])
|
||||
void sendTerminalLiveControlAfterPendingFlush(
|
||||
() => flushPendingLiveInputText(activeHandle),
|
||||
() => sendLiveTerminalInputRef.current(activeHandle, '\r')
|
||||
)
|
||||
}, [
|
||||
activeHandle,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
liveInputTerminalHandles,
|
||||
sendLiveTerminalInputRef,
|
||||
waitForPendingLiveInputFlush
|
||||
])
|
||||
}, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef])
|
||||
|
||||
return {
|
||||
clearPendingLiveInputCommit,
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import type { TextInput } from 'react-native'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
queueTerminalLivePendingFlush,
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
|
||||
} from './terminal-live-hangul-mirror'
|
||||
import {
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
@@ -16,15 +21,12 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
|
||||
}
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
readonly pendingLiveInputHandleRef: RefObject<string | null>
|
||||
readonly pendingLiveInputTextRef: RefObject<string>
|
||||
readonly schedulePendingLiveInputCommit: (
|
||||
handle: string,
|
||||
text: string,
|
||||
delayMs: number | null
|
||||
) => void
|
||||
readonly sentLiveInputTextRef: RefObject<string>
|
||||
readonly waitForPendingLiveInputFlush: () => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -36,104 +38,143 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
|
||||
const liveInputCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
|
||||
const pendingLiveInputTextRef = useRef('')
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
const runMirrorStepRef = useRef<
|
||||
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
|
||||
>(async () => false)
|
||||
|
||||
const clearHeldCommitTimer = useCallback(() => {
|
||||
if (heldCommitTimerRef.current) {
|
||||
clearTimeout(heldCommitTimerRef.current)
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const resetMirrorState = useCallback(() => {
|
||||
clearHeldCommitTimer()
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
}, [clearHeldCommitTimer])
|
||||
|
||||
const clearPendingLiveInputCommit = useCallback(() => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
resetMirrorState()
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
}, [liveInputRef, setLiveInputCapture])
|
||||
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
|
||||
|
||||
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
|
||||
}, [])
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const existingFlush = pendingLiveInputFlushRef.current
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
}
|
||||
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
const text = pendingLiveInputTextRef.current
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
setLiveInputCapture('')
|
||||
liveInputRef.current?.setNativeProps({ text: '' })
|
||||
|
||||
if (!handle || text.length === 0) {
|
||||
return existingFlush ?? false
|
||||
}
|
||||
const runMirrorStep = useCallback(
|
||||
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
|
||||
if (
|
||||
(expectedHandle !== null && handle !== expectedHandle) ||
|
||||
handle !== activeHandleRef.current ||
|
||||
activeSessionTabTypeRef.current !== 'terminal' ||
|
||||
(activeSessionTabTypeRef.current != null &&
|
||||
activeSessionTabTypeRef.current !== 'terminal') ||
|
||||
!liveInputTerminalHandlesRef.current.has(handle)
|
||||
) {
|
||||
// Why: a stale handle must not keep local mirror state alive — the next
|
||||
// active terminal would inherit wrong erase counts. A null tab type is
|
||||
// "unknown" during tab-list lag, not "left the terminal", so it must not trip.
|
||||
resetMirrorState()
|
||||
return false
|
||||
}
|
||||
|
||||
return queueTerminalLivePendingFlush(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, text)
|
||||
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
|
||||
commitHeld
|
||||
})
|
||||
sentLiveInputTextRef.current = step.nextSentText
|
||||
heldLiveInputTextRef.current = step.heldText
|
||||
pendingLiveInputHandleRef.current =
|
||||
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
|
||||
|
||||
clearHeldCommitTimer()
|
||||
if (step.heldText.length > 0) {
|
||||
heldCommitTimerRef.current = setTimeout(() => {
|
||||
heldCommitTimerRef.current = null
|
||||
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
|
||||
void runMirrorStepRef.current(handle, heldField, true)
|
||||
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
}
|
||||
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length === 0) {
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, payload)
|
||||
)
|
||||
},
|
||||
[
|
||||
activeHandleRef,
|
||||
activeSessionTabTypeRef,
|
||||
liveInputRef,
|
||||
clearHeldCommitTimer,
|
||||
liveInputTerminalHandlesRef,
|
||||
resetMirrorState,
|
||||
sendLiveTerminalInputRef,
|
||||
setLiveInputCapture
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
runMirrorStepRef.current = runMirrorStep
|
||||
|
||||
const schedulePendingLiveInputCommit = useCallback(
|
||||
(handle: string, text: string, delayMs: number | null) => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
}
|
||||
pendingLiveInputHandleRef.current = handle
|
||||
pendingLiveInputTextRef.current = text
|
||||
if (delayMs === null) {
|
||||
liveInputCommitTimerRef.current = null
|
||||
return
|
||||
}
|
||||
liveInputCommitTimerRef.current = setTimeout(() => {
|
||||
liveInputCommitTimerRef.current = null
|
||||
void flushPendingLiveInputText(handle)
|
||||
}, delayMs)
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string): void => {
|
||||
void runMirrorStep(handle, fieldText, false)
|
||||
},
|
||||
[flushPendingLiveInputText]
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
const flushPendingLiveInputText = useCallback(
|
||||
async (expectedHandle: string | null): Promise<boolean> => {
|
||||
const handle = pendingLiveInputHandleRef.current
|
||||
if (!handle) {
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
if (expectedHandle !== null && handle !== expectedHandle) {
|
||||
clearPendingLiveInputCommit()
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
|
||||
const heldText = heldLiveInputTextRef.current
|
||||
const result =
|
||||
heldText.length > 0
|
||||
? await runMirrorStep(handle, sentLiveInputTextRef.current + heldText, true)
|
||||
: await waitForPendingLiveInputFlush()
|
||||
|
||||
// Why: an explicit flush ends the field's editing session; the echoed PTY
|
||||
// text stays, so local mirror state must restart from empty.
|
||||
clearPendingLiveInputCommit()
|
||||
return result
|
||||
},
|
||||
[clearPendingLiveInputCommit, runMirrorStep, waitForPendingLiveInputFlush]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (liveInputCommitTimerRef.current) {
|
||||
clearTimeout(liveInputCommitTimerRef.current)
|
||||
liveInputCommitTimerRef.current = null
|
||||
if (heldCommitTimerRef.current) {
|
||||
clearTimeout(heldCommitTimerRef.current)
|
||||
heldCommitTimerRef.current = null
|
||||
}
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputTextRef.current = ''
|
||||
pendingLiveInputFlushRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
applyLiveInputMirror,
|
||||
clearPendingLiveInputCommit,
|
||||
flushPendingLiveInputText,
|
||||
heldLiveInputTextRef,
|
||||
pendingLiveInputHandleRef,
|
||||
pendingLiveInputTextRef,
|
||||
schedulePendingLiveInputCommit,
|
||||
sentLiveInputTextRef,
|
||||
waitForPendingLiveInputFlush
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { handleTerminalBinaryFrame } from './rpc-client-terminal-binary-frame'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
|
||||
function encodeFrame(opcode: TerminalStreamOpcode, streamId: number, payload: unknown): Uint8Array {
|
||||
const body =
|
||||
typeof payload === 'string'
|
||||
? new TextEncoder().encode(payload)
|
||||
: new TextEncoder().encode(JSON.stringify(payload))
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: body
|
||||
})
|
||||
}
|
||||
|
||||
describe('handleTerminalBinaryFrame', () => {
|
||||
it('routes terminal metadata frames to the stream listener', () => {
|
||||
const listener = vi.fn()
|
||||
const recordValidatedInboundTraffic = vi.fn()
|
||||
|
||||
handleTerminalBinaryFrame(
|
||||
encodeFrame(TerminalStreamOpcode.Metadata, 42, { cwd: '/repo/src' }),
|
||||
{
|
||||
terminalSnapshots: new Map(),
|
||||
getListener: (streamId) => (streamId === 42 ? listener : undefined),
|
||||
recordValidatedInboundTraffic
|
||||
}
|
||||
)
|
||||
|
||||
expect(recordValidatedInboundTraffic).toHaveBeenCalledTimes(1)
|
||||
expect(listener).toHaveBeenCalledWith({ type: 'metadata', streamId: 42, cwd: '/repo/src' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText
|
||||
} from './terminal-stream-protocol'
|
||||
|
||||
export type TerminalSnapshotState = {
|
||||
streamId: number
|
||||
meta: Record<string, unknown>
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
type StreamingListener = (result: unknown) => void
|
||||
|
||||
type TerminalBinaryFrameOptions = {
|
||||
terminalSnapshots: Map<number, TerminalSnapshotState>
|
||||
getListener: (streamId: number) => StreamingListener | undefined
|
||||
recordValidatedInboundTraffic: () => void
|
||||
}
|
||||
|
||||
export function handleTerminalBinaryFrame(
|
||||
bytes: Uint8Array,
|
||||
options: TerminalBinaryFrameOptions
|
||||
): void {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
const listener = options.getListener(frame.streamId)
|
||||
if (!listener) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'data',
|
||||
streamId: frame.streamId,
|
||||
chunk: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
options.terminalSnapshots.set(frame.streamId, {
|
||||
streamId: frame.streamId,
|
||||
meta,
|
||||
chunks: []
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
const snapshot = options.terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
const snapshot = options.terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
options.terminalSnapshots.delete(frame.streamId)
|
||||
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
|
||||
listener({
|
||||
...snapshot.meta,
|
||||
type: kind,
|
||||
streamId: frame.streamId,
|
||||
serialized: snapshot.chunks.join('')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Resized) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'resized',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Metadata) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'metadata',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
options.recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'error',
|
||||
streamId: frame.streamId,
|
||||
message: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { connect } from './rpc-client'
|
||||
import { encodeTerminalStreamFrame, TerminalStreamOpcode } from './terminal-stream-protocol'
|
||||
|
||||
vi.mock('./e2ee', () => ({
|
||||
generateKeyPair: () => ({
|
||||
publicKey: new Uint8Array(32),
|
||||
secretKey: new Uint8Array(32)
|
||||
}),
|
||||
deriveSharedKey: () => new Uint8Array(32),
|
||||
publicKeyFromBase64: () => new Uint8Array(32),
|
||||
publicKeyToBase64: () => 'client-public-key',
|
||||
encrypt: (plaintext: string) => `encrypted:${plaintext}`,
|
||||
decrypt: (raw: string) => (raw === 'undecryptable' ? null : raw.replace(/^encrypted:/, '')),
|
||||
decryptBytes: (bytes: Uint8Array) => bytes
|
||||
}))
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSED = 3
|
||||
|
||||
readonly CONNECTING = MockWebSocket.CONNECTING
|
||||
readonly OPEN = MockWebSocket.OPEN
|
||||
readonly CLOSED = MockWebSocket.CLOSED
|
||||
|
||||
readyState = MockWebSocket.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onmessage: ((event: { data: unknown }) => void) | null = null
|
||||
sent: string[] = []
|
||||
close = vi.fn(() => {
|
||||
this.readyState = MockWebSocket.CLOSED
|
||||
this.onclose?.()
|
||||
})
|
||||
|
||||
constructor(readonly endpoint: string) {
|
||||
mockSockets.push(this)
|
||||
}
|
||||
|
||||
send(payload: string): void {
|
||||
this.sent.push(payload)
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = MockWebSocket.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
|
||||
receive(payload: unknown): void {
|
||||
this.onmessage?.({ data: payload })
|
||||
}
|
||||
}
|
||||
|
||||
const mockSockets: MockWebSocket[] = []
|
||||
const originalWebSocket = globalThis.WebSocket
|
||||
|
||||
function authenticate(socket: MockWebSocket): void {
|
||||
socket.open()
|
||||
socket.receive(JSON.stringify({ type: 'e2ee_ready' }))
|
||||
socket.receive('encrypted:{"type":"e2ee_authenticated"}')
|
||||
}
|
||||
|
||||
function sentRequest(socket: MockWebSocket, method: string): { id: string } {
|
||||
for (const payload of socket.sent) {
|
||||
const decoded = JSON.parse(payload.replace(/^encrypted:/, '')) as {
|
||||
id: string
|
||||
method: string
|
||||
}
|
||||
if (decoded.method === method) {
|
||||
return { id: decoded.id }
|
||||
}
|
||||
}
|
||||
throw new Error(`Request not sent: ${method}`)
|
||||
}
|
||||
|
||||
function sentRequests(socket: MockWebSocket, method: string): Array<{ id: string }> {
|
||||
return socket.sent
|
||||
.map(
|
||||
(payload) => JSON.parse(payload.replace(/^encrypted:/, '')) as { id: string; method: string }
|
||||
)
|
||||
.filter((request) => request.method === method)
|
||||
}
|
||||
|
||||
function encryptedStreamingReady(id: string, streamId: number): string {
|
||||
return `encrypted:${JSON.stringify({
|
||||
id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'subscribed', streamId }
|
||||
})}`
|
||||
}
|
||||
|
||||
function unauthorizedResponsePayload(id: string): string {
|
||||
return `encrypted:${JSON.stringify({
|
||||
id,
|
||||
ok: false,
|
||||
error: { code: 'unauthorized', message: 'Unauthorized' }
|
||||
})}`
|
||||
}
|
||||
|
||||
function encodeTerminalOutput(streamId: number, chunk: string): Uint8Array {
|
||||
return encodeTerminalStreamFrame({
|
||||
opcode: TerminalStreamOpcode.Output,
|
||||
streamId,
|
||||
seq: 1,
|
||||
payload: new TextEncoder().encode(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
describe('rpc-client terminal reconnect streams', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
mockSockets.length = 0
|
||||
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.WebSocket = originalWebSocket
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('re-subscribes active streams after auth retry without routing stale stream ids', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
const terminalEvents: unknown[] = []
|
||||
authenticate(first)
|
||||
|
||||
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
|
||||
terminalEvents.push(event)
|
||||
})
|
||||
const initialSubscribe = sentRequest(first, 'terminal.subscribe')
|
||||
first.receive(encryptedStreamingReady(initialSubscribe.id, 76))
|
||||
|
||||
const request = client.sendRequest('status.get').catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
first.receive(unauthorizedResponsePayload(sentRequest(first, 'status.get').id))
|
||||
await request
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const second = mockSockets.at(-1)!
|
||||
authenticate(second)
|
||||
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
|
||||
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
|
||||
second.receive(encryptedStreamingReady(resumedSubscribe.id, 77))
|
||||
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
|
||||
second.receive(encodeTerminalOutput(76, 'stale-before-reconnect'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(terminalEvents).toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 77,
|
||||
chunk: 'after-reconnect'
|
||||
})
|
||||
expect(terminalEvents).not.toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 76,
|
||||
chunk: 'stale-before-reconnect'
|
||||
})
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
@@ -906,57 +906,6 @@ describe('mobile rpc-client connection timeout', () => {
|
||||
|
||||
client.close()
|
||||
})
|
||||
|
||||
it('re-subscribes active streams after an auth-retry reconnect', async () => {
|
||||
const client = connect('ws://desktop.invalid', 'token', 'server-key')
|
||||
const first = mockSockets[0]!
|
||||
const terminalEvents: unknown[] = []
|
||||
authenticate(first)
|
||||
expect(client.getState()).toBe('connected')
|
||||
|
||||
// An active terminal subscription on the live connection.
|
||||
client.subscribe('terminal.subscribe', { terminal: 'term-1' }, (event) => {
|
||||
terminalEvents.push(event)
|
||||
})
|
||||
expect(sentRequests(first, 'terminal.subscribe')).toHaveLength(1)
|
||||
|
||||
// Mid-session transient unauthorized — handleAuthRejection retries
|
||||
// (budget not exhausted) and reconnects rather than latching.
|
||||
const request = client.sendRequest('status.get').catch(() => undefined)
|
||||
await Promise.resolve()
|
||||
const id = sentRequest(first, 'status.get').id
|
||||
first.receive(unauthorizedResponsePayload(id))
|
||||
await request
|
||||
expect(client.getState()).toBe('reconnecting')
|
||||
|
||||
// Fresh socket authenticates; the replay loop must re-send the still
|
||||
// active terminal subscription (issue #5200 frozen-terminal regression).
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
const second = mockSockets[mockSockets.length - 1]!
|
||||
expect(second).not.toBe(first)
|
||||
authenticate(second)
|
||||
expect(client.getState()).toBe('connected')
|
||||
expect(sentRequests(second, 'terminal.subscribe')).toHaveLength(1)
|
||||
const resumedSubscribe = sentRequest(second, 'terminal.subscribe')
|
||||
second.receive(
|
||||
`encrypted:${JSON.stringify({
|
||||
id: resumedSubscribe.id,
|
||||
ok: true,
|
||||
streaming: true,
|
||||
result: { type: 'subscribed', streamId: 77 }
|
||||
})}`
|
||||
)
|
||||
second.receive(encodeTerminalOutput(77, 'after-reconnect'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(terminalEvents).toContainEqual({
|
||||
type: 'data',
|
||||
streamId: 77,
|
||||
chunk: 'after-reconnect'
|
||||
})
|
||||
|
||||
client.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests waiting for reconnect after the retry cap', async () => {
|
||||
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
decryptBytes
|
||||
} from './e2ee'
|
||||
import {
|
||||
TerminalStreamOpcode,
|
||||
decodeTerminalStreamFrame,
|
||||
decodeTerminalStreamJson,
|
||||
decodeTerminalStreamText
|
||||
} from './terminal-stream-protocol'
|
||||
handleTerminalBinaryFrame,
|
||||
type TerminalSnapshotState
|
||||
} from './rpc-client-terminal-binary-frame'
|
||||
import {
|
||||
decodeBrowserScreencastFrame,
|
||||
type BrowserScreencastFrame
|
||||
@@ -63,12 +61,6 @@ type StreamRequest = {
|
||||
sent?: boolean
|
||||
}
|
||||
|
||||
type TerminalSnapshotState = {
|
||||
streamId: number
|
||||
meta: Record<string, unknown>
|
||||
chunks: string[]
|
||||
}
|
||||
|
||||
export type RpcClient = {
|
||||
sendRequest: (
|
||||
method: string,
|
||||
@@ -490,6 +482,7 @@ export function connect(
|
||||
pendingBrowserScreencastRequestId = id
|
||||
activeBrowserScreencastRequestId = null
|
||||
}
|
||||
resetTerminalStreamRoutingForRequest(id)
|
||||
if (
|
||||
sendEncrypted({ id, deviceToken, method: stream.method, params: stream.params })
|
||||
) {
|
||||
@@ -708,7 +701,7 @@ export function connect(
|
||||
sharedKey = null
|
||||
activeBrowserScreencastRequestId = null
|
||||
pendingBrowserScreencastRequestId = null
|
||||
streamListeners.forEach((stream) => (stream.sent = false))
|
||||
markStreamsForReplay()
|
||||
if (handshakeTimer) {
|
||||
clearTimeout(handshakeTimer)
|
||||
handshakeTimer = null
|
||||
@@ -759,7 +752,7 @@ export function connect(
|
||||
ws = null
|
||||
sharedKey = null
|
||||
// Why: close cleanup stale-bails here, so mark active streams for replay.
|
||||
streamListeners.forEach((stream) => (stream.sent = false))
|
||||
markStreamsForReplay()
|
||||
rejectAllPending(reason)
|
||||
if (closing) {
|
||||
closing.close()
|
||||
@@ -900,6 +893,25 @@ export function connect(
|
||||
}
|
||||
}
|
||||
|
||||
function markStreamsForReplay(): void {
|
||||
for (const [id, stream] of streamListeners) {
|
||||
stream.sent = false
|
||||
resetTerminalStreamRoutingForRequest(id)
|
||||
}
|
||||
}
|
||||
|
||||
function resetTerminalStreamRoutingForRequest(id: string): void {
|
||||
const terminalStreamIds = terminalStreamIdsByRequest.get(id)
|
||||
if (!terminalStreamIds) {
|
||||
return
|
||||
}
|
||||
for (const streamId of terminalStreamIds) {
|
||||
terminalStreamListeners.delete(streamId)
|
||||
terminalSnapshots.delete(streamId)
|
||||
}
|
||||
terminalStreamIdsByRequest.delete(id)
|
||||
}
|
||||
|
||||
function emitStreamError(stream: StreamRequest, message: string, error?: unknown): void {
|
||||
if (stream.cancelled) {
|
||||
return
|
||||
@@ -942,7 +954,11 @@ export function connect(
|
||||
handleBrowserBinaryFrame(browserFrame)
|
||||
return
|
||||
}
|
||||
handleTerminalBinaryFrame(bytes)
|
||||
handleTerminalBinaryFrame(bytes, {
|
||||
terminalSnapshots,
|
||||
getListener: (streamId) => terminalStreamListeners.get(streamId),
|
||||
recordValidatedInboundTraffic
|
||||
})
|
||||
}
|
||||
|
||||
function handleBrowserBinaryFrame(frame: BrowserScreencastFrame) {
|
||||
@@ -956,82 +972,6 @@ export function connect(
|
||||
stream.onBinaryFrame?.(frame)
|
||||
}
|
||||
|
||||
function handleTerminalBinaryFrame(bytes: Uint8Array): void {
|
||||
const frame = decodeTerminalStreamFrame(bytes)
|
||||
if (!frame) {
|
||||
return
|
||||
}
|
||||
const listener = terminalStreamListeners.get(frame.streamId)
|
||||
if (!listener) {
|
||||
recordValidatedInboundTraffic()
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Output) {
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'data',
|
||||
streamId: frame.streamId,
|
||||
chunk: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotStart) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
recordValidatedInboundTraffic()
|
||||
terminalSnapshots.set(frame.streamId, { streamId: frame.streamId, meta, chunks: [] })
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) {
|
||||
recordValidatedInboundTraffic()
|
||||
const snapshot = terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
snapshot.chunks.push(decodeTerminalStreamText(frame.payload))
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.SnapshotEnd) {
|
||||
recordValidatedInboundTraffic()
|
||||
const snapshot = terminalSnapshots.get(frame.streamId)
|
||||
if (!snapshot) {
|
||||
return
|
||||
}
|
||||
terminalSnapshots.delete(frame.streamId)
|
||||
const kind = snapshot.meta.kind === 'resized' ? 'resized' : 'scrollback'
|
||||
listener({
|
||||
...snapshot.meta,
|
||||
type: kind,
|
||||
streamId: frame.streamId,
|
||||
serialized: snapshot.chunks.join('')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Resized) {
|
||||
const meta = decodeTerminalStreamJson<Record<string, unknown>>(frame.payload)
|
||||
if (!meta) {
|
||||
return
|
||||
}
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
...meta,
|
||||
type: 'resized',
|
||||
streamId: frame.streamId
|
||||
})
|
||||
return
|
||||
}
|
||||
if (frame.opcode === TerminalStreamOpcode.Error) {
|
||||
recordValidatedInboundTraffic()
|
||||
listener({
|
||||
type: 'error',
|
||||
streamId: frame.streamId,
|
||||
message: decodeTerminalStreamText(frame.payload)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function sendEncrypted(request: unknown): boolean {
|
||||
if (ws && ws.readyState === WebSocket.OPEN && sharedKey) {
|
||||
ws.send(encrypt(JSON.stringify(request), sharedKey))
|
||||
|
||||
@@ -8,7 +8,8 @@ export enum TerminalStreamOpcode {
|
||||
SnapshotChunk = 3,
|
||||
SnapshotEnd = 4,
|
||||
Resized = 5,
|
||||
Error = 6
|
||||
Error = 6,
|
||||
Metadata = 12
|
||||
}
|
||||
|
||||
export type TerminalStreamFrame = {
|
||||
@@ -74,6 +75,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
|
||||
value === TerminalStreamOpcode.SnapshotChunk ||
|
||||
value === TerminalStreamOpcode.SnapshotEnd ||
|
||||
value === TerminalStreamOpcode.Resized ||
|
||||
value === TerminalStreamOpcode.Error
|
||||
value === TerminalStreamOpcode.Error ||
|
||||
value === TerminalStreamOpcode.Metadata
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user