Merge remote-tracking branch 'origin/main' into brennanb2025/fix-run-binding-loss

This commit is contained in:
Brennan Benson
2026-08-27 19:19:16 -07:00
42 changed files with 2802 additions and 1922 deletions
@@ -30,6 +30,7 @@ runs:
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
version: 10.24.0
run_install: false
- name: Setup Node.js
@@ -117,7 +118,7 @@ runs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Restore compiled native modules without saving
if: inputs.native-runtime != 'none' && inputs.persist-native-cache == 'false'
@@ -127,7 +128,7 @@ runs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.native-cache-scope.outputs.scope }}-${{ runner.arch }}-${{ inputs.native-runtime }}-node${{ steps.default-node.outputs.node-version }}${{ steps.requested-node.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare native runtime
if: inputs.native-runtime != 'none'
+31 -4
View File
@@ -27,6 +27,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
@@ -442,10 +443,36 @@ jobs:
src/shared/startup-shell-portability.live-shell.test.ts \
src/shared/posix-command-path-lookup.test.ts
# Cache-key input changes would otherwise make every shard compile the same
# native addon concurrently. Prime each Node ABI once before the matrix fans out.
test_native_cache:
name: prepare test native cache node ${{ matrix.node }}
needs: [code_paths]
if: needs.code_paths.outputs.native_cache_changed == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
test:
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
needs: [code_paths]
if: needs.code_paths.outputs.test == 'true'
needs: [code_paths, test_native_cache]
if: >-
always() &&
needs.code_paths.outputs.test == 'true' &&
(needs.test_native_cache.result == 'success' || needs.test_native_cache.result == 'skipped')
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -695,7 +722,7 @@ jobs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-node-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Test Windows-specific boundaries
run: >-
@@ -748,7 +775,7 @@ jobs:
node_modules/.pnpm/node-pty@*/node_modules/node-pty/build
node_modules/.pnpm/windows-native-registry@*/node_modules/windows-native-registry/build
node_modules/.pnpm/@vscode+windows-process-tree@*/node_modules/@vscode/windows-process-tree/build
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
key: native-modules-${{ runner.os }}-${{ steps.deps.outputs.native-cache-scope }}-${{ runner.arch }}-electron-node${{ steps.deps.outputs.node-version }}-${{ hashFiles('pnpm-lock.yaml', '.github/actions/install-node-dependencies/action.yml', 'config/scripts/ensure-native-runtime.mjs', 'config/scripts/rebuild-native-deps.mjs', 'config/patches/node-pty@1.1.0.patch', 'config/patches/@vscode__windows-process-tree@0.8.0.patch') }}
- name: Prepare Electron native runtime
run: node config/scripts/ensure-native-runtime.mjs --runtime=electron
+22 -1
View File
@@ -116,6 +116,19 @@ const NATIVE_RUNTIME_PREFIXES = [
'config/patches/@vscode__windows-process-tree'
]
const NATIVE_CACHE_FILES = new Set([
'package.json',
'pnpm-lock.yaml',
'.github/actions/install-node-dependencies/action.yml',
'config/scripts/ensure-native-runtime.mjs',
'config/scripts/rebuild-native-deps.mjs'
])
const NATIVE_CACHE_PREFIXES = [
'config/patches/node-pty@',
'config/patches/@vscode__windows-process-tree'
]
const SHARED_PACKAGE_PREFIXES = [
'electron.vite.config.ts',
'config/electron-builder',
@@ -222,7 +235,11 @@ export function classifyPrJobs(changedFiles) {
shouldRun && (forceAll || ALWAYS_ON_CODE_JOBS.has(job) || jobDetector(job)(changedFiles))
])
)
return { should_run: shouldRun, ...jobs }
return {
should_run: shouldRun,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
...jobs
}
}
function jobDetector(job) {
@@ -274,6 +291,10 @@ function isDesktopIrrelevantPath(file) {
return matchesPrefix(file, DESKTOP_IRRELEVANT_PREFIXES)
}
function isNativeCacheInputPath(file) {
return NATIVE_CACHE_FILES.has(file) || matchesPrefix(file, NATIVE_CACHE_PREFIXES)
}
function isGlobalForcePath(file) {
return GLOBAL_FORCE_FILES.has(file) || matchesPrefix(file, GLOBAL_FORCE_PREFIXES)
}
+28 -3
View File
@@ -207,6 +207,22 @@ describe('per-job path classification', () => {
expect(classifyPrJobs(['pnpm-lock.yaml']).git_compatibility).toBe(true)
})
it('primes native caches only when their immutable inputs change', () => {
expect(classifyPrJobs([]).native_cache_changed).toBe(true)
expect(classifyPrJobs(['README.md']).native_cache_changed).toBe(false)
expect(classifyPrJobs(['src/main/index.ts']).native_cache_changed).toBe(false)
for (const file of [
'package.json',
'pnpm-lock.yaml',
'.github/actions/install-node-dependencies/action.yml',
'config/scripts/ensure-native-runtime.mjs',
'config/scripts/rebuild-native-deps.mjs',
'config/patches/node-pty@1.1.0.patch'
]) {
expect(classifyPrJobs([file]).native_cache_changed, file).toBe(true)
}
})
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
@@ -238,7 +254,7 @@ describe('PR Checks skip wiring', () => {
expect(classify.run).toContain('--merge-base "$BASE_SHA" "$HEAD_SHA"')
expect(classify.run).toContain('node config/scripts/pr-code-change-scope.mjs')
expect(classify.run).toContain('tee -a "$GITHUB_OUTPUT"')
for (const jobName of ['should_run', ...expensiveJobs]) {
for (const jobName of ['should_run', 'native_cache_changed', ...expensiveJobs]) {
expect(prWorkflow.jobs.code_paths.outputs[jobName], jobName).toBe(
`\${{ steps.filter.outputs.${jobName} }}`
)
@@ -250,13 +266,22 @@ describe('PR Checks skip wiring', () => {
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
})
it('gates each expensive job on its own classifier output', () => {
for (const jobName of expensiveJobs) {
it('gates each expensive job on its classifier and cache prerequisite', () => {
for (const jobName of expensiveJobs.filter((jobName) => jobName !== 'test')) {
expect(prWorkflow.jobs[jobName].needs, jobName).toEqual(['code_paths'])
expect(prWorkflow.jobs[jobName].if, jobName).toBe(
`needs.code_paths.outputs.${jobName} == 'true'`
)
}
expect(prWorkflow.jobs.test.needs).toEqual(['code_paths', 'test_native_cache'])
expect(prWorkflow.jobs.test.if).toContain("needs.code_paths.outputs.test == 'true'")
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'success'")
expect(prWorkflow.jobs.test.if).toContain("needs.test_native_cache.result == 'skipped'")
expect(prWorkflow.jobs.test_native_cache.needs).toEqual(['code_paths'])
expect(prWorkflow.jobs.test_native_cache.if).toBe(
"needs.code_paths.outputs.native_cache_changed == 'true'"
)
expect(prWorkflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
})
it('skips e2e detection on docs-only PRs without dropping the draft gate', () => {
@@ -63,6 +63,13 @@ describe('PR workflow parallelism', () => {
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
const primerInstall = workflow.jobs.test_native_cache.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(workflow.jobs.test_native_cache.strategy.matrix.node).toEqual(['24', '26'])
expect(primerInstall.with['native-runtime']).toBe('node')
expect(primerInstall.with['node-version']).toBe('${{ matrix.node }}')
expect(workflow.jobs.test.needs).toContain('test_native_cache')
})
it('runs real-shell coverage once outside the general shards', () => {
@@ -208,6 +215,8 @@ describe('PR workflow parallelism', () => {
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
const packageManagerVersion = /^pnpm@([^+]+)/.exec(packageJson.packageManager)?.[1]
expect(steps[pnpmIndex].with.version).toBe(packageManagerVersion)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
@@ -316,6 +325,9 @@ describe('PR workflow parallelism', () => {
expect(cacheStep.with.key).toContain(
'config/patches/@vscode__windows-process-tree@0.8.0.patch'
)
expect(cacheStep.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
expect(cacheStep.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
expect(cacheStep.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
expect(cacheStep.with.path).toContain('node-pty@*/node_modules/node-pty/build')
expect(cacheStep.with.path).toContain('windows-native-registry@')
expect(cacheStep.with.path).toContain('@vscode+windows-process-tree@')
@@ -50,6 +50,11 @@ describe('packaged Windows PTY native capability routing', () => {
expect(nodeCacheSave.uses).toBe('actions/cache/save@v5')
expect(nodeCacheSave.with.key).toContain('-node-node')
expect(electronCache.with.key).toContain('-electron-node')
for (const cache of [nodeCacheSave, electronCache]) {
expect(cache.with.key).toContain('.github/actions/install-node-dependencies/action.yml')
expect(cache.with.key).toContain('config/scripts/ensure-native-runtime.mjs')
expect(cache.with.key).toContain('config/scripts/rebuild-native-deps.mjs')
}
expect(ensureNativeRuntime).toContain("runPnpm(['exec', 'node-gyp', 'rebuild']")
expect(ensureNativeRuntime).toContain("resolve(moduleDir, 'scripts', 'post-install.js')")
expect(build.run).toBe('pnpm run build:release:parallel')
+3 -309
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { View, Text, SectionList, Pressable, Alert, RefreshControl } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -69,8 +69,9 @@ import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { colors, spacing } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
import { hostScreenStyles as styles } from '../../../src/host-screen/host-screen-styles'
import { leaveHostRoute } from '../../../src/host-route-exit'
import { loadPinnedIds, savePinnedIds } from '../../../src/storage/preferences'
import {
@@ -1410,310 +1411,3 @@ export default function HostWorktreeRoute() {
function ListSeparator() {
return <View style={styles.separator} />
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 34,
paddingTop: spacing.xs,
paddingHorizontal: spacing.lg
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
sidebarCollapseButton: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginLeft: spacing.xs
},
hostIdentity: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
minWidth: 0,
marginRight: spacing.md
},
hostNameText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
gap: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbar: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbarRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
embeddedFilterChip: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
embeddedModeButton: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle
},
filterChipActive: {
borderColor: colors.textSecondary,
backgroundColor: colors.bgRaised
},
filterChipText: {
fontSize: 12,
color: colors.textSecondary
},
filterChipTextActive: {
color: colors.textPrimary
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
toolbarSpacer: {
flex: 1
},
floatingWorkspaceHeaderButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
toolbarIconDisabled: {
opacity: 0.6
},
searchToggle: {
padding: spacing.xs
},
searchBar: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize
},
list: {
paddingBottom: spacing.lg
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs
},
sectionIcon: {
marginRight: spacing.xs
},
sectionRepoIcon: {
marginRight: spacing.xs
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
sectionCount: {
fontSize: 11,
color: colors.textMuted,
marginLeft: spacing.xs
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
marginLeft: spacing.lg + 24,
marginRight: spacing.lg
},
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
},
filterSectionLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
filterGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.md
},
filterRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2,
gap: spacing.sm
},
filterRowText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
filterSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
borderRadius: 4
},
confirmContent: {
paddingBottom: spacing.lg
},
confirmTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
confirmMessage: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
confirmButtons: {
flexDirection: 'row',
gap: spacing.sm
},
confirmBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
confirmBtnCancel: {
backgroundColor: colors.bgPanel
},
confirmBtnDestructive: {
backgroundColor: colors.statusRed
},
confirmBtnPressed: {
opacity: 0.7
},
confirmBtnCancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
confirmBtnDestructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
+43 -30
View File
@@ -117,6 +117,10 @@ import {
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender
} from '../../../../src/terminal/terminal-accessory-repeat'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -2993,16 +2997,22 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
async function handleAccessoryKey(
input: ReturnType<typeof createTerminalLiveAccessoryInput>,
targetHandle = activeHandleRef.current,
isDeliveryTargetCurrent: () => boolean = () => targetHandle === activeHandleRef.current
) {
if (!client || !targetHandle || !isDeliveryTargetCurrent() || !canSend) {
return false
}
const targetHandle = activeHandle
const accessoryCommit = await handleLiveInputAccessoryBytes(input)
if (accessoryCommit.kind !== 'allow-raw') {
return
if (!isDeliveryTargetCurrent()) {
return false
}
await sendTerminalLiveAccessoryRawBytes({
if (accessoryCommit.kind !== 'allow-raw') {
return accessoryCommit.kind === 'handled'
}
return sendTerminalLiveAccessoryRawBytes({
client: clientRef.current,
targetHandle,
activeHandle: activeHandleRef.current,
@@ -3399,32 +3409,37 @@ export default function SessionScreen() {
}
}
// Why: hold-to-repeat matches iOS cadence (400ms then 45ms); non-repeatable keys fire once (holding is destructive).
const repeatTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const repeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Why: ref keeps repeat firing the current callback; else a mid-hold tab switch/reconnect routes bytes to a stale terminal.
const accessoryRepeatRef = useRef(
createTerminalAccessoryRepeatController<ReturnType<typeof createTerminalLiveAccessoryInput>>()
)
// Why: the current callback observes reconnect state while the sender pins the press to its original terminal.
const handleAccessoryKeyRef = useRef(handleAccessoryKey)
handleAccessoryKeyRef.current = handleAccessoryKey
const stopAccessoryRepeat = useCallback(() => {
if (repeatTimeoutRef.current) {
clearTimeout(repeatTimeoutRef.current)
repeatTimeoutRef.current = null
}
if (repeatIntervalRef.current) {
clearInterval(repeatIntervalRef.current)
repeatIntervalRef.current = null
}
accessoryRepeatRef.current.stop()
}, [])
const startAccessoryRepeat = useCallback(
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
void handleAccessoryKeyRef.current(input)
}, 45)
}, 400)
// Why: a held repeat must not resume through a replacement client or connection.
const targetClient = clientRef.current
const targetConnectedAt = targetClient?.getLastConnectedAt() ?? null
const isDeliveryTargetCurrent = (targetHandle: string) =>
activeHandleRef.current === targetHandle &&
targetClient !== null &&
clientRef.current === targetClient &&
connStateRef.current === 'connected' &&
targetClient.getLastConnectedAt() === targetConnectedAt
accessoryRepeatRef.current.start(
input,
createTerminalAccessoryRepeatSender(
activeHandleRef.current,
isDeliveryTargetCurrent,
(nextInput, targetHandle, isTargetCurrent) =>
handleAccessoryKeyRef.current(nextInput, targetHandle, isTargetCurrent)
)
)
},
[stopAccessoryRepeat]
[]
)
const setMobileSessionRootRef = useCallback(
(node: View | null): void => {
@@ -3440,15 +3455,14 @@ export default function SessionScreen() {
clearPendingLiveInputCommit()
sessionTabActionSheetRequestSeqRef.current += 1
clearSessionTabActionSheetKeyboardListener()
stopAccessoryRepeat()
accessoryRepeatRef.current.cancel()
},
[
clearPendingLiveInputCommit,
clearDelayedActionTimers,
clearSessionTabActionSheetKeyboardListener,
clearTerminalCache,
clearToastHideTimer,
stopAccessoryRepeat
clearToastHideTimer
]
)
@@ -4848,7 +4862,6 @@ export default function SessionScreen() {
return
}
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
+23 -372
View File
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
// can't resolve Node's builtin in a React Native bundle.
import { Buffer } from 'buffer'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
@@ -10,7 +7,6 @@ import {
PanResponder,
PixelRatio,
Pressable,
StyleSheet,
Text,
TextInput,
View,
@@ -24,7 +20,7 @@ import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { colors } from '../theme/mobile-theme'
import {
MOBILE_BROWSER_FRAME_MIN_INTERVAL_MS,
buildMobileBrowserScreencastRequest,
@@ -47,11 +43,32 @@ import {
computeBrowserTouchClickRadiusCss,
mapScreenToBrowserPoint,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserPoint,
type BrowserTouchLayout,
type BrowserZoomState
} from './browser-touch-geometry'
import {
MAX_ZOOM,
MIN_ZOOM,
assertRpcOk,
browserErrorMessage,
browserFrameMetadataEqual,
buttonColor,
cacheBrowserFrame,
clearCachedBrowserFramesForWorktree,
createBrowserFrameDataUri,
createPinchGesture,
getCachedBrowserFrame,
makeBrowserFrameCacheKey,
peekCachedBrowserFrame,
shouldSurfaceBrowserError,
updateBrowserImageSource,
updateBrowserLayerVisibility,
updatePinchZoom,
type FrameLayer,
type PinchGesture
} from './mobile-browser-frame-state'
import { mobileBrowserPaneStyles as styles } from './mobile-browser-pane-styles'
import { displayBrowserUrl, normalizeBrowserUrl } from './browser-url'
import { MobileBrowserAddressField } from './MobileBrowserAddressField'
import { resolveMobileBrowserAddressSync } from './mobile-browser-address-sync'
@@ -79,15 +96,6 @@ type MobileBrowserPaneProps = {
onToast: (message: string, durationMs?: number) => void
}
type FrameLayer = 0 | 1
type PinchGesture = {
distance: number
scale: number
anchorX: number
anchorY: number
}
type PanGesture = {
x: number
y: number
@@ -105,17 +113,7 @@ const SCROLL_START_SLOP = 22
const LONG_PRESS_MS = 550
const WHEEL_INTERVAL_MS = 70
const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
const BROWSER_FRAME_CACHE_LIMIT = 4
type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
type BrowserPageParams = {
worktree: string
@@ -1268,350 +1266,3 @@ export function MobileBrowserPane({
</View>
)
}
function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
viewMode: MobileBrowserViewMode
): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
): void {
for (const [index, layer] of layers.entries()) {
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
}
}
function updateBrowserImageSource(image: Image | null, uri: string): void {
// Why: browser frames are large strings; mutating only the native Image
// source avoids re-rendering the whole tab view for every streamed frame.
const source = [{ uri }]
image?.setNativeProps({ source, src: source })
}
function assertRpcOk(
response: RpcSuccess | RpcFailure,
fallbackMessage: string
): asserts response is RpcSuccess {
if (!response.ok) {
throw new Error(response.error.message || fallbackMessage)
}
}
function browserFrameMetadataEqual(
a: BrowserScreencastFrameMetadata | null,
b: BrowserScreencastFrameMetadata
): boolean {
return (
a?.deviceWidth === b.deviceWidth &&
a?.deviceHeight === b.deviceHeight &&
a?.pageScaleFactor === b.pageScaleFactor
)
}
function browserErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
function shouldSurfaceBrowserError(message: string): boolean {
const normalized = message.toLowerCase()
// Why: selector_not_found can be emitted by in-flight page automation while
// the browser is still usable; replacing the frame with it feels like a crash.
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
}
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
const touches = event.nativeEvent.touches
if (!touches || touches.length < 2) {
return null
}
const a = readLocalTouchPoint(touches[0])
const b = readLocalTouchPoint(touches[1])
return a && b ? { a, b } : null
}
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function createPinchGesture(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
zoom: BrowserZoomState
): PinchGesture | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const distance = pointDistance(pair.a, pair.b)
if (distance < 8) {
return null
}
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
return {
distance,
scale: zoom.scale,
anchorX: (centerX - frameCenterX) / zoom.scale,
anchorY: (centerY - frameCenterY) / zoom.scale
}
}
function updatePinchZoom(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
pinch: PinchGesture
): BrowserZoomState | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const nextScale = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
)
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
return clampBrowserZoomState(
{
scale: nextScale,
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
},
geometry,
MIN_ZOOM,
MAX_ZOOM
)
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
viewport: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
backgroundColor: colors.bgBase
},
browserImageHost: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImageFill: {
width: '100%',
height: '100%'
},
browserImageLayer: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
browserImageLayerHidden: {
opacity: 0
},
browserZoomOffset: {
alignItems: 'center',
justifyContent: 'center'
},
browserFrameBox: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImage: {
backgroundColor: colors.bgBase
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.sm,
backgroundColor: 'rgba(13, 15, 24, 0.2)'
},
errorText: {
color: colors.textPrimary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 13,
textAlign: 'center',
overflow: 'hidden'
},
dialogOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 30,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: 'rgba(13, 15, 24, 0.5)'
},
dialogCard: {
width: '100%',
maxWidth: 360,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
padding: spacing.lg
},
dialogTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '600'
},
dialogMessage: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginTop: spacing.sm
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg
},
dialogButton: {
minHeight: 34,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
dialogButtonPrimary: {
backgroundColor: colors.textPrimary
},
dialogButtonPressed: {
opacity: 0.75
},
dialogButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
dialogButtonPrimaryText: {
color: colors.bgBase
},
keyboardDock: {
zIndex: 20,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs + 2
},
keyboardInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
sendButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
@@ -0,0 +1,210 @@
import { Buffer } from 'buffer'
import type { GestureResponderEvent, Image, View } from 'react-native'
import type { RpcFailure, RpcSuccess } from '../transport/types'
import type {
BrowserScreencastFrame,
BrowserScreencastFrameMetadata
} from '../transport/browser-screencast-protocol'
import { colors } from '../theme/mobile-theme'
import {
clampBrowserZoomState,
readLocalTouchPoint,
type BrowserFrameGeometry,
type BrowserPoint,
type BrowserZoomState
} from './browser-touch-geometry'
import type { MobileBrowserViewMode } from './browser-screencast-request'
export type FrameLayer = 0 | 1
export type PinchGesture = {
distance: number
scale: number
anchorX: number
anchorY: number
}
export type BrowserFrameCacheEntry = {
uri: string
metadata: BrowserScreencastFrameMetadata
}
export const MIN_ZOOM = 1
export const MAX_ZOOM = 3.5
const BROWSER_FRAME_CACHE_LIMIT = 4
const browserFrameCache = new Map<string, BrowserFrameCacheEntry>()
export function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
export function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
}
export function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
viewMode: MobileBrowserViewMode
): string | null {
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
export function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
const prefix = `${worktreeId}:`
for (const key of browserFrameCache.keys()) {
if (key.startsWith(prefix)) {
browserFrameCache.delete(key)
}
}
}
export function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
if (!cacheKey) {
return null
}
const cached = browserFrameCache.get(cacheKey)
if (!cached) {
return null
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, cached)
return cached
}
export function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
}
export function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
if (!cacheKey) {
return
}
browserFrameCache.delete(cacheKey)
browserFrameCache.set(cacheKey, entry)
while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
const oldestKey = browserFrameCache.keys().next().value
if (typeof oldestKey !== 'string') {
break
}
browserFrameCache.delete(oldestKey)
}
}
export function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
): void {
for (const [index, layer] of layers.entries()) {
layer?.setNativeProps({ style: { opacity: index === visible ? 1 : 0 } })
}
}
export function updateBrowserImageSource(image: Image | null, uri: string): void {
// Why: browser frames are large strings; mutating only the native Image
// source avoids re-rendering the whole tab view for every streamed frame.
const source = [{ uri }]
image?.setNativeProps({ source, src: source })
}
export function assertRpcOk(
response: RpcSuccess | RpcFailure,
fallbackMessage: string
): asserts response is RpcSuccess {
if (!response.ok) {
throw new Error(response.error.message || fallbackMessage)
}
}
export function browserFrameMetadataEqual(
a: BrowserScreencastFrameMetadata | null,
b: BrowserScreencastFrameMetadata
): boolean {
return (
a?.deviceWidth === b.deviceWidth &&
a?.deviceHeight === b.deviceHeight &&
a?.pageScaleFactor === b.pageScaleFactor
)
}
export function browserErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
export function shouldSurfaceBrowserError(message: string): boolean {
const normalized = message.toLowerCase()
// Why: selector_not_found can be emitted by in-flight page automation while
// the browser is still usable; replacing the frame with it feels like a crash.
return !normalized.includes('selector_not_found') && !normalized.includes('selector not found')
}
function touchPair(event: GestureResponderEvent): { a: BrowserPoint; b: BrowserPoint } | null {
const touches = event.nativeEvent.touches
if (!touches || touches.length < 2) {
return null
}
const a = readLocalTouchPoint(touches[0])
const b = readLocalTouchPoint(touches[1])
return a && b ? { a, b } : null
}
function pointDistance(a: BrowserPoint, b: BrowserPoint): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
export function createPinchGesture(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
zoom: BrowserZoomState
): PinchGesture | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const distance = pointDistance(pair.a, pair.b)
if (distance < 8) {
return null
}
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const frameCenterX = geometry.offsetX + geometry.renderedWidth / 2 + zoom.offsetX
const frameCenterY = geometry.offsetY + geometry.renderedHeight / 2 + zoom.offsetY
return {
distance,
scale: zoom.scale,
anchorX: (centerX - frameCenterX) / zoom.scale,
anchorY: (centerY - frameCenterY) / zoom.scale
}
}
export function updatePinchZoom(
event: GestureResponderEvent,
geometry: BrowserFrameGeometry | null,
pinch: PinchGesture
): BrowserZoomState | null {
if (!geometry) {
return null
}
const pair = touchPair(event)
if (!pair) {
return null
}
const nextScale = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, (pinch.scale * pointDistance(pair.a, pair.b)) / pinch.distance)
)
const centerX = (pair.a.x + pair.b.x) / 2
const centerY = (pair.a.y + pair.b.y) / 2
const baseCenterX = geometry.offsetX + geometry.renderedWidth / 2
const baseCenterY = geometry.offsetY + geometry.renderedHeight / 2
return clampBrowserZoomState(
{
scale: nextScale,
offsetX: centerX - baseCenterX - pinch.anchorX * nextScale,
offsetY: centerY - baseCenterY - pinch.anchorY * nextScale
},
geometry,
MIN_ZOOM,
MAX_ZOOM
)
}
@@ -0,0 +1,171 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const mobileBrowserPaneStyles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
backgroundColor: colors.bgBase
},
toolbar: {
minHeight: 32,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
viewport: {
flex: 1,
minHeight: 0,
overflow: 'hidden',
backgroundColor: colors.bgBase
},
browserImageHost: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImageFill: {
width: '100%',
height: '100%'
},
browserImageLayer: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center'
},
browserImageLayerHidden: {
opacity: 0
},
browserZoomOffset: {
alignItems: 'center',
justifyContent: 'center'
},
browserFrameBox: {
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden'
},
browserImage: {
backgroundColor: colors.bgBase
},
overlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
gap: spacing.sm,
backgroundColor: 'rgba(13, 15, 24, 0.2)'
},
errorText: {
color: colors.textPrimary,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.button,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: 13,
textAlign: 'center',
overflow: 'hidden'
},
dialogOverlay: {
...StyleSheet.absoluteFillObject,
zIndex: 30,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl,
backgroundColor: 'rgba(13, 15, 24, 0.5)'
},
dialogCard: {
width: '100%',
maxWidth: 360,
borderRadius: radii.card,
borderWidth: 1,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel,
padding: spacing.lg
},
dialogTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '600'
},
dialogMessage: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginTop: spacing.sm
},
dialogActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm,
marginTop: spacing.lg
},
dialogButton: {
minHeight: 34,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
alignItems: 'center',
justifyContent: 'center'
},
dialogButtonPrimary: {
backgroundColor: colors.textPrimary
},
dialogButtonPressed: {
opacity: 0.75
},
dialogButtonText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
dialogButtonPrimaryText: {
color: colors.bgBase
},
keyboardDock: {
zIndex: 20,
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
inputRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingTop: spacing.xs,
paddingBottom: spacing.xs + 2
},
keyboardInput: {
flex: 1,
height: 34,
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
fontSize: 14,
fontFamily: typography.monoFamily,
marginRight: spacing.sm
},
sendButton: {
width: 34,
height: 34,
borderRadius: 17,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised
},
disabled: {
opacity: 0.35
},
disabledText: {
color: colors.textMuted
}
})
@@ -0,0 +1,175 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const hostScreenPrimaryStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
topChrome: {
backgroundColor: colors.bgPanel,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
statusBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 34,
paddingTop: spacing.xs,
paddingHorizontal: spacing.lg
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.xs
},
sidebarCollapseButton: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
marginLeft: spacing.xs
},
hostIdentity: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
minWidth: 0,
marginRight: spacing.md
},
hostNameText: {
flex: 1,
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
reconnectButton: {
paddingVertical: 4,
paddingHorizontal: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reconnectButtonText: {
color: colors.textPrimary,
fontSize: typography.metaSize,
fontWeight: '600'
},
toolbar: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.md,
gap: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbar: {
paddingVertical: spacing.xs + 2,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
borderBottomWidth: 1,
borderBottomColor: colors.borderSubtle
},
embeddedToolbarRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
embeddedFilterChip: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
embeddedModeButton: {
flex: 1,
minWidth: 0,
height: 30,
justifyContent: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 0
},
filterChip: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle
},
filterChipActive: {
borderColor: colors.textSecondary,
backgroundColor: colors.bgRaised
},
filterChipText: {
fontSize: 12,
color: colors.textSecondary
},
filterChipTextActive: {
color: colors.textPrimary
},
modeButton: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 1,
minWidth: 0,
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs
},
sortLabel: {
flexShrink: 1,
minWidth: 0,
fontSize: 12,
color: colors.textSecondary
},
toolbarSpacer: {
flex: 1
},
floatingWorkspaceHeaderButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs
},
embeddedToolbarIconButton: {
flex: 1,
height: 28,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button
},
toolbarIconDisabled: {
opacity: 0.6
},
searchToggle: {
padding: spacing.xs
},
searchBar: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
emptyText: {
color: colors.textSecondary,
fontSize: typography.bodySize
}
})
@@ -0,0 +1,139 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const hostScreenSecondaryStyles = StyleSheet.create({
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize
},
list: {
paddingBottom: spacing.lg
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: spacing.md,
paddingBottom: spacing.xs
},
sectionIcon: {
marginRight: spacing.xs
},
sectionRepoIcon: {
marginRight: spacing.xs
},
sectionTitle: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5
},
sectionCount: {
fontSize: 11,
color: colors.textMuted,
marginLeft: spacing.xs
},
separator: {
height: 1,
backgroundColor: colors.borderSubtle,
marginLeft: spacing.lg + 24,
marginRight: spacing.lg
},
filterModalHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
filterModalTitle: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
clearFiltersText: {
fontSize: 13,
color: colors.textSecondary
},
filterSectionLabel: {
fontSize: 11,
fontWeight: '600',
color: colors.textMuted,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: spacing.xs,
paddingHorizontal: spacing.xs
},
filterGroup: {
backgroundColor: colors.bgPanel,
borderRadius: 12,
overflow: 'hidden',
marginBottom: spacing.md
},
filterRow: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2,
gap: spacing.sm
},
filterRowText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
},
filterSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
filterRepoDot: {
width: 8,
height: 8,
borderRadius: 4
},
confirmContent: {
paddingBottom: spacing.lg
},
confirmTitle: {
fontSize: 16,
fontWeight: '700',
color: colors.textPrimary
},
confirmMessage: {
fontSize: typography.bodySize,
color: colors.textSecondary,
marginTop: spacing.xs,
lineHeight: 20
},
confirmButtons: {
flexDirection: 'row',
gap: spacing.sm
},
confirmBtn: {
flex: 1,
paddingVertical: spacing.sm + 2,
borderRadius: 10,
alignItems: 'center'
},
confirmBtnCancel: {
backgroundColor: colors.bgPanel
},
confirmBtnDestructive: {
backgroundColor: colors.statusRed
},
confirmBtnPressed: {
opacity: 0.7
},
confirmBtnCancelText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.textSecondary
},
confirmBtnDestructiveText: {
fontSize: typography.bodySize,
fontWeight: '600',
color: '#fff'
}
})
@@ -0,0 +1,7 @@
import { hostScreenPrimaryStyles } from './host-screen-primary-styles'
import { hostScreenSecondaryStyles } from './host-screen-secondary-styles'
export const hostScreenStyles = {
...hostScreenPrimaryStyles,
...hostScreenSecondaryStyles
}
@@ -0,0 +1,192 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalAccessoryRepeatController,
createTerminalAccessoryRepeatSender,
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS
} from './terminal-accessory-repeat'
function deferred() {
let resolve: () => void = () => {}
const promise = new Promise<void>((done) => {
resolve = done
})
return { promise, resolve }
}
describe('terminal accessory repeat', () => {
afterEach(() => vi.useRealTimers())
it('keeps one send in flight and preserves repeat order under host latency', async () => {
vi.useFakeTimers()
const first = deferred()
const second = deferred()
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return (sent.length === 1 ? first.promise : second.promise).then(() => true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
expect(sent).toEqual(['down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_DELAY_MS + 500)
expect(sent).toEqual(['down'])
first.resolve()
await vi.advanceTimersByTimeAsync(0)
expect(sent).toEqual(['down', 'down'])
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS + 500)
expect(sent).toEqual(['down', 'down'])
second.resolve()
await vi.advanceTimersByTimeAsync(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
expect(sent).toEqual(['down', 'down', 'down'])
})
it('does not schedule another repeat after release while a send is pending', async () => {
vi.useFakeTimers()
const pending = deferred()
const send = vi.fn(() => pending.promise.then(() => true))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
repeat.stop()
pending.resolve()
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('dispatches distinct taps without waiting for prior acknowledgements', async () => {
vi.useFakeTimers()
const pending = Array.from({ length: 5 }, () => deferred())
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return pending[sent.length - 1]!.promise.then(() => true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
for (let index = 0; index < pending.length; index += 1) {
repeat.start(`tap-${index}`, send)
repeat.stop()
}
expect(sent).toEqual(['tap-0', 'tap-1', 'tap-2', 'tap-3', 'tap-4'])
pending.forEach(({ resolve }) => resolve())
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(5)
})
it('cancels repeats without dropping a distinct tap already dispatched by the user', async () => {
vi.useFakeTimers()
const first = deferred()
const sent: string[] = []
const send = vi.fn((input: string) => {
sent.push(input)
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
})
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
repeat.stop()
repeat.start('up', send)
repeat.cancel()
first.resolve()
await vi.runAllTimersAsync()
expect(sent).toEqual(['down', 'up'])
expect(send).toHaveBeenCalledTimes(2)
})
it('stops repeating when the transport rejects a send', async () => {
vi.useFakeTimers()
const send = vi.fn(() => Promise.reject(new Error('disconnected')))
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops repeating when the send is dropped before reaching the transport', async () => {
vi.useFakeTimers()
const send = vi.fn(async () => false)
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', send)
await vi.runAllTimersAsync()
expect(send).toHaveBeenCalledTimes(1)
})
it('stops sending when the terminal active at press time is no longer active', async () => {
let activeTerminal = 'terminal-a'
const sendToTerminal = vi.fn(async () => true)
const send = createTerminalAccessoryRepeatSender(
activeTerminal,
(targetHandle) => activeTerminal === targetHandle,
sendToTerminal
)
await expect(send('down')).resolves.toBe(true)
activeTerminal = 'terminal-b'
await expect(send('down')).resolves.toBe(false)
expect(sendToTerminal).toHaveBeenCalledTimes(1)
expect(sendToTerminal).toHaveBeenCalledWith('down', 'terminal-a', expect.any(Function))
})
it('provides a live target guard for async send preflight', async () => {
let targetCurrent = true
const preflight = deferred()
const send = createTerminalAccessoryRepeatSender(
'terminal-a',
() => targetCurrent,
async (_input, _targetHandle, isDeliveryTargetCurrent) => {
await preflight.promise
return isDeliveryTargetCurrent()
}
)
const result = send('down')
targetCurrent = false
preflight.resolve()
await expect(result).resolves.toBe(false)
})
it('stops held repeats when the press-time connection is no longer current', async () => {
vi.useFakeTimers()
let connectionGeneration = 1
const first = deferred()
const sent: string[] = []
const sendToTerminal = vi.fn((input: string) => {
sent.push(input)
return input === 'down' ? first.promise.then(() => true) : Promise.resolve(true)
})
const createSender = () => {
const pressedConnectionGeneration = connectionGeneration
return createTerminalAccessoryRepeatSender(
'terminal-a',
() => connectionGeneration === pressedConnectionGeneration,
sendToTerminal
)
}
const repeat = createTerminalAccessoryRepeatController<string>()
repeat.start('down', createSender())
connectionGeneration = 2
first.resolve()
await vi.runAllTimersAsync()
expect(sent).toEqual(['down'])
})
})
@@ -0,0 +1,76 @@
export const TERMINAL_ACCESSORY_REPEAT_DELAY_MS = 400
export const TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS = 45
type TerminalAccessoryRepeatSender<TInput> = (input: TInput) => Promise<boolean>
type TerminalAccessoryRepeatSendToTerminal<TInput> = (
input: TInput,
targetHandle: string,
isDeliveryTargetCurrent: () => boolean
) => Promise<boolean>
export function createTerminalAccessoryRepeatSender<TInput>(
targetHandle: string | null,
isDeliveryTargetCurrent: (targetHandle: string) => boolean,
sendToTerminal: TerminalAccessoryRepeatSendToTerminal<TInput>
): TerminalAccessoryRepeatSender<TInput> {
return (input) => {
if (!targetHandle || !isDeliveryTargetCurrent(targetHandle)) {
return Promise.resolve(false)
}
return sendToTerminal(input, targetHandle, () => isDeliveryTargetCurrent(targetHandle))
}
}
export function createTerminalAccessoryRepeatController<TInput>() {
let generation = 0
let timer: ReturnType<typeof setTimeout> | null = null
const stop = () => {
generation += 1
if (timer !== null) {
clearTimeout(timer)
timer = null
}
}
const cancel = stop
const start = (input: TInput, send: TerminalAccessoryRepeatSender<TInput>) => {
stop()
const activeGeneration = generation
const pressedAt = Date.now()
const schedule = (delayMs: number) => {
timer = setTimeout(() => {
timer = null
if (generation !== activeGeneration) {
return
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(TERMINAL_ACCESSORY_REPEAT_INTERVAL_MS)
}
},
() => undefined
)
}, delayMs)
}
void send(input).then(
(sent) => {
if (sent && generation === activeGeneration) {
schedule(
Math.min(
TERMINAL_ACCESSORY_REPEAT_DELAY_MS,
Math.max(0, TERMINAL_ACCESSORY_REPEAT_DELAY_MS - (Date.now() - pressedAt))
)
)
}
},
() => undefined
)
}
return { cancel, start, stop }
}
@@ -1,8 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { sendTerminalLiveAccessoryRawBytes } from './terminal-live-accessory-raw-send'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
function captureClient(result: Promise<unknown> = Promise.resolve({ ok: true })) {
function captureClient(
result: Promise<unknown> = Promise.resolve({
id: 'send',
ok: true,
result: { send: { handle: 'terminal-a', accepted: true, bytesWritten: 1 } },
_meta: { runtimeId: 'test-runtime' }
})
) {
const sendRequest = vi.fn(() => result)
return { client: { sendRequest } as unknown as Pick<RpcClient, 'sendRequest'>, sendRequest }
}
@@ -20,7 +28,7 @@ describe('terminal live accessory raw send', () => {
it('sends raw bytes now-or-never with the device presence tag', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
@@ -33,7 +41,9 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes instead of sending while disconnected', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, connState: 'reconnecting' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
@@ -41,16 +51,28 @@ describe('terminal live accessory raw send', () => {
it('drops the bytes when the terminal selection went stale mid-flush', async () => {
const { client, sendRequest } = captureClient()
await sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client, activeHandle: 'terminal-b' })
).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
it('swallows a rejected send so accessory taps never surface transport errors', async () => {
it('reports a rejected send without surfacing transport errors', async () => {
const { client } = captureClient(Promise.reject(new Error('Not connected: terminal.send')))
await expect(
sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })
).resolves.toBeUndefined()
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
it('reports a fulfilled RPC failure as a failed send', async () => {
const response: RpcResponse = {
id: 'send',
ok: false,
error: { code: 'terminal_error', message: 'failed' },
_meta: { runtimeId: 'test-runtime' }
}
const { client } = captureClient(Promise.resolve(response))
await expect(sendTerminalLiveAccessoryRawBytes({ ...BASE_ARGS, client })).resolves.toBe(false)
})
})
@@ -1,4 +1,5 @@
import { getTerminalLiveAccessoryRawSendTarget } from './terminal-live-accessory-raw-send-target'
import { isTerminalSendRpcAccepted } from './terminal-send-rpc-response'
import { buildTerminalSendParams, TERMINAL_INPUT_SEND_OPTIONS } from './terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -15,7 +16,7 @@ type TerminalLiveAccessoryRawSendArgs = {
export async function sendTerminalLiveAccessoryRawBytes(
args: TerminalLiveAccessoryRawSendArgs
): Promise<void> {
): Promise<boolean> {
// Why: async IME flushing can outlive the original terminal selection.
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle: args.targetHandle,
@@ -23,9 +24,9 @@ export async function sendTerminalLiveAccessoryRawBytes(
activeSessionTabType: args.activeSessionTabType
})
if (!args.client || !rawSendTarget || args.connState !== 'connected') {
return
return false
}
await args.client
return args.client
.sendRequest(
'terminal.send',
buildTerminalSendParams({
@@ -36,8 +37,5 @@ export async function sendTerminalLiveAccessoryRawBytes(
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then(
() => undefined,
() => undefined
)
.then(isTerminalSendRpcAccepted, () => false)
}
@@ -70,7 +70,7 @@ function createAccessoryInputCommitHarness({
}
}
const applyLiveInputMirror = vi.fn(
(_handle: string, _fieldText: string, _composing?: boolean) => {}
async (_handle: string, _fieldText: string, _composing?: boolean) => true
)
const clearPendingLiveInputCommit = vi.fn(() => {})
const flushPendingLiveInputText = vi.fn(async (_expectedHandle: string | null) => flushResult)
@@ -171,6 +171,20 @@ describe('terminal live accessory input commit hook', () => {
expect(result).toEqual({ kind: 'handled' })
})
it('Given held text with a failed control send When committed Then reports the accessory input as suppressed', async () => {
const harness = createAccessoryInputCommitHarness({
heldText: '한',
pendingHandle: 'terminal-a',
sendResult: false
})
const result = await harness.commit({ bytes: '\x1b' })
expect(harness.flushPendingLiveInputText).toHaveBeenCalledWith('terminal-a')
expect(harness.sent).toEqual(['\x1b'])
expect(result).toEqual({ kind: 'suppress-raw' })
})
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 })
@@ -21,7 +21,11 @@ export async function getTerminalLiveAccessoryInactiveInputCommitResult(
type TerminalLiveAccessoryInputCommitOptions = {
readonly activeHandle: string | null
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly applyLiveInputMirror: (
handle: string,
fieldText: string,
composing?: boolean
) => Promise<boolean>
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
@@ -84,15 +88,20 @@ export function useTerminalLiveAccessoryInputCommit({
setLiveInputCapture(editedText)
liveInputRef.current?.setNativeProps({ text: editedText })
// Preserve undefined so Android's heuristic hold still settles on its timer.
applyLiveInputMirror(activeHandle, editedText, liveInputComposingRef.current)
return { kind: 'handled' }
const sent = await applyLiveInputMirror(
activeHandle,
editedText,
liveInputComposingRef.current
)
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
case 'commit-held-then-send':
await sendTerminalLiveControlAfterPendingFlush(
case 'commit-held-then-send': {
const sent = await sendTerminalLiveControlAfterPendingFlush(
() => flushPendingLiveInputText(activeHandle),
() => sendLiveTerminalInputRef.current(activeHandle, decision.bytes)
)
return { kind: 'handled' }
return sent ? { kind: 'handled' } : { kind: 'suppress-raw' }
}
default:
decision satisfies never
return { kind: 'handled' }
@@ -190,6 +190,20 @@ describe('terminal live input commit hook', () => {
await vi.waitFor(() => expect(sent).toEqual(['한']))
})
it('Given a failed mirrored Backspace When accessory input commits Then reports failure', async () => {
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
changeLiveInput(handlers, 'a', false)
await vi.waitFor(() => expect(sent).toEqual(['a']))
const result = await handlers.handleLiveInputAccessoryBytes({
bytes: '\x7f',
localEdit: 'backspace'
})
expect(sent).toEqual(['a', '\x7f'])
expect(result).toEqual({ kind: 'suppress-raw' })
})
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
// Given
vi.useFakeTimers()
@@ -133,7 +133,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
// that differs from the native field text, so the controlled capture must
// echo the field verbatim; only the PTY mirror sees normalized text.
setLiveInputCapture(nativeEvent.text)
applyLiveInputMirror(
void applyLiveInputMirror(
activeHandle,
normalizeTerminalTextInput(nativeEvent.text),
nativeEvent.isComposing
@@ -30,7 +30,11 @@ type RunTerminalLiveMirrorStep = (
) => Promise<boolean>
type TerminalLivePendingInputFlush = {
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
readonly applyLiveInputMirror: (
handle: string,
fieldText: string,
composing?: boolean
) => Promise<boolean>
readonly clearPendingLiveInputCommit: () => void
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
readonly heldLiveInputTextRef: RefObject<string>
@@ -153,9 +157,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [runMirrorStep])
const applyLiveInputMirror = useCallback(
(handle: string, fieldText: string, composing?: boolean): void => {
void runMirrorStep(handle, fieldText, false, composing)
},
(handle: string, fieldText: string, composing?: boolean): Promise<boolean> =>
runMirrorStep(handle, fieldText, false, composing),
[runMirrorStep]
)
+213
View File
@@ -0,0 +1,213 @@
import type { WebContents } from 'electron'
import type {
BrowserCaptureStartResult,
BrowserCaptureStopResult,
BrowserConsoleEntry,
BrowserConsoleResult,
BrowserCookie,
BrowserCookieDeleteResult,
BrowserCookieGetResult,
BrowserCookieSetResult,
BrowserGeolocationResult,
BrowserInterceptDisableResult,
BrowserInterceptEnableResult,
BrowserInterceptedRequest,
BrowserNetworkEntry,
BrowserNetworkLogResult,
BrowserViewportResult
} from '../../shared/runtime-types'
import type { CdpCommandSender, SnapshotResult } from './snapshot-engine'
export type CdpTabState = {
navigationId: string | null
snapshotResult: SnapshotResult | null
debuggerAttached: boolean
debuggerDetachListener: (() => void) | null
debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null
iframeSessions: Map<string, string>
capturing: boolean
consoleLog: BrowserConsoleEntry[]
networkLog: BrowserNetworkEntry[]
intercepting: boolean
interceptPatterns: string[]
pausedRequests: Map<string, BrowserInterceptedRequest>
networkRequestMap: Map<string, BrowserNetworkEntry>
}
type ActiveCdpContext = {
guest: WebContents
sender: CdpCommandSender
state: CdpTabState
}
export type CdpAuxiliaryCommandHost = {
run: <T>(operation: (context: ActiveCdpContext) => Promise<T>) => Promise<T>
// Why: captureStop must not attach the debugger — stopping a capture on a tab whose
// debugger detached (DevTools opened) would otherwise throw before clearing state.
runOnState: <T>(
operation: (context: Pick<ActiveCdpContext, 'guest' | 'state'>) => Promise<T>
) => Promise<T>
current: () => Pick<ActiveCdpContext, 'guest' | 'state'>
}
export class CdpAuxiliaryCommands {
constructor(private readonly host: CdpAuxiliaryCommandHost) {}
cookieGet(url?: string): Promise<BrowserCookieGetResult> {
return this.host.run(async ({ sender }) => {
const params: Record<string, unknown> = {}
if (url) {
params.urls = [url]
}
const { cookies } = (await sender('Network.getCookies', params)) as {
cookies: BrowserCookie[]
}
return { cookies }
})
}
cookieSet(cookie: {
name: string
value: string
domain?: string
path?: string
secure?: boolean
httpOnly?: boolean
sameSite?: string
expires?: number
}): Promise<BrowserCookieSetResult> {
return this.host.run(async ({ sender }) => {
let domain = cookie.domain
if (!domain) {
const { result } = (await sender('Runtime.evaluate', {
expression: 'location.hostname',
returnByValue: true
})) as { result: { value: string } }
domain = result.value
}
const params: Record<string, unknown> = {
name: cookie.name,
value: cookie.value,
domain,
path: cookie.path ?? '/',
secure: cookie.secure ?? false,
httpOnly: cookie.httpOnly ?? false,
sameSite: cookie.sameSite ?? 'Lax'
}
if (cookie.expires !== undefined) {
params.expires = cookie.expires
}
const { success } = (await sender('Network.setCookie', params)) as { success: boolean }
return { success }
})
}
cookieDelete(name: string, domain?: string, url?: string): Promise<BrowserCookieDeleteResult> {
return this.host.run(async ({ sender }) => {
const params: Record<string, unknown> = { name }
if (domain) {
params.domain = domain
}
if (url) {
params.url = url
}
if (!domain && !url) {
const { result } = (await sender('Runtime.evaluate', {
expression: 'location.href',
returnByValue: true
})) as { result: { value: string } }
params.url = result.value
}
await sender('Network.deleteCookies', params)
return { deleted: true }
})
}
setViewport(
width: number,
height: number,
deviceScaleFactor = 1,
mobile = false
): Promise<BrowserViewportResult> {
return this.host.run(async ({ sender }) => {
await sender('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor,
mobile
})
await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {})
return { width, height, deviceScaleFactor, mobile }
})
}
setGeolocation(
latitude: number,
longitude: number,
accuracy = 1
): Promise<BrowserGeolocationResult> {
return this.host.run(async ({ sender }) => {
await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy })
return { latitude, longitude, accuracy }
})
}
interceptEnable(patterns: string[] = ['*']): Promise<BrowserInterceptEnableResult> {
return this.host.run(async ({ sender, state }) => {
await sender('Fetch.enable', { patterns: patterns.map((urlPattern) => ({ urlPattern })) })
state.intercepting = true
state.interceptPatterns = patterns
return { enabled: true, patterns }
})
}
interceptDisable(): Promise<BrowserInterceptDisableResult> {
return this.host.run(async ({ sender, state }) => {
await sender('Fetch.disable')
state.intercepting = false
state.interceptPatterns = []
state.pausedRequests.clear()
return { disabled: true }
})
}
interceptList(): { requests: BrowserInterceptedRequest[] } {
const { state } = this.host.current()
return { requests: [...state.pausedRequests.values()] }
}
captureStart(): Promise<BrowserCaptureStartResult> {
return this.host.run(async ({ sender, state }) => {
await sender('Runtime.enable')
state.capturing = true
state.consoleLog = []
state.networkLog = []
state.networkRequestMap.clear()
return { capturing: true }
})
}
captureStop(): Promise<BrowserCaptureStopResult> {
return this.host.runOnState(async ({ state }) => {
state.capturing = false
state.networkRequestMap.clear()
return { stopped: true }
})
}
consoleLog(limit = 100): BrowserConsoleResult {
const { state } = this.host.current()
return {
entries: state.consoleLog.slice(-limit),
truncated: state.consoleLog.length > limit
}
}
networkLog(limit = 100): BrowserNetworkLogResult {
const { state } = this.host.current()
return {
entries: state.networkLog.slice(-limit),
truncated: state.networkLog.length > limit
}
}
}
+58 -210
View File
@@ -6,9 +6,7 @@ import type {
BrowserCheckResult,
BrowserClearResult,
BrowserClickResult,
BrowserConsoleEntry,
BrowserConsoleResult,
BrowserCookie,
BrowserCookieDeleteResult,
BrowserCookieGetResult,
BrowserCookieSetResult,
@@ -39,16 +37,12 @@ import type {
BrowserViewportResult,
BrowserWaitResult
} from '../../shared/runtime-types'
import {
buildSnapshot,
type CdpCommandSender,
type RefEntry,
type SnapshotResult
} from './snapshot-engine'
import { buildSnapshot, type CdpCommandSender, type RefEntry } from './snapshot-engine'
import { insertTextThroughCdp } from './browser-text-insertion'
import type { BrowserManager } from './browser-manager'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { BrowserError } from './browser-error'
import { CdpAuxiliaryCommands, type CdpTabState } from './cdp-auxiliary-commands'
const CAPTURE_LOG_LIMIT = 1000
@@ -56,25 +50,6 @@ const CAPTURE_LOG_LIMIT = 1000
// pulling Chromium in. Existing importers of this path keep working.
export { BrowserError } from './browser-error'
type TabState = {
navigationId: string | null
snapshotResult: SnapshotResult | null
debuggerAttached: boolean
debuggerDetachListener: (() => void) | null
debuggerMessageListener: ((_event: unknown, method: string, params: unknown) => void) | null
iframeSessions: Map<string, string>
// Why: capture state is per-tab so one tab's console/network events don't pollute another's buffer.
capturing: boolean
consoleLog: BrowserConsoleEntry[]
networkLog: BrowserNetworkEntry[]
// Why: interception state lets the agent selectively continue or block individual requests.
intercepting: boolean
interceptPatterns: string[]
pausedRequests: Map<string, BrowserInterceptedRequest>
// Why: maps CDP requestId → networkLog entry so loadingFinished attributes size to the right overlapping response.
networkRequestMap: Map<string, BrowserNetworkEntry>
}
type QueuedCommand = {
execute: () => Promise<unknown>
resolve: (value: unknown) => void
@@ -83,13 +58,45 @@ type QueuedCommand = {
export class CdpBridge {
private activeWebContentsId: number | null = null
private readonly tabState = new Map<string, TabState>()
private readonly tabState = new Map<string, CdpTabState>()
private readonly commandQueues = new Map<string, QueuedCommand[]>()
private readonly processingQueues = new Set<string>()
private readonly browserManager: BrowserManager
private readonly auxiliaryCommands: CdpAuxiliaryCommands
constructor(browserManager: BrowserManager) {
this.browserManager = browserManager
this.auxiliaryCommands = new CdpAuxiliaryCommands({
run: <T>(
operation: (context: {
guest: Electron.WebContents
sender: CdpCommandSender
state: CdpTabState
}) => Promise<T>
): Promise<T> =>
this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const state = this.getOrCreateTabState(this.resolveTabId(guest.id))
return operation({ guest, sender, state })
}),
runOnState: <T>(
operation: (context: { guest: Electron.WebContents; state: CdpTabState }) => Promise<T>
): Promise<T> =>
this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const state = this.getOrCreateTabState(this.resolveTabId(guest.id))
return operation({ guest, state })
}),
current: () => {
const guest = this.getActiveGuest()
return {
guest,
state: this.getOrCreateTabState(this.resolveTabId(guest.id))
}
}
})
}
setActiveTab(webContentsId: number): void {
@@ -636,25 +643,11 @@ export class CdpBridge {
// ── Cookie management ──
async cookieGet(url?: string): Promise<BrowserCookieGetResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const params: Record<string, unknown> = {}
if (url) {
params.urls = [url]
}
const { cookies } = (await sender('Network.getCookies', params)) as {
cookies: BrowserCookie[]
}
return { cookies }
})
cookieGet(url?: string): Promise<BrowserCookieGetResult> {
return this.auxiliaryCommands.cookieGet(url)
}
async cookieSet(cookie: {
cookieSet(cookie: {
name: string
value: string
domain?: string
@@ -664,211 +657,66 @@ export class CdpBridge {
sameSite?: string
expires?: number
}): Promise<BrowserCookieSetResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
// Why: Network.setCookie needs a domain or url to scope the cookie; infer the domain from the current page when omitted.
let domain = cookie.domain
if (!domain) {
const { result: urlResult } = (await sender('Runtime.evaluate', {
expression: 'location.hostname',
returnByValue: true
})) as { result: { value: string } }
domain = urlResult.value
}
const params: Record<string, unknown> = {
name: cookie.name,
value: cookie.value,
domain,
path: cookie.path ?? '/',
secure: cookie.secure ?? false,
httpOnly: cookie.httpOnly ?? false,
sameSite: cookie.sameSite ?? 'Lax'
}
if (cookie.expires !== undefined) {
params.expires = cookie.expires
}
const { success } = (await sender('Network.setCookie', params)) as { success: boolean }
return { success }
})
return this.auxiliaryCommands.cookieSet(cookie)
}
async cookieDelete(
name: string,
domain?: string,
url?: string
): Promise<BrowserCookieDeleteResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const params: Record<string, unknown> = { name }
if (domain) {
params.domain = domain
}
if (url) {
params.url = url
}
// Why: Network.deleteCookies needs a domain or url; infer from the current page if neither was given.
if (!domain && !url) {
const { result: urlResult } = (await sender('Runtime.evaluate', {
expression: 'location.href',
returnByValue: true
})) as { result: { value: string } }
params.url = urlResult.value
}
await sender('Network.deleteCookies', params)
return { deleted: true }
})
cookieDelete(name: string, domain?: string, url?: string): Promise<BrowserCookieDeleteResult> {
return this.auxiliaryCommands.cookieDelete(name, domain, url)
}
// ── Viewport emulation ──
async setViewport(
setViewport(
width: number,
height: number,
deviceScaleFactor = 1,
mobile = false
): Promise<BrowserViewportResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
await sender('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor,
mobile
})
// Why: metrics-only resize can leave the compositor surface at the old size, cropping remote screencast clients.
await Promise.resolve(sender('Emulation.setVisibleSize', { width, height })).catch(() => {})
return { width, height, deviceScaleFactor, mobile }
})
return this.auxiliaryCommands.setViewport(width, height, deviceScaleFactor, mobile)
}
// ── Geolocation ──
async setGeolocation(
setGeolocation(
latitude: number,
longitude: number,
accuracy = 1
): Promise<BrowserGeolocationResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
await sender('Emulation.setGeolocationOverride', { latitude, longitude, accuracy })
return { latitude, longitude, accuracy }
})
return this.auxiliaryCommands.setGeolocation(latitude, longitude, accuracy)
}
// ── Request interception ──
async interceptEnable(patterns: string[] = ['*']): Promise<BrowserInterceptEnableResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
const requestPatterns = patterns.map((p) => ({ urlPattern: p }))
await sender('Fetch.enable', { patterns: requestPatterns })
state.intercepting = true
state.interceptPatterns = patterns
return { enabled: true, patterns }
})
interceptEnable(patterns: string[] = ['*']): Promise<BrowserInterceptEnableResult> {
return this.auxiliaryCommands.interceptEnable(patterns)
}
async interceptDisable(): Promise<BrowserInterceptDisableResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
await sender('Fetch.disable')
state.intercepting = false
state.interceptPatterns = []
state.pausedRequests.clear()
return { disabled: true }
})
interceptDisable(): Promise<BrowserInterceptDisableResult> {
return this.auxiliaryCommands.interceptDisable()
}
interceptList(): { requests: BrowserInterceptedRequest[] } {
const guest = this.getActiveGuest()
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
return { requests: [...state.pausedRequests.values()] }
return this.auxiliaryCommands.interceptList()
}
// TODO: Add interceptContinue/interceptBlock once agent-browser supports per-request decisions (CLI is URL-pattern-only).
// ── Console/network capture ──
async captureStart(): Promise<BrowserCaptureStartResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const sender = this.makeCdpSender(guest)
await this.ensureDebuggerAttached(guest)
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
await sender('Runtime.enable')
state.capturing = true
state.consoleLog = []
state.networkLog = []
state.networkRequestMap.clear()
return { capturing: true }
})
captureStart(): Promise<BrowserCaptureStartResult> {
return this.auxiliaryCommands.captureStart()
}
async captureStop(): Promise<BrowserCaptureStopResult> {
return this.enqueueCommand(async () => {
const guest = this.getActiveGuest()
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
state.capturing = false
state.networkRequestMap.clear()
return { stopped: true }
})
captureStop(): Promise<BrowserCaptureStopResult> {
return this.auxiliaryCommands.captureStop()
}
consoleLog(limit = 100): BrowserConsoleResult {
const guest = this.getActiveGuest()
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
const entries = state.consoleLog.slice(-limit)
return { entries, truncated: state.consoleLog.length > limit }
return this.auxiliaryCommands.consoleLog(limit)
}
networkLog(limit = 100): BrowserNetworkLogResult {
const guest = this.getActiveGuest()
const tabId = this.resolveTabId(guest.id)
const state = this.getOrCreateTabState(tabId)
const entries = state.networkLog.slice(-limit)
return { entries, truncated: state.networkLog.length > limit }
return this.auxiliaryCommands.networkLog(limit)
}
async back(): Promise<{ url: string; title: string }> {
@@ -1077,7 +925,7 @@ export class CdpBridge {
return null
}
private getOrCreateTabState(tabId: string): TabState {
private getOrCreateTabState(tabId: string): CdpTabState {
let state = this.tabState.get(tabId)
if (!state) {
state = {
@@ -1100,7 +948,7 @@ export class CdpBridge {
return state
}
private removeDebuggerListeners(guest: Electron.WebContents, state: TabState): void {
private removeDebuggerListeners(guest: Electron.WebContents, state: CdpTabState): void {
const detachListener = state.debuggerDetachListener
const messageListener = state.debuggerMessageListener
state.debuggerDetachListener = null
+10 -103
View File
@@ -1,11 +1,9 @@
/* eslint-disable max-lines -- Why: owns the full daemon lifecycle (init, launch, adapter wiring,
restart, teardown); the "swap the provider atomically" invariant keeps restart + singletons co-located. */
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { getAppEnvironment } from '../../shared/app-environment'
import { mkdirSync, existsSync, readFileSync, unlinkSync } from 'node:fs'
import { readFileSync, unlinkSync } from 'node:fs'
import { fork, type ChildProcess } from 'node:child_process'
import { connect } from 'node:net'
import {
DaemonSpawner,
getDaemonPidPath,
@@ -52,13 +50,21 @@ import {
rebindLocalProviderListeners
} from '../ipc/pty'
import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from '../startup/startup-diagnostics'
import { getDaemonLogFilePath } from '../observability/logs-directory'
import {
confirmSeededClaudeLivePtys,
hasSeededUnconfirmedClaudePtys
} from '../claude-accounts/live-pty-gate'
import { parseDaemonReadyIdentity, readDaemonProcessIncarnation } from './daemon-ready-identity'
import type { DaemonEndpointIdentity } from './daemon-hello-protocol'
import {
daemonLogArgs,
getAliveDaemonSessionCount,
getDaemonEntryPath,
getDaemonHistoryDir as getHistoryDir,
getDaemonRuntimeDir as getRuntimeDir,
probeDaemonSocket as probeSocket,
resolvePackagedDarwinAppVersion
} from './daemon-launch-paths'
// Why: daemon init runs concurrent with window load, so an in-process t timestamp (not harness stderr timing) measures cold-start.
function logDaemonMilestone(event: string, details: Record<string, unknown> = {}): void {
@@ -101,105 +107,6 @@ let adapter: DaemonProvider | null = null
// Why: coalesce concurrent restartDaemon() calls so two entries can't race the 7-step sequence against a half-spawned replacement.
let restartInFlight: Promise<RestartDaemonResult> | null = null
function getRuntimeDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'daemon')
mkdirSync(dir, { recursive: true })
return dir
}
function getHistoryDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history')
mkdirSync(dir, { recursive: true })
return dir
}
function getDaemonEntryPath(): string {
const appPath = getAppEnvironment().getAppPath()
// Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
// Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds
// orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts).
const basePath = appPath.includes('app.asar')
? appPath.replace('app.asar', 'app.asar.unpacked')
: appPath
const directEntryPath = join(basePath, 'daemon-entry.js')
if (existsSync(directEntryPath)) {
return directEntryPath
}
return join(basePath, 'out', 'main', 'daemon-entry.js')
}
// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host.
function resolvePackagedDarwinAppVersion(): string | null {
const environment = getAppEnvironment()
return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null
}
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
function daemonLogArgs(): string[] {
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
if (disabled === '1' || disabled === 'true') {
return []
}
return ['--log-file', getDaemonLogFilePath()]
}
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
function probeSocket(socketPath: string): Promise<boolean> {
return new Promise((resolve) => {
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return
}
const sock = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
function finish(alive: boolean, options?: { destroy?: boolean }): void {
if (settled) {
return
}
settled = true
clearTimeout(timer)
sock.removeListener('connect', onConnect)
sock.removeListener('error', onError)
if (options?.destroy) {
sock.destroy()
}
resolve(alive)
}
function onConnect(): void {
finish(true, { destroy: true })
}
function onError(): void {
finish(false)
}
timer = setTimeout(() => {
finish(false, { destroy: true })
}, 1000)
sock.on('connect', onConnect)
sock.on('error', onError)
})
}
async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
} finally {
client.disconnect()
}
}
function createPreservedDaemonHandle(
runtimeDir: string,
protocolVersion = PROTOCOL_VERSION,
+93
View File
@@ -0,0 +1,93 @@
import { existsSync, mkdirSync } from 'node:fs'
import { connect } from 'node:net'
import { join } from 'node:path'
import { getAppEnvironment } from '../../shared/app-environment'
import { getDaemonLogFilePath } from '../observability/logs-directory'
import { DaemonClient } from './client'
import { PROTOCOL_VERSION, type ListSessionsResult } from './types'
export function getDaemonRuntimeDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'daemon')
mkdirSync(dir, { recursive: true })
return dir
}
export function getDaemonHistoryDir(): string {
const dir = join(getAppEnvironment().getPath('userData'), 'terminal-history')
mkdirSync(dir, { recursive: true })
return dir
}
export function getDaemonEntryPath(): string {
const appPath = getAppEnvironment().getAppPath()
// Why: packaged getAppPath() points at app.asar, so redirect to app.asar.unpacked where daemon-entry.js is fork-executable.
// Why asar and not isPackaged: orcad is a packaged non-Electron host whose bundle root holds
// orcad.js and daemon-entry.js side by side with no asar to redirect (see parcel-watcher-entry-path.ts).
const basePath = appPath.includes('app.asar')
? appPath.replace('app.asar', 'app.asar.unpacked')
: appPath
const directEntryPath = join(basePath, 'daemon-entry.js')
return existsSync(directEntryPath)
? directEntryPath
: join(basePath, 'out', 'main', 'daemon-entry.js')
}
// macOS TCC attribution pins the daemon to a packaged app bundle; there is none on a Node host.
export function resolvePackagedDarwinAppVersion(): string | null {
const environment = getAppEnvironment()
return process.platform === 'darwin' && environment.isPackaged() ? environment.getVersion() : null
}
// Why: pass a log-file arg so field failures are diagnosable, but honor the ORCA_DIAGNOSTICS_DISABLED privacy switch.
export function daemonLogArgs(): string[] {
const disabled = (process.env.ORCA_DIAGNOSTICS_DISABLED ?? '').trim().toLowerCase()
return disabled === '1' || disabled === 'true' ? [] : ['--log-file', getDaemonLogFilePath()]
}
// Why: a socket that accepts a connection proves a daemon survived a previous app session and can be reused.
export function probeDaemonSocket(socketPath: string): Promise<boolean> {
const { promise, resolve } = Promise.withResolvers<boolean>()
if (process.platform !== 'win32' && !existsSync(socketPath)) {
resolve(false)
return promise
}
const socket = connect({ path: socketPath })
let settled = false
let timer: ReturnType<typeof setTimeout>
const finish = (alive: boolean, destroy = false): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.removeListener('connect', onConnect)
socket.removeListener('error', onError)
if (destroy) {
socket.destroy()
}
resolve(alive)
}
const onConnect = (): void => finish(true, true)
const onError = (): void => finish(false)
timer = setTimeout(() => finish(false, true), 1000)
socket.on('connect', onConnect)
socket.on('error', onError)
return promise
}
export async function getAliveDaemonSessionCount(
socketPath: string,
tokenPath: string,
protocolVersion = PROTOCOL_VERSION
): Promise<number | null> {
const client = new DaemonClient({ socketPath, tokenPath, protocolVersion })
try {
await client.ensureConnected()
const result = await client.request<ListSessionsResult>('listSessions', undefined)
return result.sessions.filter((session) => session.isAlive).length
} catch {
return null
} finally {
client.disconnect()
}
}
@@ -0,0 +1,98 @@
import { basename, delimiter, win32 as pathWin32 } from 'node:path'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { parseWslPath } from '../wsl'
import { resolvePathEnvKey } from '../pty/windows-environment-path'
import { expandWindowsEnvironmentVariables } from '../../shared/windows-environment-expansion'
import { resolveSafePtyDefaultCwd } from './pty-default-cwd'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
export function getDefaultCwd(): string {
return resolveSafePtyDefaultCwd()
}
export function removeUnspecifiedPaneIdentityEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
for (const key of PANE_IDENTITY_ENV_KEYS) {
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
delete env[key]
}
}
}
export function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const normalizedRequestedPath =
process.platform === 'win32'
? expandWindowsEnvironmentVariables(requestedPath, env)
: requestedPath
const pathDelimiter = process.platform === 'win32' ? ';' : delimiter
const shimDir = normalizedRequestedPath.split(pathDelimiter)[0]
if (!shimDir) {
return
}
const pathKey = resolvePathEnvKey(env, process.platform)
const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? []
env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter)
}
export function getWslContextFromWorktreeId(
worktreeId: string | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
// Why: strip any synthetic `::workspace:<uuid>` suffix so WSL detection parses the real path, not a nonexistent identifier.
const worktreePath = worktreeId
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
: undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined
}
export function getWslContextFromPreferredDistro(
distro: string | null | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
const trimmed = distro?.trim()
return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined
}
export function normalizeLocalCallerSessionId(
sessionId: string | undefined,
allowNumeric = false
): string | null {
const requested = sessionId?.trim()
return !requested || (!allowNumeric && /^\d+$/.test(requested)) ? null : requested
}
export function normalizeForegroundProcessName(
processName: string | null | undefined
): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
return !trimmed || trimmed === 'xterm-256color' ? null : trimmed.split(/[\\/]/).pop() || null
}
export function resolveForegroundFallbackProcess(
processName: string | null | undefined,
shellName: string | undefined
): string | null {
if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) {
return processName || null
}
return shellName ?? processName ?? null
}
/** Basename of the spawned shell path, parsed for the *target* platform.
* Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */
export function getSpawnedShellName(shellPath: string): string {
return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath)
}
+13 -127
View File
@@ -1,5 +1,5 @@
/* eslint-disable max-lines -- Why: splitting spawn() would scatter tightly coupled PTY lifecycle logic (scan → ready → write → exit) with no cleaner ownership seam. */
import { basename, delimiter, win32 as pathWin32 } from 'node:path'
import { basename, win32 as pathWin32 } from 'node:path'
import { randomUUID } from 'node:crypto'
import {
ORCA_CODEX_LAUNCH_PREFLIGHT_CMD_QUOTE_ENV,
@@ -15,7 +15,6 @@ import { resolveProcessCwd } from './process-cwd'
import { existsSync } from 'node:fs'
import * as pty from 'node-pty'
import { getDefaultWslDistro, parseWslPath, isWslAvailableAsync } from '../wsl'
import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
import {
injectHistoryEnv,
@@ -76,7 +75,7 @@ import {
} from './windows-cached-agent-revalidation'
import { forceKillPosixPtyProcessGroups } from '../pty/posix-pty-process-groups'
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
import { assertSafeAgentStartupCwd, resolveSafePtyDefaultCwd } from './pty-default-cwd'
import { assertSafeAgentStartupCwd } from './pty-default-cwd'
import { ORCA_HERMES_STARTUP_QUERY_ENV } from '../../shared/hermes-startup-query'
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
import { mergeGitConfigEnvProtocol } from '../../shared/git-credential-prompt-env'
@@ -94,18 +93,19 @@ import {
createShellPromptReadinessProbe,
type ShellPromptReadinessProbe
} from '../shell-prompt-readiness-probe'
import {
expandWindowsEnvironmentVariables,
expandWindowsPathEnvironmentVariables
} from '../../shared/windows-environment-expansion'
import { expandWindowsPathEnvironmentVariables } from '../../shared/windows-environment-expansion'
import { resolveProcessExitCause, type TerminalExitCause } from '../../shared/terminal-exit-cause'
const PANE_IDENTITY_ENV_KEYS = [
'ORCA_PANE_KEY',
'ORCA_TAB_ID',
'ORCA_WORKTREE_ID',
'ORCA_AGENT_LAUNCH_TOKEN'
] as const
import {
getDefaultCwd,
getSpawnedShellName,
getWslContextFromPreferredDistro,
getWslContextFromWorktreeId,
normalizeLocalCallerSessionId,
promoteAgentTeamsShimPath,
removeUnspecifiedPaneIdentityEnv,
resolveForegroundFallbackProcess
} from './local-pty-launch-helpers'
let ptyCounter = 0
const ptyProcesses = new Map<string, pty.IPty>()
@@ -174,51 +174,6 @@ const dataListeners = new Set<DataCallback>()
const exitListeners = new Set<ExitCallback>()
const startupIngressByPty = new Map<string, PtyStartupIngress>()
/**
* Returns a stable default cwd for locally spawned PTYs.
*/
function getDefaultCwd(): string {
return resolveSafePtyDefaultCwd()
}
/**
* Removes inherited pane identity unless this PTY explicitly supplies it.
*/
function removeUnspecifiedPaneIdentityEnv(
env: Record<string, string>,
explicitEnv: Record<string, string> | undefined
): void {
for (const key of PANE_IDENTITY_ENV_KEYS) {
if (!explicitEnv || !Object.hasOwn(explicitEnv, key)) {
delete env[key]
}
}
}
/**
* Promotes the agent-teams shim path ahead of inherited PATH entries.
*/
function promoteAgentTeamsShimPath(
env: Record<string, string>,
requestedPath: string | undefined
): void {
if (!env.ORCA_AGENT_TEAMS_TEAM_ID || !requestedPath) {
return
}
const normalizedRequestedPath =
process.platform === 'win32'
? expandWindowsEnvironmentVariables(requestedPath, env)
: requestedPath
const pathDelimiter = process.platform === 'win32' ? ';' : delimiter
const shimDir = normalizedRequestedPath.split(pathDelimiter)[0]
if (!shimDir) {
return
}
const pathKey = resolvePathEnvKey(env, process.platform)
const currentParts = env[pathKey]?.split(pathDelimiter).filter(Boolean) ?? []
env[pathKey] = [shimDir, ...currentParts.filter((part) => part !== shimDir)].join(pathDelimiter)
}
/**
* Disposes native node-pty listeners registered for a PTY id.
*/
@@ -254,30 +209,6 @@ function runPtyCleanup(id: string): void {
cleanup()
}
/**
* Resolves a WSL context from a worktree id whose path is already a WSL path.
*/
function getWslContextFromWorktreeId(
worktreeId: string | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
// Why: strip any synthetic `::workspace:<uuid>` suffix so WSL detection parses the real path, not a nonexistent identifier.
const worktreePath = worktreeId
? splitWorktreeIdForFilesystem(worktreeId)?.worktreePath
: undefined
const wslInfo = worktreePath ? parseWslPath(worktreePath) : null
return wslInfo ? { distro: wslInfo.distro, treatPosixCwdAsWsl: true } : undefined
}
/**
* Resolves a WSL launch context from a user-selected distro name.
*/
function getWslContextFromPreferredDistro(
distro: string | null | undefined
): { distro: string; treatPosixCwdAsWsl: true } | undefined {
const trimmed = distro?.trim()
return trimmed ? { distro: trimmed, treatPosixCwdAsWsl: true } : undefined
}
/**
* Removes all local tracking state for a PTY id after teardown.
*/
@@ -413,20 +344,6 @@ function cancelAllPendingLocalPtySpawns(): void {
}
}
/**
* Normalizes renderer session ids that should be reused for local PTY reattach.
*/
function normalizeLocalCallerSessionId(
sessionId: string | undefined,
allowNumeric = false
): string | null {
const requested = sessionId?.trim()
if (!requested || (!allowNumeric && /^\d+$/.test(requested))) {
return null
}
return requested
}
function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResult | null {
const existing = ptyProcesses.get(id)
if (!existing) {
@@ -445,37 +362,6 @@ function reattachLocalPty(id: string, cols: number, rows: number): PtySpawnResul
}
}
/**
* Normalizes node-pty foreground process strings to executable basenames.
*/
function normalizeForegroundProcessName(processName: string | null | undefined): string | null {
const trimmed = processName?.trim().replace(/^["']|["']$/g, '') ?? ''
if (!trimmed || trimmed === 'xterm-256color') {
return null
}
return trimmed.split(/[\\/]/).pop() || null
}
/**
* Falls back to the spawned Windows shell when node-pty reports a terminal name.
*/
function resolveForegroundFallbackProcess(
processName: string | null | undefined,
shellName: string | undefined
): string | null {
if (process.platform !== 'win32' || normalizeForegroundProcessName(processName)) {
return processName || null
}
// Why: Windows node-pty may expose only the terminal name (`xterm-256color`); the spawned shell is the best foreground fallback.
return shellName ?? processName ?? null
}
/** Basename of the spawned shell path, parsed for the *target* platform.
* Why: POSIX `basename` won't split a Windows `\` path (non-Windows host/CI), so it'd break the foreground comparison. */
function getSpawnedShellName(shellPath: string): string {
return process.platform === 'win32' ? pathWin32.basename(shellPath) : basename(shellPath)
}
/**
* Disposes the native PTY handle while avoiding recycled-pid signals on POSIX.
*/
+14 -274
View File
@@ -47,11 +47,23 @@ import type {
GitLabMRUpdate,
GitLabPipelineJob,
GitLabWorkItem,
GitLabWorkItemDetails,
MRComment
GitLabWorkItemDetails
} from '../../../shared/gitlab-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import { translate } from '@/i18n/i18n'
import {
CommentCard,
PipelineJobRow,
StateBadge,
dedupeGitLabUsers,
formatGitLabLabelDraft,
gitLabUserKey,
normalizeGitLabLabels,
parseGitLabLabelDraft,
showGitLabMutationError,
toggleGitLabLabelDraft,
type JobTraceState
} from './gitlab-item-dialog-parts'
type Props = {
item: GitLabWorkItem | null
@@ -68,278 +80,6 @@ type GitLabDialogRepoSelector = {
sourceContext?: TaskSourceContext | null
}
type JobTraceState = {
loading: boolean
trace?: string
error?: string
}
// Why: GitLab MR / issue states map onto a coarser palette than GitHub.
const STATE_TONE: Record<GitLabWorkItem['state'], string> = {
opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300',
locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
}
// Why: pipeline job statuses map to one of four visual buckets — keep
// the mapping local so the renderer doesn't depend on the backend's
// shared mapper module (which is main-process only).
function jobStatusTone(status: string): string {
switch (status) {
case 'success':
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
case 'failed':
return 'bg-rose-500/15 text-rose-700 dark:text-rose-300'
case 'running':
case 'pending':
case 'created':
case 'preparing':
case 'waiting_for_resource':
case 'scheduled':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'manual':
return 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
case 'canceled':
case 'skipped':
default:
return 'bg-muted text-muted-foreground'
}
}
function showGitLabMutationError(error: unknown): void {
const message = error instanceof Error && error.message ? error.message : String(error)
toast.error(
message === 'undefined' || message === 'null'
? translate('auto.components.GitLabItemDialog.gitlabActionFailed', 'GitLab action failed.')
: message
)
}
function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
STATE_TONE[state]
)}
>
{state}
</span>
)
}
function normalizeGitLabLabels(labels: readonly string[]): string[] {
const seen = new Set<string>()
const normalized: string[] = []
for (const label of labels) {
const trimmed = label.trim()
const key = trimmed.toLowerCase()
if (!trimmed || seen.has(key)) {
continue
}
seen.add(key)
normalized.push(trimmed)
}
return normalized
}
function parseGitLabLabelDraft(value: string): string[] {
return normalizeGitLabLabels(value.split(','))
}
function formatGitLabLabelDraft(labels: readonly string[]): string {
return normalizeGitLabLabels(labels).join(', ')
}
function toggleGitLabLabelDraft(value: string, label: string): string {
const labels = parseGitLabLabelDraft(value)
const key = label.trim().toLowerCase()
const next = labels.some((item) => item.toLowerCase() === key)
? labels.filter((item) => item.toLowerCase() !== key)
: [...labels, label]
return formatGitLabLabelDraft(next)
}
function gitLabUserKey(user: GitLabAssignableUser): string {
return typeof user.id === 'number' ? `id:${user.id}` : `username:${user.username.toLowerCase()}`
}
function dedupeGitLabUsers(users: readonly GitLabAssignableUser[]): GitLabAssignableUser[] {
const byKey = new Map<string, GitLabAssignableUser>()
for (const user of users) {
byKey.set(gitLabUserKey(user), user)
}
return Array.from(byKey.values()).sort((a, b) => a.username.localeCompare(b.username))
}
function CommentCard({
comment,
canResolve,
resolving,
onResolve
}: {
comment: MRComment
canResolve?: boolean
resolving?: boolean
onResolve?: (threadId: string, resolved: boolean) => void
}): React.JSX.Element {
const hasThread = Boolean(comment.threadId)
return (
<div className="rounded-md border border-border/40 bg-muted/30 p-3">
<div className="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt=""
className="size-5 rounded-full"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className="font-medium text-foreground">{comment.author}</span>
{comment.isResolved ? (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.GitLabItemDialog.f23ea85341', 'resolved')}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{canResolve && hasThread && onResolve ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={resolving}
onClick={() => onResolve(comment.threadId ?? '', !comment.isResolved)}
className="h-6"
>
{resolving ? <LoaderCircle className="size-3 animate-spin" /> : null}
{comment.isResolved
? translate('auto.components.GitLabItemDialog.65e784c1f1', 'Reopen')
: translate('auto.components.GitLabItemDialog.4168eb2c51', 'Resolve')}
</Button>
) : null}
<span>{comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''}</span>
</div>
</div>
{comment.path ? (
<div className="mb-1.5 font-mono text-[11px] text-muted-foreground">
{comment.path}
{comment.line ? `:${comment.line}` : ''}
</div>
) : null}
<CommentMarkdown
content={comment.body}
variant="document"
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
</div>
)
}
function PipelineJobRow({
job,
expanded,
traceState,
retrying,
onToggleTrace,
onRetry
}: {
job: GitLabPipelineJob
expanded: boolean
traceState?: JobTraceState
retrying: boolean
onToggleTrace: (job: GitLabPipelineJob) => void
onRetry: (job: GitLabPipelineJob) => void
}): React.JSX.Element {
const canRetry = ['failed', 'canceled', 'cancelled'].includes(job.status)
return (
<div className="rounded-md">
<div className="grid w-full grid-cols-[minmax(0,2fr)_minmax(0,1fr)_80px_64px_96px] items-center gap-3 px-3 py-2 text-left text-sm hover:bg-muted/40">
<button
type="button"
onClick={() => onToggleTrace(job)}
className="min-w-0 truncate text-left font-medium"
>
{job.name}
</button>
<span className="min-w-0 truncate text-xs text-muted-foreground">{job.stage}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-wide',
jobStatusTone(job.status)
)}
>
{job.status}
</span>
<span className="text-right text-[11px] text-muted-foreground">
{/* Why: durations come back as seconds; show "Nm Ns" for >60s
and "Ns" otherwise. null = job hasn't finished. */}
{typeof job.duration === 'number'
? job.duration >= 60
? `${Math.floor(job.duration / 60)}m ${Math.floor(job.duration % 60)}s`
: `${Math.floor(job.duration)}s`
: '—'}
</span>
<div className="flex justify-end gap-1">
{canRetry ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={retrying}
onClick={() => onRetry(job)}
className="h-6"
>
{retrying ? <LoaderCircle className="size-3 animate-spin" /> : null}
{translate('auto.components.GitLabItemDialog.fa3e042203', 'Retry')}
</Button>
) : null}
{job.webUrl ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => void window.api.shell.openUrl(job.webUrl)}
title={translate('auto.components.GitLabItemDialog.032ae1312b', 'Open job in GitLab')}
>
<ExternalLink className="size-3" />
</Button>
) : null}
</div>
</div>
{expanded ? (
<div className="mx-3 mb-2 rounded-md border border-border/50 bg-muted/20">
<div className="flex items-center justify-between border-b border-border/40 px-2.5 py-1.5 text-[11px] text-muted-foreground">
<span>{translate('auto.components.GitLabItemDialog.2f9b27f838', 'Job log')}</span>
<Button type="button" variant="ghost" size="xs" onClick={() => onToggleTrace(job)}>
{translate('auto.components.GitLabItemDialog.028bde664e', 'Hide')}
</Button>
</div>
{traceState?.loading ? (
<div className="flex items-center gap-2 px-2.5 py-3 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.GitLabItemDialog.d600c2619a', 'Loading log')}
</div>
) : traceState?.error ? (
<div className="px-2.5 py-3 text-xs text-destructive">{traceState.error}</div>
) : (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words px-2.5 py-2 font-mono text-[11px] leading-4 text-foreground scrollbar-sleek">
{traceState?.trace?.trim()
? traceState.trace
: translate('auto.components.GitLabItemDialog.32f8bef818', 'No log output.')}
</pre>
)}
</div>
) : null}
</div>
)
}
export default function GitLabItemDialog({
item,
repoPath,
@@ -0,0 +1,134 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types'
import { CheckRunDetailsPanel } from './CheckRunDetailsPanel'
const writeClipboardText = vi.fn<(text: string) => Promise<void>>()
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/components/sidebar/CommentMarkdown', () => ({
default: ({ content }: { content: string }) => content
}))
vi.mock('@/components/right-sidebar/source-control-fix-split-button', () => ({
SourceControlFixSplitButton: () => null
}))
vi.mock('./check-run-details-fix-with-ai', () => ({
useCheckRunDetailsFixWithAI: () => ({
canFixWithAI: false,
disabledReason: null,
isFixing: false,
fixPrompt: '',
repoId: null,
connectionId: null,
launchPlatform: null,
savedAgentId: null,
savedCommandInputTemplate: null,
savedAgentArgs: [],
saveLaunchActionDefault: vi.fn(),
openSourceControlAiSettings: vi.fn(),
fixWithAI: vi.fn()
})
}))
afterEach(cleanup)
beforeEach(() => {
writeClipboardText.mockReset().mockResolvedValue(undefined)
Object.assign(window, { api: { ui: { writeClipboardText } } })
})
const check: PRCheckDetail = {
name: 'package',
status: 'completed',
conclusion: 'failure',
url: null,
checkRunId: 42
}
const details: PRCheckRunDetails = {
name: 'package',
status: 'completed',
conclusion: 'failure',
url: null,
detailsUrl: null,
startedAt: null,
completedAt: null,
title: 'Build failed',
summary: 'One test failed.',
text: 'Run the suite locally.',
annotations: [
{
path: 'src/main.ts',
startLine: 10,
endLine: 10,
annotationLevel: 'failure',
title: 'Type error',
message: 'Expected string',
rawDetails: 'Actual: number'
}
],
jobs: [
{
id: 1,
name: 'unit',
status: 'completed',
conclusion: 'failure',
startedAt: null,
completedAt: null,
url: null,
logTail: 'AssertionError: expected true',
steps: [
{
name: 'test',
status: 'completed',
conclusion: 'failure',
startedAt: null,
completedAt: null
}
]
}
]
}
describe('CheckRunDetailsPanel card copy actions', () => {
it('renders a copy action for every card and copies each card payload', async () => {
render(
<TooltipProvider>
<CheckRunDetailsPanel
check={check}
details={details}
loading={false}
error={null}
openUrl={null}
worktreeId={null}
/>
</TooltipProvider>
)
fireEvent.click(screen.getByRole('button', { name: 'Copy output' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy annotations' }))
fireEvent.click(screen.getByRole('button', { name: 'Copy jobs' }))
await waitFor(() => expect(writeClipboardText).toHaveBeenCalledTimes(3))
expect(writeClipboardText).toHaveBeenNthCalledWith(
1,
'Build failed\n\nOne test failed.\n\nRun the suite locally.'
)
expect(writeClipboardText).toHaveBeenNthCalledWith(
2,
'src/main.ts:10\nfailure\nType error\nExpected string\nActual: number'
)
expect(writeClipboardText).toHaveBeenNthCalledWith(
3,
'unit: failure\ntest: failure\nAssertionError: expected true'
)
})
})
@@ -0,0 +1,285 @@
import React from 'react'
import { ExternalLink, LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type {
GitLabAssignableUser,
GitLabPipelineJob,
GitLabWorkItem,
MRComment
} from '../../../shared/gitlab-types'
export type JobTraceState = {
loading: boolean
trace?: string
error?: string
}
// Why: GitLab MR / issue states map onto a coarser palette than GitHub.
export const STATE_TONE: Record<GitLabWorkItem['state'], string> = {
opened: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
closed: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
merged: 'bg-violet-500/15 text-violet-700 dark:text-violet-300',
locked: 'bg-rose-500/15 text-rose-700 dark:text-rose-300',
draft: 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
}
// Why: pipeline job statuses map to one of four visual buckets — keep
// the mapping local so the renderer doesn't depend on the backend's
// shared mapper module (which is main-process only).
export function jobStatusTone(status: string): string {
switch (status) {
case 'success':
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300'
case 'failed':
return 'bg-rose-500/15 text-rose-700 dark:text-rose-300'
case 'running':
case 'pending':
case 'created':
case 'preparing':
case 'waiting_for_resource':
case 'scheduled':
return 'bg-sky-500/15 text-sky-700 dark:text-sky-300'
case 'manual':
return 'bg-amber-500/15 text-amber-700 dark:text-amber-300'
case 'canceled':
case 'skipped':
default:
return 'bg-muted text-muted-foreground'
}
}
export function showGitLabMutationError(error: unknown): void {
const message = error instanceof Error && error.message ? error.message : String(error)
toast.error(
message === 'undefined' || message === 'null'
? translate('auto.components.GitLabItemDialog.gitlabActionFailed', 'GitLab action failed.')
: message
)
}
export function StateBadge({ state }: { state: GitLabWorkItem['state'] }): React.JSX.Element {
return (
<span
className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide',
STATE_TONE[state]
)}
>
{state}
</span>
)
}
export function normalizeGitLabLabels(labels: readonly string[]): string[] {
const seen = new Set<string>()
const normalized: string[] = []
for (const label of labels) {
const trimmed = label.trim()
const key = trimmed.toLowerCase()
if (!trimmed || seen.has(key)) {
continue
}
seen.add(key)
normalized.push(trimmed)
}
return normalized
}
export function parseGitLabLabelDraft(value: string): string[] {
return normalizeGitLabLabels(value.split(','))
}
export function formatGitLabLabelDraft(labels: readonly string[]): string {
return normalizeGitLabLabels(labels).join(', ')
}
export function toggleGitLabLabelDraft(value: string, label: string): string {
const labels = parseGitLabLabelDraft(value)
const key = label.trim().toLowerCase()
const next = labels.some((item) => item.toLowerCase() === key)
? labels.filter((item) => item.toLowerCase() !== key)
: [...labels, label]
return formatGitLabLabelDraft(next)
}
export function gitLabUserKey(user: GitLabAssignableUser): string {
return typeof user.id === 'number' ? `id:${user.id}` : `username:${user.username.toLowerCase()}`
}
export function dedupeGitLabUsers(users: readonly GitLabAssignableUser[]): GitLabAssignableUser[] {
const byKey = new Map<string, GitLabAssignableUser>()
for (const user of users) {
byKey.set(gitLabUserKey(user), user)
}
return Array.from(byKey.values()).sort((a, b) => a.username.localeCompare(b.username))
}
export function CommentCard({
comment,
canResolve,
resolving,
onResolve
}: {
comment: MRComment
canResolve?: boolean
resolving?: boolean
onResolve?: (threadId: string, resolved: boolean) => void
}): React.JSX.Element {
const hasThread = Boolean(comment.threadId)
return (
<div className="rounded-md border border-border/40 bg-muted/30 p-3">
<div className="mb-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{comment.authorAvatarUrl ? (
<img
src={comment.authorAvatarUrl}
alt=""
className="size-5 rounded-full"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
) : null}
<span className="font-medium text-foreground">{comment.author}</span>
{comment.isResolved ? (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-medium text-emerald-700 dark:text-emerald-300">
{translate('auto.components.GitLabItemDialog.f23ea85341', 'resolved')}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{canResolve && hasThread && onResolve ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={resolving}
onClick={() => onResolve(comment.threadId ?? '', !comment.isResolved)}
className="h-6"
>
{resolving ? <LoaderCircle className="size-3 animate-spin" /> : null}
{comment.isResolved
? translate('auto.components.GitLabItemDialog.65e784c1f1', 'Reopen')
: translate('auto.components.GitLabItemDialog.4168eb2c51', 'Resolve')}
</Button>
) : null}
<span>{comment.createdAt ? new Date(comment.createdAt).toLocaleDateString() : ''}</span>
</div>
</div>
{comment.path ? (
<div className="mb-1.5 font-mono text-[11px] text-muted-foreground">
{comment.path}
{comment.line ? `:${comment.line}` : ''}
</div>
) : null}
<CommentMarkdown
content={comment.body}
variant="document"
className="min-w-0 max-w-full overflow-hidden break-words text-[13px] leading-relaxed [&_a]:break-all [&_code]:break-words [&_pre]:max-w-full"
/>
</div>
)
}
export function PipelineJobRow({
job,
expanded,
traceState,
retrying,
onToggleTrace,
onRetry
}: {
job: GitLabPipelineJob
expanded: boolean
traceState?: JobTraceState
retrying: boolean
onToggleTrace: (job: GitLabPipelineJob) => void
onRetry: (job: GitLabPipelineJob) => void
}): React.JSX.Element {
const canRetry = ['failed', 'canceled', 'cancelled'].includes(job.status)
return (
<div className="rounded-md">
<div className="grid w-full grid-cols-[minmax(0,2fr)_minmax(0,1fr)_80px_64px_96px] items-center gap-3 px-3 py-2 text-left text-sm hover:bg-muted/40">
<button
type="button"
onClick={() => onToggleTrace(job)}
className="min-w-0 truncate text-left font-medium"
>
{job.name}
</button>
<span className="min-w-0 truncate text-xs text-muted-foreground">{job.stage}</span>
<span
className={cn(
'rounded-full px-2 py-0.5 text-center text-[10px] font-medium uppercase tracking-wide',
jobStatusTone(job.status)
)}
>
{job.status}
</span>
<span className="text-right text-[11px] text-muted-foreground">
{/* Why: durations come back as seconds; show "Nm Ns" for >60s
and "Ns" otherwise. null = job hasn't finished. */}
{typeof job.duration === 'number'
? job.duration >= 60
? `${Math.floor(job.duration / 60)}m ${Math.floor(job.duration % 60)}s`
: `${Math.floor(job.duration)}s`
: '—'}
</span>
<div className="flex justify-end gap-1">
{canRetry ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={retrying}
onClick={() => onRetry(job)}
className="h-6"
>
{retrying ? <LoaderCircle className="size-3 animate-spin" /> : null}
{translate('auto.components.GitLabItemDialog.fa3e042203', 'Retry')}
</Button>
) : null}
{job.webUrl ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
onClick={() => void window.api.shell.openUrl(job.webUrl)}
title={translate('auto.components.GitLabItemDialog.032ae1312b', 'Open job in GitLab')}
>
<ExternalLink className="size-3" />
</Button>
) : null}
</div>
</div>
{expanded ? (
<div className="mx-3 mb-2 rounded-md border border-border/50 bg-muted/20">
<div className="flex items-center justify-between border-b border-border/40 px-2.5 py-1.5 text-[11px] text-muted-foreground">
<span>{translate('auto.components.GitLabItemDialog.2f9b27f838', 'Job log')}</span>
<Button type="button" variant="ghost" size="xs" onClick={() => onToggleTrace(job)}>
{translate('auto.components.GitLabItemDialog.028bde664e', 'Hide')}
</Button>
</div>
{traceState?.loading ? (
<div className="flex items-center gap-2 px-2.5 py-3 text-xs text-muted-foreground">
<LoaderCircle className="size-3.5 animate-spin" />
{translate('auto.components.GitLabItemDialog.d600c2619a', 'Loading log')}
</div>
) : traceState?.error ? (
<div className="px-2.5 py-3 text-xs text-destructive">{traceState.error}</div>
) : (
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words px-2.5 py-2 font-mono text-[11px] leading-4 text-foreground scrollbar-sleek">
{traceState?.trace?.trim()
? traceState.trace
: translate('auto.components.GitLabItemDialog.32f8bef818', 'No log output.')}
</pre>
)}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Repo } from '../../../../shared/repo-types'
import { RepositoryForkSyncSection } from './RepositoryForkSyncSection'
const syncRuntimeGitForkDefaultBranch = vi.fn()
vi.mock('../../runtime/runtime-git-client', () => ({
syncRuntimeGitForkDefaultBranch: (...args: unknown[]) => syncRuntimeGitForkDefaultBranch(...args)
}))
vi.mock('../../store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ settingsSearchQuery: '', settings: { activeRuntimeEnvironmentId: 'env-1' } })
}))
vi.mock('sonner', () => ({
toast: Object.assign(vi.fn(), { message: vi.fn(), success: vi.fn(), error: vi.fn() })
}))
const RUNTIME_REPO: Repo = {
id: 'repo-1',
path: '/srv/repo-1',
displayName: 'repo-1',
badgeColor: '#000000',
addedAt: 0,
kind: 'git',
executionHostId: 'runtime:env-1',
forkSyncMode: 'ask',
upstream: { owner: 'up', repo: 'r' }
}
describe('RepositoryForkSyncSection', () => {
afterEach(() => {
cleanup()
syncRuntimeGitForkDefaultBranch.mockReset()
})
it('syncs a runtime-hosted repo by its main worktree id, not the bare repo id', () => {
// Why: the runtime rejects `id:<repo-id>` with worktree_id_requires_full_path (#16447).
syncRuntimeGitForkDefaultBranch.mockResolvedValue({ status: 'up-to-date', behind: 0 })
render(
<RepositoryForkSyncSection repo={RUNTIME_REPO} updateRepo={vi.fn()} forceVisible={true} />
)
fireEvent.click(screen.getByRole('button', { name: /sync now/i }))
expect(syncRuntimeGitForkDefaultBranch).toHaveBeenCalledWith(
expect.objectContaining({ worktreeId: 'repo-1::/srv/repo-1', worktreePath: '/srv/repo-1' }),
{ owner: 'up', repo: 'r' }
)
})
})
@@ -3,6 +3,7 @@ import { RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
import type { ForkSyncMode, GitForkSyncResult } from '../../../../shared/git-fork-sync'
import type { Repo } from '../../../../shared/repo-types'
import { getRepoMainWorktreeId } from '../../../../shared/worktree/id'
import { Button } from '../ui/button'
import { SearchableSetting } from './SearchableSetting'
import { SettingsSegmentedControl } from './SettingsFormControls'
@@ -128,7 +129,7 @@ export function RepositoryForkSyncSection({
const result = await syncRuntimeGitForkDefaultBranch(
{
settings: getRepoOwnerRoutedSettings(settings, repo),
worktreeId: repo.id,
worktreeId: getRepoMainWorktreeId(repo),
worktreePath: repo.path,
connectionId: repo.connectionId ?? undefined
},
@@ -0,0 +1,168 @@
type CursorQueueEntry = {
chunks: { data: string }[]
chunkIndex: number
}
export const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
function findCursorPositionSequenceEnd(
data: string,
fromIndex: number,
toIndex = data.length
): number {
let offset = data.indexOf('\x1b[', fromIndex)
while (offset !== -1 && offset < toIndex) {
let index = offset + 2
while (index < toIndex) {
const char = data[index]
if (char === 'G' || char === 'H' || char === 'f') {
return index + 1
}
if ((char < '0' || char > '9') && char !== ';') {
break
}
index += 1
}
offset = data.indexOf('\x1b[', offset + 2)
}
return -1
}
export function removeTransientCursorShowSequences(data: string): string {
let result = ''
let offset = 0
let showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE)
while (showIndex !== -1) {
const nextHideIndex = data.indexOf(
CURSOR_HIDE_SEQUENCE,
showIndex + CURSOR_SHOW_SEQUENCE.length
)
const nextPositionEnd = findCursorPositionSequenceEnd(
data,
showIndex + CURSOR_SHOW_SEQUENCE.length,
nextHideIndex === -1 ? data.length : nextHideIndex
)
if (nextHideIndex === -1) {
if (nextPositionEnd === -1) {
const synchronizedEndIndex = data.indexOf(
SYNCHRONIZED_OUTPUT_END_SEQUENCE,
showIndex + CURSOR_SHOW_SEQUENCE.length
)
if (synchronizedEndIndex === -1) {
break
}
// Why: keep the cursor hidden through the synchronized repaint, restoring it after the frame ends so Windows never paints it in the transient draw position.
result += data.slice(offset, showIndex)
result += data.slice(
showIndex + CURSOR_SHOW_SEQUENCE.length,
synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
)
result += CURSOR_SHOW_SEQUENCE
offset = synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
continue
}
// Why: Codex can show the cursor before its final synchronized-frame placement. Place first so xterm cannot rasterize the stale cell.
result += data.slice(offset, showIndex)
result += data.slice(showIndex + CURSOR_SHOW_SEQUENCE.length, nextPositionEnd)
result += CURSOR_SHOW_SEQUENCE
offset = nextPositionEnd
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
continue
}
result += data.slice(offset, showIndex)
offset = showIndex + CURSOR_SHOW_SEQUENCE.length
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
}
return offset === 0 ? data : result + data.slice(offset)
}
function containsCursorPositionSequence(data: string): boolean {
let offset = data.indexOf('\x1b[')
while (offset !== -1) {
let index = offset + 2
while (index < data.length) {
const char = data[index]
if (char === 'G' || char === 'H' || char === 'f') {
return true
}
if ((char < '0' || char > '9') && char !== ';') {
break
}
index += 1
}
offset = data.indexOf('\x1b[', offset + 2)
}
return false
}
function containsCursorRestore(data: string): boolean {
const hideIndex = data.indexOf(CURSOR_HIDE_SEQUENCE)
const showIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE)
return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data)
}
export function containsDrainableCursorRestore(data: string): boolean {
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return containsCursorRestore(data)
}
return containsCursorRestore(
data.slice(synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length)
)
}
export function containsFinalCursorPlacementBeforeSynchronizedEnd(data: string): boolean {
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return false
}
const lastShowIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE, synchronizedEndIndex)
if (lastShowIndex === -1) {
return false
}
const lastHideIndex = data.lastIndexOf(CURSOR_HIDE_SEQUENCE, synchronizedEndIndex)
if (lastHideIndex > lastShowIndex) {
return false
}
return (
findCursorPositionSequenceEnd(
data,
lastShowIndex + CURSOR_SHOW_SEQUENCE.length,
synchronizedEndIndex
) !== -1
)
}
function previewQueuedData(entry: CursorQueueEntry, limit: number): string {
let data = ''
for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) {
const chunk = entry.chunks[index]
const remaining = limit - data.length
if (remaining <= 0) {
break
}
data += chunk.data.slice(0, remaining)
}
return data
}
export function coalescedQueuedDataNeedsCursorRestore(entry: CursorQueueEntry): boolean {
const data = previewQueuedData(entry, SYNC_FOREGROUND_FLUSH_CHARS)
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return false
}
const synchronizedFrame = data.slice(
0,
synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
)
return (
containsCursorRestore(synchronizedFrame) &&
!containsFinalCursorPlacementBeforeSynchronizedEnd(synchronizedFrame) &&
!containsDrainableCursorRestore(data)
)
}
@@ -0,0 +1,181 @@
import { flattenRetainedSlice } from '@/lib/flatten-retained-slice'
import type {
QueueEntry,
QueuedWrite,
TerminalOutputBeforeWrite,
TerminalOutputParsedCallback
} from './pane-terminal-output-scheduler'
import { recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure } from './pane-terminal-output-scheduler-debug'
type ForegroundRefreshSyncResolver = () => boolean
const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true
export function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
let remaining = limit
let data = ''
let foreground: boolean | null = null
let forceForegroundRefresh = false
let followupForegroundRefresh = false
let shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver | null = null
let additionalRefreshSyncResolvers: ForegroundRefreshSyncResolver[] | null = null
let stripTransientCursorShows = false
let beforeWrite: TerminalOutputBeforeWrite | undefined
let additionalBeforeWriteCallbacks: TerminalOutputBeforeWrite[] | null = null
const parsedCallbacks: TerminalOutputParsedCallback[] = []
const ackCredits: (() => void)[] = []
while (remaining > 0 && entry.chunkIndex < entry.chunks.length) {
const chunk = entry.chunks[entry.chunkIndex]
if (foreground !== null && chunk.foreground !== foreground) {
break
}
foreground ??= chunk.foreground
forceForegroundRefresh ||= chunk.forceForegroundRefresh
followupForegroundRefresh ||= chunk.followupForegroundRefresh
// Why: one drained write can combine chunks from different renderer states or producers; preserve every forced policy and prep hook.
if (chunk.forceForegroundRefresh) {
if (shouldRefreshForegroundSynchronously === null) {
shouldRefreshForegroundSynchronously = chunk.shouldRefreshForegroundSynchronously
} else if (
chunk.shouldRefreshForegroundSynchronously !== shouldRefreshForegroundSynchronously &&
!additionalRefreshSyncResolvers?.includes(chunk.shouldRefreshForegroundSynchronously)
) {
additionalRefreshSyncResolvers ??= []
additionalRefreshSyncResolvers.push(chunk.shouldRefreshForegroundSynchronously)
}
}
stripTransientCursorShows ||= chunk.stripTransientCursorShows
if (!beforeWrite) {
beforeWrite = chunk.beforeWrite
} else if (
chunk.beforeWrite &&
chunk.beforeWrite !== beforeWrite &&
!additionalBeforeWriteCallbacks?.includes(chunk.beforeWrite)
) {
additionalBeforeWriteCallbacks ??= []
additionalBeforeWriteCallbacks.push(chunk.beforeWrite)
}
if (chunk.data.length <= remaining) {
data += chunk.data
remaining -= chunk.data.length
entry.queuedChars -= chunk.data.length
// Clear drained slots before the 64-chunk compaction can release them.
entry.chunks[entry.chunkIndex] = {
data: '',
retainedChars: 0,
foreground: chunk.foreground,
forceForegroundRefresh: false,
followupForegroundRefresh: false,
shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY,
stripTransientCursorShows: false
}
entry.chunkIndex += 1
if (chunk.onParsed) {
parsedCallbacks.push(chunk.onParsed)
}
if (chunk.ackCredit) {
ackCredits.push(chunk.ackCredit)
}
continue
}
data += chunk.data.slice(0, remaining)
const residual = chunk.data.slice(remaining)
// Geometric flattening bounds retained parents while keeping total copy work linear.
const flatten = residual.length * 2 <= chunk.retainedChars
entry.chunks[entry.chunkIndex] = {
...chunk,
data: flatten ? flattenRetainedSlice(residual) : residual,
retainedChars: flatten ? residual.length : chunk.retainedChars
}
entry.queuedChars -= remaining
remaining = 0
}
compactConsumedChunks(entry)
if (entry.queuedChars < 0) {
entry.queuedChars = 0
}
recordQueueDebugPressure()
return data
? {
data,
foreground: foreground === true,
forceForegroundRefresh,
followupForegroundRefresh,
shouldRefreshForegroundSynchronously:
additionalRefreshSyncResolvers && shouldRefreshForegroundSynchronously
? () =>
shouldRefreshForegroundSynchronously() ||
additionalRefreshSyncResolvers.some((resolve) => resolve())
: (shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY),
stripTransientCursorShows,
beforeWrite:
additionalBeforeWriteCallbacks && beforeWrite
? (queuedData) => {
beforeWrite(queuedData)
for (const callback of additionalBeforeWriteCallbacks) {
callback(queuedData)
}
}
: beforeWrite,
onParsed:
parsedCallbacks.length > 0
? () => {
for (const callback of parsedCallbacks) {
callback()
}
}
: undefined,
ackCredits
}
: null
}
export function compactConsumedChunks(entry: QueueEntry): void {
if (entry.chunkIndex === 0) {
return
}
if (entry.chunkIndex === entry.chunks.length) {
entry.chunks.length = 0
entry.chunkIndex = 0
return
}
if (entry.chunkIndex >= 64) {
entry.chunks.splice(0, entry.chunkIndex)
entry.chunkIndex = 0
}
}
export function enqueueChunk(
entry: QueueEntry,
data: string,
options?: {
foreground?: boolean
forceForegroundRefresh?: boolean
followupForegroundRefresh?: boolean
shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver
stripTransientCursorShows?: boolean
beforeWrite?: TerminalOutputBeforeWrite
onParsed?: TerminalOutputParsedCallback
ackCredit?: () => void
}
): void {
// Own queued data so producer slices cannot pin larger PTY or restore buffers.
const owned = flattenRetainedSlice(data)
entry.chunks.push({
data: owned,
retainedChars: owned.length,
foreground: options?.foreground === true,
forceForegroundRefresh: options?.forceForegroundRefresh === true,
followupForegroundRefresh: options?.followupForegroundRefresh === true,
shouldRefreshForegroundSynchronously:
options?.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY,
stripTransientCursorShows: options?.stripTransientCursorShows === true,
beforeWrite: options?.beforeWrite,
onParsed: options?.onParsed,
ackCredit: options?.ackCredit
})
entry.queuedChars += data.length
recordQueueDebugPressure()
}
@@ -0,0 +1,110 @@
import { e2eConfig } from '@/lib/e2e-config'
type QueueDebugEntry = { queuedChars: number }
// Why the cap is lossy: a backgrounded Chromium document throttles timers while PTYs keep writing, so unbounded hidden scrollback would grow renderer memory until the app crashes.
type TerminalOutputSchedulerDebugSnapshot = {
backgroundEnqueueCount: number
deferredForegroundEnqueueCount: number
foregroundWriteCount: number
backgroundWriteCount: number
deferredForegroundWriteCount: number
flushWriteCount: number
scheduledDrainCount: number
queuedTerminalCount: number
queuedChars: number
peakQueuedTerminalCount: number
peakQueuedChars: number
peakQueuedCharsByTerminal: number
droppedBacklogCount: number
drainWrites: number[]
drainHighPriority: boolean[]
}
export const terminalOutputSchedulerDebugEnabled = e2eConfig.exposeStore
export const terminalOutputSchedulerDebugState: TerminalOutputSchedulerDebugSnapshot = {
backgroundEnqueueCount: 0,
deferredForegroundEnqueueCount: 0,
foregroundWriteCount: 0,
backgroundWriteCount: 0,
deferredForegroundWriteCount: 0,
flushWriteCount: 0,
scheduledDrainCount: 0,
queuedTerminalCount: 0,
queuedChars: 0,
peakQueuedTerminalCount: 0,
peakQueuedChars: 0,
peakQueuedCharsByTerminal: 0,
droppedBacklogCount: 0,
drainWrites: [],
drainHighPriority: []
}
let readQueueEntries: () => Iterable<QueueDebugEntry> = () => []
export function setTerminalOutputDebugQueueReader(reader: () => Iterable<QueueDebugEntry>): void {
readQueueEntries = reader
}
function resetDebugState(): void {
const state = terminalOutputSchedulerDebugState
state.backgroundEnqueueCount = 0
state.deferredForegroundEnqueueCount = 0
state.foregroundWriteCount = 0
state.backgroundWriteCount = 0
state.deferredForegroundWriteCount = 0
state.flushWriteCount = 0
state.scheduledDrainCount = 0
state.queuedTerminalCount = 0
state.queuedChars = 0
state.peakQueuedTerminalCount = 0
state.peakQueuedChars = 0
state.peakQueuedCharsByTerminal = 0
state.droppedBacklogCount = 0
state.drainWrites = []
state.drainHighPriority = []
}
export function recordTerminalOutputQueueDebugPressure(): void {
if (!terminalOutputSchedulerDebugEnabled) {
return
}
let queuedTerminalCount = 0
let queuedChars = 0
let queuedCharsByTerminal = 0
for (const entry of readQueueEntries()) {
queuedTerminalCount++
queuedChars += entry.queuedChars
queuedCharsByTerminal = Math.max(queuedCharsByTerminal, entry.queuedChars)
}
const state = terminalOutputSchedulerDebugState
state.queuedTerminalCount = queuedTerminalCount
state.queuedChars = queuedChars
state.peakQueuedTerminalCount = Math.max(state.peakQueuedTerminalCount, queuedTerminalCount)
state.peakQueuedChars = Math.max(state.peakQueuedChars, queuedChars)
state.peakQueuedCharsByTerminal = Math.max(state.peakQueuedCharsByTerminal, queuedCharsByTerminal)
}
export function exposeTerminalOutputSchedulerDebugApi(): void {
if (!terminalOutputSchedulerDebugEnabled || typeof window === 'undefined') {
return
}
// Why: the e2e repro must prove background output used the shared drain, but production must not accumulate diagnostic counters indefinitely.
const target = window as unknown as {
__terminalOutputSchedulerDebug?: {
reset: () => void
snapshot: () => TerminalOutputSchedulerDebugSnapshot
}
}
target.__terminalOutputSchedulerDebug ??= {
reset: resetDebugState,
snapshot: () => {
recordTerminalOutputQueueDebugPressure()
return {
...terminalOutputSchedulerDebugState,
drainWrites: [...terminalOutputSchedulerDebugState.drainWrites],
drainHighPriority: [...terminalOutputSchedulerDebugState.drainHighPriority]
}
}
}
}
@@ -1,5 +1,4 @@
/* oxlint-disable max-lines -- Why: output ordering, foreground settle, queue state, and e2e diagnostics form one state machine; splitting it would make backlog/resume guarantees harder to audit. */
import { e2eConfig } from '@/lib/e2e-config'
import {
discardForegroundRenderSettle,
writeForegroundTerminalChunk,
@@ -7,7 +6,6 @@ import {
} from './pane-terminal-foreground-render-settle'
import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard'
import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder'
import { flattenRetainedSlice } from '@/lib/flatten-retained-slice'
import {
discardInFlightTerminalOutputAckCredits,
registerTerminalOutputAckCredits
@@ -24,12 +22,26 @@ import {
TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS,
terminalOutputBacklogCapChars
} from '../../../../shared/terminal-scrollback-policy'
import {
coalescedQueuedDataNeedsCursorRestore,
containsDrainableCursorRestore,
removeTransientCursorShowSequences,
SYNC_FOREGROUND_FLUSH_CHARS
} from './pane-terminal-cursor-sequencing'
import {
exposeTerminalOutputSchedulerDebugApi as exposeDebugApi,
recordTerminalOutputQueueDebugPressure as recordQueueDebugPressure,
setTerminalOutputDebugQueueReader,
terminalOutputSchedulerDebugEnabled as debugEnabled,
terminalOutputSchedulerDebugState as debugState
} from './pane-terminal-output-scheduler-debug'
import { enqueueChunk, takeQueuedChunk } from './pane-terminal-output-queue-chunks'
type TerminalOutputTarget = ForegroundTerminalOutputTarget
type TerminalOutputBeforeWrite = (data: string) => void
export type TerminalOutputBeforeWrite = (data: string) => void
type TerminalBacklogRecoveryRequest = () => boolean
type TerminalOutputParsedCallback = () => void
export type TerminalOutputParsedCallback = () => void
type ForegroundRefreshSyncResolver = () => boolean
type WriteTerminalOutputOptions = {
@@ -62,7 +74,7 @@ type QueueChunk = {
ackCredit?: () => void
}
type QueuedWrite = {
export type QueuedWrite = {
data: string
foreground: boolean
forceForegroundRefresh: boolean
@@ -74,7 +86,7 @@ type QueuedWrite = {
ackCredits: (() => void)[]
}
type QueueEntry = {
export type QueueEntry = {
terminal: TerminalOutputTarget
chunks: QueueChunk[]
chunkIndex: number
@@ -103,7 +115,6 @@ const MAX_WRITES_PER_DRAIN = 2
const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8
const DRAIN_TIME_BUDGET_MS = 8
const LARGE_BACKLOG_CHARS = 512 * 1024
const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024
// Why mutable: the cap scales with the user's scrollback setting (terminalOutputBacklogCapChars), configured when settings apply; the chunk-count cap stays fixed.
let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS
const MAX_BACKGROUND_QUEUE_CHUNKS = 4096
@@ -117,9 +128,6 @@ const FOREGROUND_HOLD_SAFETY_DELAY_MS = 250
// Why: key repeat can tick every 30-50ms; one frame catches split restores without batching multiple typed-character redraws behind the fallback.
const LATENCY_SENSITIVE_FOREGROUND_COALESCE_DELAY_MS = 16
const LATENCY_SENSITIVE_FOREGROUND_HOLD_SAFETY_DELAY_MS = 32
const CURSOR_SHOW_SEQUENCE = '\x1b[?25h'
const CURSOR_HIDE_SEQUENCE = '\x1b[?25l'
const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l'
// Why: leading CAN aborts any partial escape sequence before the style reset so the backlog warning renders cleanly.
const BACKGROUND_BACKLOG_WARNING =
'\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n'
@@ -129,6 +137,7 @@ const FOREGROUND_BACKLOG_WARNING =
const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true
const queuedByTerminal = new Map<TerminalOutputTarget, QueueEntry>()
setTerminalOutputDebugQueueReader(() => queuedByTerminal.values())
const backlogRecoveryByTerminal = new WeakMap<
TerminalOutputTarget,
TerminalBacklogRecoveryRequest
@@ -169,125 +178,6 @@ export function setUseMessageChannelDrainForTesting(value: boolean | null): void
cancelImmediateDrain()
useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv())
}
const debugEnabled = e2eConfig.exposeStore
// Why the cap is lossy: a backgrounded Chromium document throttles timers while PTYs keep writing, so unbounded hidden scrollback would grow renderer memory until the app crashes.
type TerminalOutputSchedulerDebugSnapshot = {
backgroundEnqueueCount: number
deferredForegroundEnqueueCount: number
foregroundWriteCount: number
backgroundWriteCount: number
deferredForegroundWriteCount: number
flushWriteCount: number
scheduledDrainCount: number
queuedTerminalCount: number
queuedChars: number
peakQueuedTerminalCount: number
peakQueuedChars: number
peakQueuedCharsByTerminal: number
droppedBacklogCount: number
drainWrites: number[]
drainHighPriority: boolean[]
}
type TerminalOutputSchedulerDebugApi = {
reset: () => void
snapshot: () => TerminalOutputSchedulerDebugSnapshot
}
const debugState: TerminalOutputSchedulerDebugSnapshot = {
backgroundEnqueueCount: 0,
deferredForegroundEnqueueCount: 0,
foregroundWriteCount: 0,
backgroundWriteCount: 0,
deferredForegroundWriteCount: 0,
flushWriteCount: 0,
scheduledDrainCount: 0,
queuedTerminalCount: 0,
queuedChars: 0,
peakQueuedTerminalCount: 0,
peakQueuedChars: 0,
peakQueuedCharsByTerminal: 0,
droppedBacklogCount: 0,
drainWrites: [],
drainHighPriority: []
}
function resetDebugState(): void {
debugState.backgroundEnqueueCount = 0
debugState.deferredForegroundEnqueueCount = 0
debugState.foregroundWriteCount = 0
debugState.backgroundWriteCount = 0
debugState.deferredForegroundWriteCount = 0
debugState.flushWriteCount = 0
debugState.scheduledDrainCount = 0
debugState.queuedTerminalCount = 0
debugState.queuedChars = 0
debugState.peakQueuedTerminalCount = 0
debugState.peakQueuedChars = 0
debugState.peakQueuedCharsByTerminal = 0
debugState.droppedBacklogCount = 0
debugState.drainWrites = []
debugState.drainHighPriority = []
}
function readQueueDebugSnapshot(): {
queuedTerminalCount: number
queuedChars: number
queuedCharsByTerminal: number
} {
let queuedChars = 0
let queuedCharsByTerminal = 0
for (const entry of queuedByTerminal.values()) {
queuedChars += entry.queuedChars
queuedCharsByTerminal = Math.max(queuedCharsByTerminal, entry.queuedChars)
}
return {
queuedTerminalCount: queuedByTerminal.size,
queuedChars,
queuedCharsByTerminal
}
}
function recordQueueDebugPressure(): void {
if (!debugEnabled) {
return
}
const current = readQueueDebugSnapshot()
debugState.queuedTerminalCount = current.queuedTerminalCount
debugState.queuedChars = current.queuedChars
debugState.peakQueuedTerminalCount = Math.max(
debugState.peakQueuedTerminalCount,
current.queuedTerminalCount
)
debugState.peakQueuedChars = Math.max(debugState.peakQueuedChars, current.queuedChars)
debugState.peakQueuedCharsByTerminal = Math.max(
debugState.peakQueuedCharsByTerminal,
current.queuedCharsByTerminal
)
}
function exposeDebugApi(): void {
if (!debugEnabled || typeof window === 'undefined') {
return
}
// Why: the e2e repro must prove background output used the shared drain, but production must not accumulate diagnostic counters indefinitely.
const target = window as unknown as {
__terminalOutputSchedulerDebug?: TerminalOutputSchedulerDebugApi
}
target.__terminalOutputSchedulerDebug ??= {
reset: resetDebugState,
snapshot: () => {
recordQueueDebugPressure()
return {
...debugState,
drainWrites: [...debugState.drainWrites],
drainHighPriority: [...debugState.drainHighPriority]
}
}
}
}
function scheduleDrain(delayMs: number): void {
if (drainImmediatePending) {
@@ -430,335 +320,6 @@ function isEntryDrainable(entry: QueueEntry): boolean {
return !entry.foregroundHold && !entry.foregroundCoalesce
}
function findCursorPositionSequenceEnd(
data: string,
fromIndex: number,
toIndex = data.length
): number {
let offset = data.indexOf('\x1b[', fromIndex)
while (offset !== -1 && offset < toIndex) {
let index = offset + 2
while (index < toIndex) {
const char = data[index]
if (char === 'G' || char === 'H' || char === 'f') {
return index + 1
}
if ((char < '0' || char > '9') && char !== ';') {
break
}
index += 1
}
offset = data.indexOf('\x1b[', offset + 2)
}
return -1
}
function removeTransientCursorShowSequences(data: string): string {
let result = ''
let offset = 0
let showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE)
while (showIndex !== -1) {
const nextHideIndex = data.indexOf(
CURSOR_HIDE_SEQUENCE,
showIndex + CURSOR_SHOW_SEQUENCE.length
)
const nextPositionEnd = findCursorPositionSequenceEnd(
data,
showIndex + CURSOR_SHOW_SEQUENCE.length,
nextHideIndex === -1 ? data.length : nextHideIndex
)
if (nextHideIndex === -1) {
if (nextPositionEnd === -1) {
const synchronizedEndIndex = data.indexOf(
SYNCHRONIZED_OUTPUT_END_SEQUENCE,
showIndex + CURSOR_SHOW_SEQUENCE.length
)
if (synchronizedEndIndex === -1) {
break
}
// Why: keep the cursor hidden through the synchronized repaint, restoring it after the frame ends so Windows never paints it in the transient draw position.
result += data.slice(offset, showIndex)
result += data.slice(
showIndex + CURSOR_SHOW_SEQUENCE.length,
synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
)
result += CURSOR_SHOW_SEQUENCE
offset = synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
continue
}
// Why: Codex can show the cursor before its final synchronized-frame placement. Place first so xterm cannot rasterize the stale cell.
result += data.slice(offset, showIndex)
result += data.slice(showIndex + CURSOR_SHOW_SEQUENCE.length, nextPositionEnd)
result += CURSOR_SHOW_SEQUENCE
offset = nextPositionEnd
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
continue
}
result += data.slice(offset, showIndex)
offset = showIndex + CURSOR_SHOW_SEQUENCE.length
showIndex = data.indexOf(CURSOR_SHOW_SEQUENCE, offset)
}
return offset === 0 ? data : result + data.slice(offset)
}
function containsCursorPositionSequence(data: string): boolean {
let offset = data.indexOf('\x1b[')
while (offset !== -1) {
let index = offset + 2
while (index < data.length) {
const char = data[index]
if (char === 'G' || char === 'H' || char === 'f') {
return true
}
if ((char < '0' || char > '9') && char !== ';') {
break
}
index += 1
}
offset = data.indexOf('\x1b[', offset + 2)
}
return false
}
function containsCursorRestore(data: string): boolean {
const hideIndex = data.indexOf(CURSOR_HIDE_SEQUENCE)
const showIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE)
return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data)
}
function containsDrainableCursorRestore(data: string): boolean {
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return containsCursorRestore(data)
}
return containsCursorRestore(
data.slice(synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length)
)
}
function containsFinalCursorPlacementBeforeSynchronizedEnd(data: string): boolean {
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return false
}
const lastShowIndex = data.lastIndexOf(CURSOR_SHOW_SEQUENCE, synchronizedEndIndex)
if (lastShowIndex === -1) {
return false
}
const lastHideIndex = data.lastIndexOf(CURSOR_HIDE_SEQUENCE, synchronizedEndIndex)
if (lastHideIndex > lastShowIndex) {
return false
}
return (
findCursorPositionSequenceEnd(
data,
lastShowIndex + CURSOR_SHOW_SEQUENCE.length,
synchronizedEndIndex
) !== -1
)
}
function previewQueuedData(entry: QueueEntry, limit: number): string {
let data = ''
for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) {
const chunk = entry.chunks[index]
const remaining = limit - data.length
if (remaining <= 0) {
break
}
data += chunk.data.slice(0, remaining)
}
return data
}
function coalescedQueuedDataNeedsCursorRestore(entry: QueueEntry): boolean {
const data = previewQueuedData(entry, SYNC_FOREGROUND_FLUSH_CHARS)
const synchronizedEndIndex = data.lastIndexOf(SYNCHRONIZED_OUTPUT_END_SEQUENCE)
if (synchronizedEndIndex === -1) {
return false
}
const synchronizedFrame = data.slice(
0,
synchronizedEndIndex + SYNCHRONIZED_OUTPUT_END_SEQUENCE.length
)
return (
containsCursorRestore(synchronizedFrame) &&
!containsFinalCursorPlacementBeforeSynchronizedEnd(synchronizedFrame) &&
!containsDrainableCursorRestore(data)
)
}
function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null {
let remaining = limit
let data = ''
let foreground: boolean | null = null
let forceForegroundRefresh = false
let followupForegroundRefresh = false
let shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver | null = null
let additionalRefreshSyncResolvers: ForegroundRefreshSyncResolver[] | null = null
let stripTransientCursorShows = false
let beforeWrite: TerminalOutputBeforeWrite | undefined
let additionalBeforeWriteCallbacks: TerminalOutputBeforeWrite[] | null = null
const parsedCallbacks: TerminalOutputParsedCallback[] = []
const ackCredits: (() => void)[] = []
while (remaining > 0 && entry.chunkIndex < entry.chunks.length) {
const chunk = entry.chunks[entry.chunkIndex]
if (foreground !== null && chunk.foreground !== foreground) {
break
}
foreground ??= chunk.foreground
forceForegroundRefresh ||= chunk.forceForegroundRefresh
followupForegroundRefresh ||= chunk.followupForegroundRefresh
// Why: one drained write can combine chunks from different renderer states or producers; preserve every forced policy and prep hook.
if (chunk.forceForegroundRefresh) {
if (shouldRefreshForegroundSynchronously === null) {
shouldRefreshForegroundSynchronously = chunk.shouldRefreshForegroundSynchronously
} else if (
chunk.shouldRefreshForegroundSynchronously !== shouldRefreshForegroundSynchronously &&
!additionalRefreshSyncResolvers?.includes(chunk.shouldRefreshForegroundSynchronously)
) {
additionalRefreshSyncResolvers ??= []
additionalRefreshSyncResolvers.push(chunk.shouldRefreshForegroundSynchronously)
}
}
stripTransientCursorShows ||= chunk.stripTransientCursorShows
if (!beforeWrite) {
beforeWrite = chunk.beforeWrite
} else if (
chunk.beforeWrite &&
chunk.beforeWrite !== beforeWrite &&
!additionalBeforeWriteCallbacks?.includes(chunk.beforeWrite)
) {
additionalBeforeWriteCallbacks ??= []
additionalBeforeWriteCallbacks.push(chunk.beforeWrite)
}
if (chunk.data.length <= remaining) {
data += chunk.data
remaining -= chunk.data.length
entry.queuedChars -= chunk.data.length
// Clear drained slots before the 64-chunk compaction can release them.
entry.chunks[entry.chunkIndex] = {
data: '',
retainedChars: 0,
foreground: chunk.foreground,
forceForegroundRefresh: false,
followupForegroundRefresh: false,
shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY,
stripTransientCursorShows: false
}
entry.chunkIndex += 1
if (chunk.onParsed) {
parsedCallbacks.push(chunk.onParsed)
}
if (chunk.ackCredit) {
ackCredits.push(chunk.ackCredit)
}
continue
}
data += chunk.data.slice(0, remaining)
const residual = chunk.data.slice(remaining)
// Geometric flattening bounds retained parents while keeping total copy work linear.
const flatten = residual.length * 2 <= chunk.retainedChars
entry.chunks[entry.chunkIndex] = {
...chunk,
data: flatten ? flattenRetainedSlice(residual) : residual,
retainedChars: flatten ? residual.length : chunk.retainedChars
}
entry.queuedChars -= remaining
remaining = 0
}
compactConsumedChunks(entry)
if (entry.queuedChars < 0) {
entry.queuedChars = 0
}
recordQueueDebugPressure()
return data
? {
data,
foreground: foreground === true,
forceForegroundRefresh,
followupForegroundRefresh,
shouldRefreshForegroundSynchronously:
additionalRefreshSyncResolvers && shouldRefreshForegroundSynchronously
? () =>
shouldRefreshForegroundSynchronously() ||
additionalRefreshSyncResolvers.some((resolve) => resolve())
: (shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY),
stripTransientCursorShows,
beforeWrite:
additionalBeforeWriteCallbacks && beforeWrite
? (queuedData) => {
beforeWrite(queuedData)
for (const callback of additionalBeforeWriteCallbacks) {
callback(queuedData)
}
}
: beforeWrite,
onParsed:
parsedCallbacks.length > 0
? () => {
for (const callback of parsedCallbacks) {
callback()
}
}
: undefined,
ackCredits
}
: null
}
function compactConsumedChunks(entry: QueueEntry): void {
if (entry.chunkIndex === 0) {
return
}
if (entry.chunkIndex === entry.chunks.length) {
entry.chunks.length = 0
entry.chunkIndex = 0
return
}
if (entry.chunkIndex >= 64) {
entry.chunks.splice(0, entry.chunkIndex)
entry.chunkIndex = 0
}
}
function enqueueChunk(
entry: QueueEntry,
data: string,
options?: {
foreground?: boolean
forceForegroundRefresh?: boolean
followupForegroundRefresh?: boolean
shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver
stripTransientCursorShows?: boolean
beforeWrite?: TerminalOutputBeforeWrite
onParsed?: TerminalOutputParsedCallback
ackCredit?: () => void
}
): void {
// Own queued data so producer slices cannot pin larger PTY or restore buffers.
const owned = flattenRetainedSlice(data)
entry.chunks.push({
data: owned,
retainedChars: owned.length,
foreground: options?.foreground === true,
forceForegroundRefresh: options?.forceForegroundRefresh === true,
followupForegroundRefresh: options?.followupForegroundRefresh === true,
shouldRefreshForegroundSynchronously:
options?.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY,
stripTransientCursorShows: options?.stripTransientCursorShows === true,
beforeWrite: options?.beforeWrite,
onParsed: options?.onParsed,
ackCredit: options?.ackCredit
})
entry.queuedChars += data.length
recordQueueDebugPressure()
}
// Why: every discard path MUST fire these before clearing/replacing the queue — a dropped chunk still counts as consumed, or main's in-flight window shrinks permanently and the PTY wedges.
function fireQueuedAckCredits(entry: QueueEntry): void {
for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) {
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '../types'
import type { Repo } from '../../../../shared/repo-types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { safeAutoForkSyncAttempts, scheduleSafeAutoForkSync } from './safe-auto-fork-sync'
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
const gitSyncFork = vi.fn()
const RUNTIME_REPO: Repo = {
id: 'repo-1',
path: '/srv/repo-1',
displayName: 'repo-1',
badgeColor: '#000000',
addedAt: 0,
kind: 'git',
executionHostId: 'runtime:env-1',
forkSyncMode: 'safe-auto',
upstream: { owner: 'up', repo: 'r' }
}
function stateWith(repo: Repo): AppState {
return {
repos: [repo],
settings: { activeRuntimeEnvironmentId: 'env-1' }
} as unknown as AppState
}
async function flushScheduledSyncs(): Promise<void> {
await Promise.all([...safeAutoForkSyncAttempts.values()].map((attempt) => attempt.promise))
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
safeAutoForkSyncAttempts.clear()
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentTransportCall.mockReset()
gitSyncFork.mockReset()
gitSyncFork.mockResolvedValue({ status: 'up-to-date', behind: 0 })
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-1',
ok: true,
result: { status: 'up-to-date', behind: 0 },
_meta: { runtimeId: 'remote-runtime' }
})
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
git: { syncFork: gitSyncFork },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
describe('scheduleSafeAutoForkSync', () => {
it('addresses a runtime-hosted repo by its main worktree id, not the bare repo id', async () => {
// Why: the runtime rejects `id:<repo-id>` with worktree_id_requires_full_path (#16447).
scheduleSafeAutoForkSync(() => stateWith(RUNTIME_REPO), [RUNTIME_REPO])
await flushScheduledSyncs()
expect(runtimeEnvironmentCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'git.forkSync',
params: expect.objectContaining({ worktree: 'id:repo-1::/srv/repo-1' })
})
)
})
it('still runs local repos against the repo path over local git IPC', async () => {
const localRepo: Repo = {
...RUNTIME_REPO,
id: 'repo-2',
path: '/home/me/repo-2',
executionHostId: 'local'
}
scheduleSafeAutoForkSync(
() =>
({ ...stateWith(localRepo), settings: { activeRuntimeEnvironmentId: null } }) as AppState,
[localRepo]
)
await flushScheduledSyncs()
expect(gitSyncFork).toHaveBeenCalledWith({
worktreePath: '/home/me/repo-2',
connectionId: undefined,
expectedUpstream: { owner: 'up', repo: 'r' }
})
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
})
@@ -2,6 +2,7 @@ import type { AppState } from '../types'
import type { Repo } from '../../../../shared/repo-types'
import { syncRuntimeGitForkDefaultBranch } from '../../runtime/runtime-git-client'
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
import { getRepoMainWorktreeId } from '../../../../shared/worktree/id'
import { settingsForRepoOwner } from './owner-routing'
export const SAFE_AUTO_FORK_SYNC_COOLDOWN_MS = 10 * 60 * 1000
@@ -32,7 +33,7 @@ export function scheduleSafeAutoForkSync(get: () => AppState, repos: readonly Re
const promise = syncRuntimeGitForkDefaultBranch(
{
settings: settingsForRepoOwner(get(), repo.id),
worktreeId: repo.id,
worktreeId: getRepoMainWorktreeId(repo),
worktreePath: repo.path,
connectionId: repo.connectionId ?? undefined
},
+14
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
WORKTREE_ID_SEPARATOR,
getRepoIdFromWorktreeId,
getRepoMainWorktreeId,
getWorktreePathBasenameFromId,
splitWorktreeId,
splitWorktreeIdForFilesystem,
@@ -14,6 +15,19 @@ describe('WORKTREE_ID_SEPARATOR', () => {
})
})
describe('getRepoMainWorktreeId', () => {
it('round-trips through the id parsers on posix and Windows paths', () => {
for (const repo of [
{ id: 'repo-123', path: '/abs/path' },
{ id: 'repo-123', path: 'C:\\Users\\me\\repo' }
]) {
const worktreeId = getRepoMainWorktreeId(repo)
expect(worktreeId).toBe(`${repo.id}${WORKTREE_ID_SEPARATOR}${repo.path}`)
expect(splitWorktreeId(worktreeId)).toEqual({ repoId: repo.id, worktreePath: repo.path })
}
})
})
describe('getRepoIdFromWorktreeId', () => {
it('returns the repo id for a canonical worktree id', () => {
expect(getRepoIdFromWorktreeId('repo-123::/abs/path')).toBe('repo-123')
+9
View File
@@ -1,5 +1,6 @@
import { normalizeRuntimePathForComparison } from '../cross-platform-path'
import { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format'
import type { Repo } from '../repo-types'
export { WORKTREE_ID_SEPARATOR } from '../pty-session-id-format'
@@ -13,6 +14,14 @@ const FOLDER_WORKSPACE_INSTANCE_SUFFIX = new RegExp(
`${FOLDER_WORKSPACE_INSTANCE_SEPARATOR.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[0-9a-f-]{36}$`
)
/**
* Worktree id of the repo's own checkout. A bare repo id is never a valid worktree id —
* runtimes reject it with `worktree_id_requires_full_path` (#16447).
*/
export function getRepoMainWorktreeId(repo: Pick<Repo, 'id' | 'path'>): string {
return `${repo.id}${WORKTREE_ID_SEPARATOR}${repo.path}`
}
export function getRepoIdFromWorktreeId(worktreeId: string): string {
const separatorIdx = worktreeId.indexOf(WORKTREE_ID_SEPARATOR)
return separatorIdx === -1 ? worktreeId : worktreeId.slice(0, separatorIdx)