+ {GRAPES.map((grape, index) => {
+ const energy = active ? normalizedLevel : 0
+ const scale = grape.base + energy * grape.response
+ const verticalOffset = energy * grape.lift * 2
+ return (
+
+ )
+ })}
+
+ )
+}
diff --git a/src/renderer/src/components/dictation/DictationIndicator.test.tsx b/src/renderer/src/components/dictation/DictationIndicator.test.tsx
index 89fb0e726af..6bc3572f520 100644
--- a/src/renderer/src/components/dictation/DictationIndicator.test.tsx
+++ b/src/renderer/src/components/dictation/DictationIndicator.test.tsx
@@ -10,6 +10,11 @@ import { DICTATION_CONTROL_EVENT, type DictationControlAction } from './dictatio
const storeState = {
dictationState: 'listening' as DictationState,
partialTranscript: '',
+ dictationMeter: {
+ level: 0,
+ isSpeaking: false,
+ isClipping: false
+ },
settings: { voice: { dictationMode: 'toggle' } } as {
voice?: { dictationMode?: 'toggle' | 'hold' }
} | null
@@ -20,6 +25,10 @@ vi.mock('@/store', () => {
return { useAppStore }
})
+vi.mock('./dictation-meter-store', () => ({
+ useDictationMeter: () => storeState.dictationMeter
+}))
+
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
@@ -44,6 +53,11 @@ const originalUserAgent = navigator.userAgent
beforeEach(() => {
storeState.dictationState = 'listening'
storeState.partialTranscript = ''
+ storeState.dictationMeter = {
+ level: 0,
+ isSpeaking: false,
+ isClipping: false
+ }
storeState.settings = { voice: { dictationMode: 'toggle' } }
setUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')
})
@@ -88,7 +102,7 @@ describe('DictationIndicator', () => {
const { container } = render(
-
-
{label}
- {canStop ? (
- <>
-
-
-
-
-
-
- {stopLabel}
- {showShortcut ? (
-
- ) : null}
-
-
- >
+
+
+
+
+ {label}
+
+
+ {announcedLabel}
+
+ {canStop ? (
+ <>
+
+
+
+
+
+
+ {stopLabel}
+ {showShortcut ? (
+
+ ) : null}
+
+
+ >
+ ) : null}
+
+ {transcript ? (
+
+ {transcript}
+
) : null}
)
diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.test.ts b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts
new file mode 100644
index 00000000000..e7940664e02
--- /dev/null
+++ b/src/renderer/src/components/dictation/dictation-audio-meter.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import {
+ analyzeDictationAudioChunk,
+ createDictationMeterAnalyzerState,
+ measureDictationAudioChunk,
+ toPublicDictationMeterState
+} from './dictation-audio-meter'
+
+describe('dictation audio meter', () => {
+ it('measures RMS and clamps peaks from copied audio samples', () => {
+ expect(measureDictationAudioChunk(new Float32Array([0.5, -0.5]))).toEqual({
+ rms: 0.5,
+ peak: 0.5
+ })
+ expect(measureDictationAudioChunk(new Float32Array([2]))).toEqual({ rms: 2, peak: 1 })
+ })
+
+ it('uses a fast attack and slower release for a stable voice envelope', () => {
+ const initial = createDictationMeterAnalyzerState()
+ const loud = analyzeDictationAudioChunk(new Float32Array(128).fill(0.3), 100, initial)
+ const quiet = analyzeDictationAudioChunk(new Float32Array(128), 200, loud)
+
+ expect(loud.level).toBeGreaterThan(0.5)
+ expect(loud.isSpeaking).toBe(true)
+ expect(quiet.level).toBeGreaterThan(0.3)
+ expect(quiet.level).toBeLessThan(loud.level)
+ })
+
+ it('holds clipping briefly so a single peak does not flicker', () => {
+ const initial = createDictationMeterAnalyzerState()
+ const clipped = analyzeDictationAudioChunk(new Float32Array([1]), 1_000, initial)
+ const held = analyzeDictationAudioChunk(new Float32Array([0]), 1_400, clipped)
+ const released = analyzeDictationAudioChunk(new Float32Array([0]), 1_600, held)
+
+ expect(clipped.isClipping).toBe(true)
+ expect(held.isClipping).toBe(true)
+ expect(released.isClipping).toBe(false)
+ })
+
+ it('publishes only presentation-safe meter fields', () => {
+ const analyzed = analyzeDictationAudioChunk(
+ new Float32Array(128).fill(0.25),
+ 250,
+ createDictationMeterAnalyzerState()
+ )
+
+ expect(toPublicDictationMeterState(analyzed)).toEqual({
+ level: Math.round(analyzed.level * 100) / 100,
+ isSpeaking: analyzed.isSpeaking,
+ isClipping: analyzed.isClipping
+ })
+ })
+})
diff --git a/src/renderer/src/components/dictation/dictation-audio-meter.ts b/src/renderer/src/components/dictation/dictation-audio-meter.ts
new file mode 100644
index 00000000000..c7f9c98117a
--- /dev/null
+++ b/src/renderer/src/components/dictation/dictation-audio-meter.ts
@@ -0,0 +1,106 @@
+export type DictationMeterState = {
+ level: number
+ isSpeaking: boolean
+ isClipping: boolean
+}
+
+export type DictationMeterAnalyzerState = DictationMeterState & {
+ peak: number
+ lastUpdatedAt: number
+ noiseFloor: number
+ smoothedLevel: number
+ clippingUntil: number
+}
+
+export const DEFAULT_DICTATION_METER: DictationMeterState = {
+ level: 0,
+ isSpeaking: false,
+ isClipping: false
+}
+
+const CLIPPING_THRESHOLD = 0.98
+const CLIPPING_HOLD_MS = 500
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value))
+}
+
+export function createDictationMeterAnalyzerState(): DictationMeterAnalyzerState {
+ return {
+ ...DEFAULT_DICTATION_METER,
+ peak: 0,
+ lastUpdatedAt: 0,
+ noiseFloor: 0.008,
+ smoothedLevel: 0,
+ clippingUntil: Number.NEGATIVE_INFINITY
+ }
+}
+
+export function measureDictationAudioChunk(samples: Float32Array): {
+ rms: number
+ peak: number
+} {
+ if (samples.length === 0) {
+ return { rms: 0, peak: 0 }
+ }
+
+ let sumSquares = 0
+ let peak = 0
+ for (const sample of samples) {
+ const absoluteSample = Math.abs(sample)
+ sumSquares += sample * sample
+ peak = Math.max(peak, absoluteSample)
+ }
+ return {
+ rms: Math.sqrt(sumSquares / samples.length),
+ peak: clamp(peak, 0, 1)
+ }
+}
+
+export function analyzeDictationAudioChunk(
+ samples: Float32Array,
+ now: number,
+ previous: DictationMeterAnalyzerState
+): DictationMeterAnalyzerState {
+ const { rms, peak } = measureDictationAudioChunk(samples)
+ const noiseFloor = Math.max(
+ 0.004,
+ previous.noiseFloor * 0.96 + Math.min(rms, previous.noiseFloor * 2) * 0.04
+ )
+ const rawLevel = clamp((rms - noiseFloor) / 0.16, 0, 1)
+ const smoothing = rawLevel > previous.smoothedLevel ? 0.58 : 0.2
+ const smoothedLevel = previous.smoothedLevel + (rawLevel - previous.smoothedLevel) * smoothing
+ const clippingUntil = peak >= CLIPPING_THRESHOLD ? now + CLIPPING_HOLD_MS : previous.clippingUntil
+
+ return {
+ level: smoothedLevel,
+ peak,
+ isSpeaking: smoothedLevel >= 0.1 || peak >= 0.18,
+ isClipping: now <= clippingUntil,
+ lastUpdatedAt: now,
+ noiseFloor,
+ smoothedLevel,
+ clippingUntil
+ }
+}
+
+export function toPublicDictationMeterState(
+ state: DictationMeterAnalyzerState
+): DictationMeterState {
+ return {
+ level: Math.round(state.level * 100) / 100,
+ isSpeaking: state.isSpeaking,
+ isClipping: state.isClipping
+ }
+}
+
+export function dictationMeterStatesEqual(
+ left: DictationMeterState,
+ right: DictationMeterState
+): boolean {
+ return (
+ left.level === right.level &&
+ left.isSpeaking === right.isSpeaking &&
+ left.isClipping === right.isClipping
+ )
+}
diff --git a/src/renderer/src/components/dictation/dictation-meter-store.test.tsx b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx
new file mode 100644
index 00000000000..07a74612734
--- /dev/null
+++ b/src/renderer/src/components/dictation/dictation-meter-store.test.tsx
@@ -0,0 +1,28 @@
+// @vitest-environment happy-dom
+
+import { act, renderHook } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it } from 'vitest'
+import {
+ publishDictationMeter,
+ resetDictationMeter,
+ useDictationMeter
+} from './dictation-meter-store'
+
+beforeEach(resetDictationMeter)
+afterEach(resetDictationMeter)
+
+it('notifies only the scoped meter consumer when presentation state changes', () => {
+ let renders = 0
+ const { result } = renderHook(() => {
+ renders += 1
+ return useDictationMeter()
+ })
+ const speaking = { level: 0.72, isSpeaking: true, isClipping: false }
+
+ act(() => publishDictationMeter(speaking))
+ expect(result.current).toEqual(speaking)
+ expect(renders).toBe(2)
+
+ act(() => publishDictationMeter({ ...speaking }))
+ expect(renders).toBe(2)
+})
diff --git a/src/renderer/src/components/dictation/dictation-meter-store.ts b/src/renderer/src/components/dictation/dictation-meter-store.ts
new file mode 100644
index 00000000000..66e97da6a0c
--- /dev/null
+++ b/src/renderer/src/components/dictation/dictation-meter-store.ts
@@ -0,0 +1,29 @@
+import { e2eConfig } from '@/lib/e2e-config'
+import { useStore } from 'zustand'
+import { createStore } from 'zustand/vanilla'
+import {
+ DEFAULT_DICTATION_METER,
+ dictationMeterStatesEqual,
+ type DictationMeterState
+} from './dictation-audio-meter'
+
+const dictationMeterStore = createStore
(() => DEFAULT_DICTATION_METER)
+
+export function publishDictationMeter(meter: DictationMeterState): void {
+ if (!dictationMeterStatesEqual(dictationMeterStore.getState(), meter)) {
+ dictationMeterStore.setState(meter, true)
+ }
+}
+
+export function resetDictationMeter(): void {
+ dictationMeterStore.setState(DEFAULT_DICTATION_METER, true)
+}
+
+export function useDictationMeter(): DictationMeterState {
+ return useStore(dictationMeterStore, (state) => state)
+}
+
+if (e2eConfig.exposeStore && typeof window !== 'undefined') {
+ const testWindow = window as unknown as Record
+ testWindow.__dictationMeterE2E = { publish: publishDictationMeter }
+}
diff --git a/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts
index 2f9dd125e91..f314b5c82f1 100644
--- a/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts
+++ b/src/renderer/src/hooks/use-audio-capture.capture-loss.test.ts
@@ -8,6 +8,12 @@ class FakeTrack extends EventTarget {
stop = vi.fn()
}
+type FakeAudioProcessor = {
+ connect: ReturnType
+ disconnect: ReturnType
+ onaudioprocess: ((event: AudioProcessingEvent) => void) | null
+}
+
function makeStream(track: FakeTrack): MediaStream {
return {
getTracks: () => [track],
@@ -15,8 +21,8 @@ function makeStream(track: FakeTrack): MediaStream {
} as unknown as MediaStream
}
-function installAudioContext(): void {
- const processor = {
+function installAudioContext(): FakeAudioProcessor {
+ const processor: FakeAudioProcessor = {
connect: vi.fn(),
disconnect: vi.fn(),
onaudioprocess: null
@@ -34,14 +40,16 @@ function installAudioContext(): void {
close = vi.fn(async () => undefined)
}
)
+ return processor
}
describe('useAudioCapture device loss', () => {
let track: FakeTrack
+ let processor: FakeAudioProcessor
beforeEach(() => {
track = new FakeTrack()
- installAudioContext()
+ processor = installAudioContext()
vi.stubGlobal('navigator', {
mediaDevices: {
getUserMedia: vi.fn(async () => makeStream(track)),
@@ -57,6 +65,7 @@ describe('useAudioCapture device loss', () => {
})
afterEach(() => {
+ vi.restoreAllMocks()
vi.unstubAllGlobals()
})
@@ -80,4 +89,85 @@ describe('useAudioCapture device loss', () => {
expect(onCaptureLost).not.toHaveBeenCalled()
})
+
+ it('publishes the copied audio envelope without suppressing speech input', async () => {
+ const publishMeter = vi.fn()
+ const { result } = renderHook(() => useAudioCapture(publishMeter))
+ await result.current.start({ sessionId: 'meter-session' })
+
+ processor.onaudioprocess?.({
+ inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) }
+ } as unknown as AudioProcessingEvent)
+
+ expect(publishMeter).toHaveBeenLastCalledWith(
+ expect.objectContaining({ isSpeaking: true, level: expect.any(Number) })
+ )
+ expect(window.api.speech.feedAudio).toHaveBeenCalledWith(
+ expect.any(Float32Array),
+ 48_000,
+ 'meter-session'
+ )
+ })
+
+ it('caps visual publications and deduplicates steady silence', async () => {
+ let now = 0
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ const publishMeter = vi.fn()
+ const { result } = renderHook(() => useAudioCapture(publishMeter))
+ await result.current.start()
+
+ const process = (sample: number): void => {
+ processor.onaudioprocess?.({
+ inputBuffer: { getChannelData: () => new Float32Array(128).fill(sample) }
+ } as unknown as AudioProcessingEvent)
+ }
+
+ process(0)
+ expect(publishMeter).toHaveBeenCalledTimes(1)
+
+ now = 70
+ process(0.3)
+ now = 90
+ process(0)
+ expect(publishMeter).toHaveBeenCalledTimes(2)
+
+ now = 140
+ process(0)
+ expect(publishMeter).toHaveBeenCalledTimes(3)
+ })
+
+ it('skips hidden updates, resumes promptly, and resets immediately on stop', async () => {
+ let now = 0
+ let hidden = true
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ vi.spyOn(document, 'hidden', 'get').mockImplementation(() => hidden)
+ const publishMeter = vi.fn()
+ const { result } = renderHook(() => useAudioCapture(publishMeter))
+ await result.current.start()
+
+ const process = (): void => {
+ processor.onaudioprocess?.({
+ inputBuffer: { getChannelData: () => new Float32Array(128).fill(0.3) }
+ } as unknown as AudioProcessingEvent)
+ }
+
+ process()
+ expect(publishMeter).toHaveBeenCalledTimes(1)
+ expect(window.api.speech.feedAudio).toHaveBeenCalledTimes(1)
+
+ hidden = false
+ now = 10
+ process()
+ expect(publishMeter).toHaveBeenCalledTimes(2)
+
+ hidden = true
+ now = 20
+ result.current.stop()
+ expect(publishMeter).toHaveBeenLastCalledWith({
+ level: 0,
+ isSpeaking: false,
+ isClipping: false
+ })
+ expect(publishMeter).toHaveBeenCalledTimes(3)
+ })
})
diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts
index 025e29092e2..702640ae1c9 100644
--- a/src/renderer/src/hooks/use-audio-capture.ts
+++ b/src/renderer/src/hooks/use-audio-capture.ts
@@ -1,5 +1,13 @@
import { useRef, useCallback } from 'react'
import { openMicrophoneCaptureStream } from '@/components/dictation/microphone-devices'
+import {
+ DEFAULT_DICTATION_METER,
+ analyzeDictationAudioChunk,
+ createDictationMeterAnalyzerState,
+ dictationMeterStatesEqual,
+ toPublicDictationMeterState,
+ type DictationMeterState
+} from '@/components/dictation/dictation-audio-meter'
type BufferedAudioChunk = {
samples: Float32Array
@@ -28,8 +36,11 @@ type StopAudioCaptureOptions = {
const MAX_BUFFERED_AUDIO_SECONDS = 30
const MAX_BUFFERED_AUDIO_BYTES = 8 * 1024 * 1024
+const METER_PUBLISH_INTERVAL_MS = 1000 / 15
-export function useAudioCapture() {
+type DictationMeterPublisher = (meter: DictationMeterState) => void
+
+export function useAudioCapture(publishMeter?: DictationMeterPublisher) {
const streamRef = useRef(null)
const contextRef = useRef(null)
const processorRef = useRef(null)
@@ -44,6 +55,9 @@ export function useAudioCapture() {
const capturedChunkCountRef = useRef(0)
const sessionIdRef = useRef('desktop')
const trackLostCleanupRef = useRef<(() => void) | null>(null)
+ const meterAnalyzerRef = useRef(createDictationMeterAnalyzerState())
+ const publishedMeterRef = useRef(DEFAULT_DICTATION_METER)
+ const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY)
const cleanupCaptureResources = useCallback(() => {
trackLostCleanupRef.current?.()
@@ -70,6 +84,13 @@ export function useAudioCapture() {
bufferedAudioSecondsRef.current = 0
}, [])
+ const resetMeter = useCallback(() => {
+ meterAnalyzerRef.current = createDictationMeterAnalyzerState()
+ publishedMeterRef.current = DEFAULT_DICTATION_METER
+ lastMeterPublishedAtRef.current = Number.NEGATIVE_INFINITY
+ publishMeter?.(DEFAULT_DICTATION_METER)
+ }, [publishMeter])
+
const removeOldestBufferedAudioChunk = useCallback(() => {
const chunk = bufferedAudioRef.current.shift()
if (!chunk) {
@@ -112,6 +133,7 @@ export function useAudioCapture() {
bufferAudioRef.current = options.bufferAudio ?? false
resetBufferedAudio()
capturedChunkCountRef.current = 0
+ resetMeter()
const { stream, fellBackToDefaultMicrophone } = await openMicrophoneCaptureStream({
preferredDeviceId: options.microphoneDeviceId,
@@ -175,6 +197,23 @@ export function useAudioCapture() {
return
}
const samples = new Float32Array(e.inputBuffer.getChannelData(0))
+ const now = performance.now()
+ meterAnalyzerRef.current = analyzeDictationAudioChunk(
+ samples,
+ now,
+ meterAnalyzerRef.current
+ )
+ if (
+ !document.hidden &&
+ now - lastMeterPublishedAtRef.current >= METER_PUBLISH_INTERVAL_MS
+ ) {
+ lastMeterPublishedAtRef.current = now
+ const meter = toPublicDictationMeterState(meterAnalyzerRef.current)
+ if (!dictationMeterStatesEqual(publishedMeterRef.current, meter)) {
+ publishedMeterRef.current = meter
+ publishMeter?.(meter)
+ }
+ }
capturedChunkCountRef.current += 1
if (bufferAudioRef.current) {
appendBufferedAudioChunk({
@@ -242,7 +281,13 @@ export function useAudioCapture() {
throw err
}
},
- [appendBufferedAudioChunk, cleanupCaptureResources, resetBufferedAudio]
+ [
+ appendBufferedAudioChunk,
+ cleanupCaptureResources,
+ publishMeter,
+ resetBufferedAudio,
+ resetMeter
+ ]
)
const flushBufferedAudio = useCallback(async () => {
@@ -285,8 +330,9 @@ export function useAudioCapture() {
resetBufferedAudio()
}
cleanupCaptureResources()
+ resetMeter()
},
- [cleanupCaptureResources, resetBufferedAudio]
+ [cleanupCaptureResources, resetBufferedAudio, resetMeter]
)
return {
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 59ba7e3237c..05c84a4b55e 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -14610,7 +14610,12 @@
"micDisconnected": "Microphone disconnected. Dictation stopped."
},
"DictationIndicator": {
- "335e1bc6cb": "Stop dictation"
+ "335e1bc6cb": "Stop dictation",
+ "tooLoud": "Too loud",
+ "speaking": "Speaking",
+ "starting": "Starting mic…",
+ "processing": "Processing…",
+ "listening": "Listening"
}
},
"dashboard": {
diff --git a/tests/e2e/dictation-indicator.spec.ts b/tests/e2e/dictation-indicator.spec.ts
new file mode 100644
index 00000000000..b7028c347e8
--- /dev/null
+++ b/tests/e2e/dictation-indicator.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from './helpers/orca-app'
+import type { Page } from '@stablyai/playwright-test'
+
+type MeterFixture = {
+ level: number
+ isSpeaking: boolean
+ isClipping: boolean
+}
+
+async function setDictationVisualState(
+ page: Page,
+ state: 'listening' | 'stopping',
+ meter: MeterFixture,
+ partialTranscript = ''
+): Promise {
+ await page.evaluate(
+ ({ dictationState, transcript }) => {
+ const store = window.__store
+ if (!store) {
+ throw new Error('Expected the E2E store to be exposed')
+ }
+ store.setState({
+ dictationState,
+ partialTranscript: transcript
+ })
+ },
+ { dictationState: state, transcript: partialTranscript }
+ )
+ await page.waitForFunction(() => Boolean(window.__dictationMeterE2E))
+ await page.evaluate((dictationMeter) => {
+ window.__dictationMeterE2E?.publish(dictationMeter)
+ }, meter)
+}
+
+async function pauseForRecordedProof(page: Page): Promise {
+ if (process.env.ORCA_E2E_RECORD_VIDEO === '1') {
+ await page.waitForTimeout(700)
+ }
+}
+
+test('dictation grapes react across the visible recording lifecycle', async ({ orcaPage }) => {
+ const quiet = { level: 0, isSpeaking: false, isClipping: false }
+ await setDictationVisualState(orcaPage, 'listening', quiet)
+
+ const indicator = orcaPage.getByTestId('dictation-indicator')
+ const status = indicator.getByRole('status')
+ await expect(indicator).toBeVisible()
+ await expect(status).toHaveText('Listening')
+ await expect(indicator.getByTestId('dictation-grapes').locator('span')).toHaveCount(9)
+ await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toBeVisible()
+ await orcaPage.emulateMedia({ reducedMotion: 'reduce' })
+ await expect(indicator.getByTestId('dictation-grapes').locator('span').first()).toHaveCSS(
+ 'transition-property',
+ 'none'
+ )
+ await orcaPage.emulateMedia({ reducedMotion: 'no-preference' })
+ await pauseForRecordedProof(orcaPage)
+
+ const speaking = {
+ level: 0.76,
+ isSpeaking: true,
+ isClipping: false
+ }
+ await setDictationVisualState(orcaPage, 'listening', speaking)
+ await expect(indicator.getByText('Speaking')).toBeVisible()
+ await expect(status).toHaveText('Listening')
+ await pauseForRecordedProof(orcaPage)
+
+ const clipping = { ...speaking, level: 1, isClipping: true }
+ await setDictationVisualState(orcaPage, 'listening', clipping)
+ await expect(status).toHaveText('Too loud')
+ await expect(indicator).toHaveClass(/text-destructive/)
+ await pauseForRecordedProof(orcaPage)
+
+ await setDictationVisualState(
+ orcaPage,
+ 'listening',
+ speaking,
+ 'The visualizer follows every word without covering the workspace.'
+ )
+ await expect(
+ orcaPage.getByText('The visualizer follows every word without covering the workspace.')
+ ).toBeVisible()
+ await expect(status).toHaveText('Listening')
+ await pauseForRecordedProof(orcaPage)
+
+ await setDictationVisualState(orcaPage, 'stopping', quiet)
+ await expect(status).toHaveText('Processing…')
+ await expect(indicator.getByRole('button', { name: 'Stop dictation' })).toHaveCount(0)
+ await pauseForRecordedProof(orcaPage)
+})
diff --git a/tests/e2e/helpers/runtime-types.ts b/tests/e2e/helpers/runtime-types.ts
index 99ce5b76fbe..215e1ce5bcc 100644
--- a/tests/e2e/helpers/runtime-types.ts
+++ b/tests/e2e/helpers/runtime-types.ts
@@ -11,6 +11,7 @@ import type { Repo } from '../../../src/shared/repo-types'
import type { WorkspaceVisibleTabType } from '../../../src/shared/tab-types'
import type { TerminalTab } from '../../../src/shared/terminal-tab-types'
import type { Worktree } from '../../../src/shared/worktree/types'
+import type { DictationMeterState } from '../../../src/renderer/src/components/dictation/dictation-audio-meter'
// Why: window.__store is the Zustand bound store itself, so specs get the whole StoreApi.
export type AppStore = {
@@ -63,6 +64,7 @@ declare global {
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions -- declaration merging requires interface
interface Window {
__store?: AppStore
+ __dictationMeterE2E?: { publish(meter: DictationMeterState): void }
__paneManagers?: Map
}
}
From 03fcfdfb92f63ae53f6db5b7b67169f75148f32d Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 21:47:46 -0700
Subject: [PATCH 19/27] feat(orcad): boot the Orca runtime on plain Node
(#15968)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* refactor(host): resolve the app root through the port in fork-reachable modules
`parcel-watcher-entry-path.ts` and `session-scanner-service-entry-path.ts` read the
app root via `require('electron').app` inside a try/catch that already returns null
when Electron is absent. They were therefore correct under plain Node at runtime and
only failed the *static* text check — which is real, not pedantic: the comment in
`ports/port-scan-command-client.ts:19` records that the plain-node-entry-guard fails
on that literal text, try/catch or not.
`hasAppEnvironment() ? getAppEnvironment() : null` gives the identical "no app root
here" answer without the text. That restores `hasAppEnvironment`, which an earlier
commit in this stack deleted as unused — it now has the caller it was waiting for.
Ratchet baseline 27 → 25.
Verified: 74 files / 458 tests; `pnpm typecheck` clean; `oxlint` clean.
* feat(orcad): boot the Orca runtime on plain Node
Closes the last two Electron couplings and makes `orcad` a working artifact:
a 4.43 MB Node bundle that boots, pairs, registers a repo, creates a real git
worktree and round-trips a PTY — with zero `require("electron")`.
Ratchet 2 -> 0, so `config/runtime-electron-baseline.txt` is now empty and its
test asserts exactly that: any reachable electron import is a regression.
- speech: inject the service factories, so importing ModelManager for its type
no longer drags Electron's streaming net.request into the graph
- filesystem-watcher: add a WorktreeWatcherRemoval port. Every entry in those
maps arrives through an ipcMain handler carrying a renderer sender, so a host
with no renderer has nothing to close, restore or forget — the inert default
is what the desktop code does against empty maps, not a stub hiding work
- user-data-path / profile-storage-paths: resolve userData through
AppEnvironment. These surfaced only once orcad pulled the store in
Both host ports now anchor to a realm-global symbol. `vi.resetModules()` gives
the re-imported graph a fresh module copy, so a binding installed before the
reset silently read back as uninstalled.
The acceptance smoke drives both hosts through one code path (`--target
orcad|electron`) and seeds its own git repo, so it is hermetic and asserts the
same contract of each. Wired into PR CI.
* test(smoke): remove the seeded workspace container, not just the worktree
* test(smoke): surface the server's stderr when it dies before ready
* fix(smoke): build node-pty for Node before booting orcad in CI
* fix(smoke): drive the CLI built from this checkout, not one on PATH
* docs(ratchet): say the baseline must stay empty, not merely shrink
* build(orcad): externalize only the native modules actually in the graph
---
.github/workflows/pr.yml | 6 +
config/runtime-electron-baseline.txt | 7 +-
config/scripts/build-orcad.mjs | 18 +-
.../check-runtime-electron-ratchet.mjs | 5 +-
.../check-runtime-electron-ratchet.test.mjs | 9 +-
.../scripts/runtime-serve-terminal-smoke.mjs | 244 ++++++++++++++----
package.json | 2 +
src/main/automations/service.test.ts | 3 +
src/main/host/electron-speech-services.ts | 9 +
src/main/index.ts | 9 +-
src/main/ipc/filesystem-watcher.ts | 14 +
.../ipc/orca-profile-auth-handlers.test.ts | 5 +-
src/main/ipc/orca-profiles.test.ts | 7 +-
src/main/ipc/worktree-watcher-removal.ts | 42 +++
.../orca-profiles/profile-index-store.test.ts | 12 +-
.../orca-profiles/profile-storage-paths.ts | 6 +-
src/main/orcad/main.ts | 8 +
src/main/orcad/orcad-entry.ts | 168 +++++++++++-
.../persistence-async-write-syscalls.test.ts | 4 +
...tence-duplicate-repo-id-host-scope.test.ts | 4 +
...tence-protected-secret-fail-closed.test.ts | 4 +
...stence-protected-secret-write-race.test.ts | 4 +
.../persistence-proxy-secret-recovery.test.ts | 4 +
src/main/persistence-single-serialize.test.ts | 4 +
src/main/persistence-test-harness.ts | 5 +
src/main/persistence-ui-state.test.ts | 4 +
...tence-workspace-session-scrollback.test.ts | 7 +
...rsistence-worktree-name-retirement.test.ts | 4 +
.../loading-store/user-data-path.ts | 10 +-
.../quit-path-durable-write-blocking.test.ts | 3 +
.../mobile-pairing-userdata-path.test.ts | 14 +-
src/main/runtime/orca-runtime.test.ts | 20 +-
src/main/runtime/orca-runtime.ts | 21 +-
src/main/speech/speech-runtime-service.ts | 40 ++-
34 files changed, 603 insertions(+), 123 deletions(-)
create mode 100644 src/main/host/electron-speech-services.ts
create mode 100644 src/main/ipc/worktree-watcher-removal.ts
create mode 100644 src/main/orcad/main.ts
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 636aeed1b92..01724348ba6 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -119,6 +119,12 @@ jobs:
- name: Enforce runtime Electron-import ratchet
run: pnpm run check:runtime-electron-ratchet
+ # Why both: the ratchet proves nothing reachable from the runtime imports electron,
+ # which is a property of the import graph. This proves the Node artifact it enables
+ # actually boots, pairs, creates a worktree and round-trips a real PTY.
+ - name: Boot orcad and round-trip a terminal
+ run: pnpm run smoke:orcad-terminal
+
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
diff --git a/config/runtime-electron-baseline.txt b/config/runtime-electron-baseline.txt
index d3af9f74c23..f392f70ecc9 100644
--- a/config/runtime-electron-baseline.txt
+++ b/config/runtime-electron-baseline.txt
@@ -1,7 +1,6 @@
# Modules reachable from the Orca runtime that import `electron`.
# Generated by config/scripts/check-runtime-electron-ratchet.mjs.
-# This list may only SHRINK. Adding an entry means the runtime got less
-# portable; migrate the module behind a host port instead (src/main/host/).
+# This list is EMPTY and must stay that way: the runtime boots on plain Node
+# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;
+# migrate the module behind a host port instead (src/main/host/).
-src/main/ipc/filesystem-watcher.ts
-src/main/speech/model-manager.ts
diff --git a/config/scripts/build-orcad.mjs b/config/scripts/build-orcad.mjs
index c679e2351ba..989efd2d8e7 100644
--- a/config/scripts/build-orcad.mjs
+++ b/config/scripts/build-orcad.mjs
@@ -14,21 +14,19 @@ import process from 'node:process'
const ROOT = join(import.meta.dirname, '..', '..')
const OUT_DIR = join(ROOT, 'out', 'orcad')
-const ENTRY = join(ROOT, 'src', 'main', 'orcad', 'orcad-entry.ts')
+const ENTRY = join(ROOT, 'src/main/orcad/main.ts')
// Native addons must exist on the host; they cannot be bundled.
// `electron` is external so a residual import fails loudly at require() time rather
// than silently bundling the npm package's installer shim, which is what happened the
// first time and made the bundle look clean while it was not.
-const EXTERNAL = [
- 'electron',
- 'node-pty',
- '@parcel/watcher',
- 'better-sqlite3',
- 'keytar',
- 'fsevents',
- 'cpu-features'
-]
+// Why only these: measured, not guessed. `node-pty` is a hard `require.resolve` — orcad
+// exits at startup without it. `@parcel/watcher` is a guarded dynamic import, so the
+// server boots without it but every watch install fails. `fsevents` is macOS-only and
+// optional upstream. better-sqlite3 / keytar / cpu-features were externalized here
+// defensively and appear nowhere in the graph; listing them implied a shipping burden
+// that does not exist.
+const EXTERNAL = ['electron', 'node-pty', '@parcel/watcher', 'fsevents']
/** Why: the UMD build's relative dynamic requires do not bundle. Same fix build-relay.mjs uses. */
const jsoncParserEsm = {
diff --git a/config/scripts/check-runtime-electron-ratchet.mjs b/config/scripts/check-runtime-electron-ratchet.mjs
index 5fe3fb18c1a..c50d5df8f46 100644
--- a/config/scripts/check-runtime-electron-ratchet.mjs
+++ b/config/scripts/check-runtime-electron-ratchet.mjs
@@ -112,8 +112,9 @@ function renderBaseline(files) {
return [
'# Modules reachable from the Orca runtime that import `electron`.',
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
- '# This list may only SHRINK. Adding an entry means the runtime got less',
- '# portable; migrate the module behind a host port instead (src/main/host/).',
+ '# This list is EMPTY and must stay that way: the runtime boots on plain Node',
+ '# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
+ '# migrate the module behind a host port instead (src/main/host/).',
'',
...files
].join('\n')
diff --git a/config/scripts/check-runtime-electron-ratchet.test.mjs b/config/scripts/check-runtime-electron-ratchet.test.mjs
index ba75875c20f..e993ca32fc5 100644
--- a/config/scripts/check-runtime-electron-ratchet.test.mjs
+++ b/config/scripts/check-runtime-electron-ratchet.test.mjs
@@ -45,9 +45,12 @@ describe('the checked-in baseline', () => {
expect(diffAgainstBaseline(current, baseline)).toEqual({ added: [], removed: [] })
}, 120_000)
- it('only lists modules under src/, so a node_modules path cannot pad the count', () => {
+ // Why an exact-empty assertion now: the reachable set reached zero, so "may only
+ // shrink" has no room left and any entry at all is a regression. This is strictly
+ // stronger than the old under-src/ check, which only stopped a node_modules path from
+ // padding a non-empty count.
+ it('stays empty, so nothing reachable from the runtime imports electron', () => {
const baseline = readBaseline(readFileSync('config/runtime-electron-baseline.txt', 'utf8'))
- expect(baseline.length).toBeGreaterThan(0)
- expect(baseline.filter((entry) => !entry.startsWith('src/'))).toEqual([])
+ expect(baseline).toEqual([])
})
})
diff --git a/config/scripts/runtime-serve-terminal-smoke.mjs b/config/scripts/runtime-serve-terminal-smoke.mjs
index a9e0dca7ced..21b6cea6b0b 100644
--- a/config/scripts/runtime-serve-terminal-smoke.mjs
+++ b/config/scripts/runtime-serve-terminal-smoke.mjs
@@ -20,14 +20,15 @@
* - the server exits when asked.
*/
import { spawn, spawnSync } from 'node:child_process'
-import { mkdtempSync, rmSync } from 'node:fs'
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
+import { dirname, join, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import process from 'node:process'
const projectDir = resolve(import.meta.dirname, '../..')
const serveEntry = join(projectDir, 'out', 'main', 'index.js')
+const ORCAD_ENTRY = join(projectDir, 'out', 'orcad', 'orcad.js')
const READY_TIMEOUT_MS = 120_000
const OUTPUT_TIMEOUT_MS = 30_000
const SHUTDOWN_TIMEOUT_MS = 15_000
@@ -43,14 +44,30 @@ function fail(message) {
process.exitCode = 1
}
+/**
+ * Prefer the CLI built from this checkout over whatever `orca` is on PATH: it is the
+ * version under test, and a CI runner has no installed Orca app to fall back on.
+ */
+function resolveCli() {
+ const built = join(projectDir, 'out', 'cli', 'index.js')
+ return existsSync(built)
+ ? { command: process.execPath, prefix: [built] }
+ : { command: 'orca', prefix: [] }
+}
+
/** The `orca` CLI, driven with an explicit pairing code so it targets this server only. */
function orca(pairingCode, args) {
- const result = spawnSync('orca', [...args, '--pairing-code', pairingCode, '--json'], {
- encoding: 'utf8',
- // Why not shell:true — argument encoding is handled by spawnSync; a shell would
- // re-split the pairing code, which is base64url and can contain '='.
- shell: false
- })
+ const cli = resolveCli()
+ const result = spawnSync(
+ cli.command,
+ [...cli.prefix, ...args, '--pairing-code', pairingCode, '--json'],
+ {
+ encoding: 'utf8',
+ // Why not shell:true — argument encoding is handled by spawnSync; a shell would
+ // re-split the pairing code, which is base64url and can contain '='.
+ shell: false
+ }
+ )
if (result.error) {
throw new Error(`orca ${args[0]} failed to spawn: ${result.error.message}`)
}
@@ -70,10 +87,17 @@ function orca(pairingCode, args) {
function waitForReady(child) {
return new Promise((resolvePromise, rejectPromise) => {
let buffered = ''
+ let serverErr = ''
const timer = setTimeout(
() => rejectPromise(new Error(`no ready payload within ${READY_TIMEOUT_MS}ms`)),
READY_TIMEOUT_MS
)
+ // Why read stderr at all: an unread pipe can fill and block the child, and without it
+ // a boot failure surfaces only as "exited with 1", which says nothing actionable.
+ child.stderr.setEncoding('utf8')
+ child.stderr.on('data', (chunk) => {
+ serverErr += chunk
+ })
child.stdout.setEncoding('utf8')
child.stdout.on('data', (chunk) => {
buffered += chunk
@@ -95,7 +119,13 @@ function waitForReady(child) {
})
child.on('exit', (code) => {
clearTimeout(timer)
- rejectPromise(new Error(`server exited with ${code} before signalling ready`))
+ rejectPromise(
+ new Error(
+ `server exited with ${code} before signalling ready${
+ serverErr.trim() ? `:\n${serverErr.trim()}` : ' (no stderr)'
+ }`
+ )
+ )
})
})
}
@@ -125,50 +155,127 @@ async function waitForNonce(pairingCode, terminalHandle, nonce) {
return false
}
+/**
+ * The two hosts this acceptance drives.
+ *
+ * Everything after boot — pairing, worktree list, terminal create, the nonce round trip —
+ * is identical for both. That is the point: the Node artifact has to satisfy the same
+ * contract as the Electron server, proven by the same code rather than a parallel test
+ * that could drift into asserting less.
+ */
+function resolveLaunch(userDataDir) {
+ // Why a flag and not just an env var: package scripts have to set this on Windows too,
+ // and `FOO=bar cmd` is not portable there.
+ const flagIndex = process.argv.indexOf('--target')
+ const target =
+ flagIndex !== -1 ? process.argv[flagIndex + 1] : (process.env.ORCA_SMOKE_TARGET ?? 'electron')
+ if (target === 'orcad') {
+ return {
+ label: `orcad (${ORCAD_ENTRY})`,
+ command: process.execPath,
+ args: [ORCAD_ENTRY, '--port', String(PORT), '--json'],
+ env: { ORCA_USER_DATA: userDataDir }
+ }
+ }
+ if (target !== 'electron') {
+ throw new Error(
+ `--target (or ORCA_SMOKE_TARGET) must be 'electron' or 'orcad', got '${target}'`
+ )
+ }
+ const serveArgs = [
+ serveEntry,
+ '--serve',
+ '--serve-port',
+ String(PORT),
+ '--serve-json',
+ `--user-data-dir=${userDataDir}`
+ ]
+ const override = process.env.ORCA_SMOKE_ELECTRON
+ return {
+ label: `electron (${serveEntry})`,
+ command: override ?? 'npx',
+ args: override ? serveArgs : ['electron', ...serveArgs],
+ env: {}
+ }
+}
+
+/** A throwaway git repo with one commit, so `repo add` has something real to register. */
+function seedGitRepo() {
+ const dir = mkdtempSync(join(tmpdir(), 'orca-smoke-repo-'))
+ writeFileSync(join(dir, 'README.md'), '# orca smoke\n')
+ const git = (...args) => {
+ const result = spawnSync('git', args, { cwd: dir, encoding: 'utf8' })
+ if (result.status !== 0) {
+ throw new Error(`git ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
+ }
+ }
+ git('init', '-b', 'main')
+ git('config', 'user.email', 'smoke@orca.test')
+ git('config', 'user.name', 'Orca Smoke')
+ git('add', '-A')
+ git('commit', '-m', 'seed')
+ return dir
+}
+
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-serve-smoke-'))
- log(`booting ${serveEntry} on port ${PORT} with userData ${userDataDir}`)
+ const launch = resolveLaunch(userDataDir)
+ log(`booting ${launch.label} on port ${PORT} with userData ${userDataDir}`)
- const child = spawn(
- process.env.ORCA_SMOKE_ELECTRON ?? 'npx',
- process.env.ORCA_SMOKE_ELECTRON
- ? [
- serveEntry,
- '--serve',
- '--serve-port',
- String(PORT),
- '--serve-json',
- `--user-data-dir=${userDataDir}`
- ]
- : [
- 'electron',
- serveEntry,
- '--serve',
- '--serve-port',
- String(PORT),
- '--serve-json',
- `--user-data-dir=${userDataDir}`
- ],
- { stdio: ['ignore', 'pipe', 'pipe'] }
- )
+ // Why tracked out here: the worktree lands in the real workspaces root, not the temp
+ // profile, so the finally block has to remove it explicitly or every run leaks one.
+ let seeded = null
+ let pairing = null
+
+ const child = spawn(launch.command, launch.args, {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env: { ...process.env, ...launch.env }
+ })
try {
const ready = await waitForReady(child)
log(`ready: ${ready.advertisedEndpoint}`)
const pairingCode = pairingCodeFrom(ready)
+ pairing = pairingCode
- const worktrees = orca(pairingCode, ['worktree', 'list'])?.worktrees ?? []
- if (worktrees.length === 0) {
- throw new Error('paired client saw no worktrees; cannot create a terminal')
+ // Why seed instead of using whatever the profile already holds: a hermetic repo makes
+ // this runnable on a clean CI box, keeps the assertion deterministic, and exercises
+ // repo.add + worktree.create rather than assuming someone else registered a worktree.
+ const repoPath = seedGitRepo()
+ seeded = { repoPath }
+ log(`seeded repo at ${repoPath}`)
+ const repo = orca(pairingCode, ['repo', 'add', '--path', repoPath])?.repo
+ if (!repo?.id) {
+ throw new Error('repo.add returned no repo id')
}
- log(`paired client sees ${worktrees.length} worktree(s)`)
- const terminal = orca(pairingCode, [
- 'terminal',
+ const worktreeName = `smoke-${randomBytes(4).toString('hex')}`
+ const created = orca(pairingCode, [
+ 'worktree',
'create',
- '--worktree',
- worktrees[0].id
- ])?.terminal
+ '--repo',
+ `id:${repo.id}`,
+ '--name',
+ worktreeName,
+ '--setup',
+ 'skip'
+ ])?.worktree
+ if (!created?.id) {
+ throw new Error('worktree.create returned no worktree id')
+ }
+ seeded.worktreeId = created.id
+ log(`created worktree ${created.id}`)
+
+ // Why `show` and not membership in `list`: list is capped, and the Electron target
+ // reads a shared dev profile that can already hold more worktrees than the cap. The
+ // point is that the server persisted and can resolve THIS worktree.
+ const shown = orca(pairingCode, ['worktree', 'show', '--worktree', created.id])?.worktree
+ if (shown?.id !== created.id) {
+ throw new Error('worktree.create succeeded but worktree.show cannot resolve it')
+ }
+ log(`server resolves ${shown.id}`)
+
+ const terminal = orca(pairingCode, ['terminal', 'create', '--worktree', created.id])?.terminal
if (!terminal?.handle) {
throw new Error('terminal.create returned no handle')
}
@@ -196,16 +303,55 @@ async function main() {
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
} finally {
- child.kill('SIGTERM')
- const exited = await Promise.race([
- new Promise((r) => child.on('exit', () => r(true))),
- new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
- ])
- if (!exited) {
- child.kill('SIGKILL')
- fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
+ // Why before SIGTERM: worktree removal is a server operation, so it needs the server.
+ if (seeded?.worktreeId && pairing) {
+ const cleanupCli = resolveCli()
+ const removed = spawnSync(
+ cleanupCli.command,
+ [
+ ...cleanupCli.prefix,
+ 'worktree',
+ 'rm',
+ '--worktree',
+ seeded.worktreeId,
+ '--pairing-code',
+ pairing,
+ '--force',
+ '--json'
+ ],
+ { encoding: 'utf8' }
+ )
+ // Why the parent too: `worktree rm` removes the worktree directory, leaving the
+ // empty `//` container behind. Every run would leak one.
+ const worktreePath = seeded.worktreeId.split('::')[1]
+ if (removed.status === 0 && worktreePath) {
+ rmSync(dirname(worktreePath), { recursive: true, force: true })
+ }
+ if (removed.status !== 0) {
+ log(
+ `WARN: could not remove seeded worktree ${seeded.worktreeId}: ` +
+ `${(removed.stderr?.trim() || removed.stdout?.trim() || '').replace(/\s+/g, ' ')} (exit ${removed.status})`
+ )
+ }
+ }
+ // Why the exitCode guard: a server that died during boot has already exited, and
+ // waiting for a second 'exit' that will never fire reported a bogus shutdown failure
+ // stacked on top of the real error.
+ if (child.exitCode === null && child.signalCode === null) {
+ child.kill('SIGTERM')
+ const exited = await Promise.race([
+ new Promise((r) => child.on('exit', () => r(true))),
+ new Promise((r) => setTimeout(() => r(false), SHUTDOWN_TIMEOUT_MS))
+ ])
+ if (!exited) {
+ child.kill('SIGKILL')
+ fail(`server did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`)
+ }
}
rmSync(userDataDir, { recursive: true, force: true })
+ if (seeded?.repoPath) {
+ rmSync(seeded.repoPath, { recursive: true, force: true })
+ }
}
if (!process.exitCode) {
diff --git a/package.json b/package.json
index 5c20829e8b0..a026bdb7824 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,8 @@
"check:reliability-gates": "node config/scripts/check-reliability-gates.mjs",
"check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs",
"check:runtime-electron-ratchet": "node config/scripts/check-runtime-electron-ratchet.mjs",
+ "build:orcad": "node config/scripts/build-orcad.mjs",
+ "smoke:orcad-terminal": "node config/scripts/ensure-native-runtime.mjs --runtime=node && pnpm run build:cli && pnpm run build:orcad && node config/scripts/runtime-serve-terminal-smoke.mjs --target orcad",
"smoke:serve-terminal": "node config/scripts/runtime-serve-terminal-smoke.mjs",
"check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs",
"generate:bundled-skill-guides": "node config/scripts/generate-bundled-skill-guides.mjs --write",
diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts
index 27f96400258..0b546d5a46e 100644
--- a/src/main/automations/service.test.ts
+++ b/src/main/automations/service.test.ts
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import type { Repo } from '../../shared/repo-types'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { AutomationService } from './service'
+import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -21,6 +22,8 @@ vi.mock('electron', () => ({
async function createStore() {
vi.resetModules()
+ // Why: userData resolves through AppEnvironment; point it at this file's temp dir.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('../persistence')
initDataPath()
return new Store()
diff --git a/src/main/host/electron-speech-services.ts b/src/main/host/electron-speech-services.ts
new file mode 100644
index 00000000000..52ccf9099c1
--- /dev/null
+++ b/src/main/host/electron-speech-services.ts
@@ -0,0 +1,9 @@
+import { ModelManager } from '../speech/model-manager'
+import { SttService } from '../speech/stt-service'
+import type { SpeechServiceFactories } from '../speech/speech-runtime-service'
+
+/** The desktop speech factories. Importing this file is what pulls Electron's net in. */
+export const electronSpeechServiceFactories: SpeechServiceFactories = {
+ createModelManager: (customModelsDir) => new ModelManager(customModelsDir),
+ createSttService: (models) => new SttService(models)
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index fc141e98988..42abcd75590 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -29,6 +29,9 @@ import { electronRuntimeBrowserCommandsFactory } from './host/electron-browser-c
import { setRuntimeBrowserCommandsFactory } from './runtime/runtime-browser-commands-factory'
import { electronHttpClient } from './host/electron-http-client'
import { setMainHttpClient } from './network/http-client'
+import { electronSpeechServiceFactories } from './host/electron-speech-services'
+import { setSpeechServiceFactories } from './speech/speech-runtime-service'
+import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal'
import { setSecretStore } from '../shared/secret-store'
import { ElectronSecretStore } from './host/electron-secret-store'
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
@@ -67,7 +70,7 @@ import {
isCodexPaneHomeRouteProvenAwayFromSharedHome,
reconcileCodexPaneAccountsWithLivePtys
} from './codex/codex-pane-account-registry'
-import { closeAllWatchers } from './ipc/filesystem-watcher'
+import { closeAllWatchers, desktopWorktreeWatcherRemoval } from './ipc/filesystem-watcher'
import { disposeWorktreeBaseDirectoryWatchers } from './ipc/worktree-base-directory-watcher'
import { stopFolderRepoGitUpgradeWatch } from './ipc/folder-repo-git-upgrade'
import { registerCoreHandlers } from './ipc/register-core-handlers'
@@ -902,6 +905,10 @@ if (hasSingleInstanceLock) {
// falls back to the platform default, which is a real behavioural difference (proxy
// read from the environment, Node's user agent) rather than a transparent swap.
setMainHttpClient(electronHttpClient)
+ // Why here: constructing the speech services is what pulls Electron's streaming net
+ // request in. A host without them rejects speech calls rather than pretending.
+ setSpeechServiceFactories(electronSpeechServiceFactories)
+ setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
const shouldCoupleToDevParent = is.dev && !isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts
index 0a337967386..71830efc7c2 100644
--- a/src/main/ipc/filesystem-watcher.ts
+++ b/src/main/ipc/filesystem-watcher.ts
@@ -39,6 +39,7 @@ import {
} from './watcher-removal-drain'
// Why: suppress high-churn dirs at the watcher level (separate from the File Explorer display filter, which only hides rows).
import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore'
+import type { WorktreeWatcherRemoval } from './worktree-watcher-removal'
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot/WatcherSubscription live in filesystem-watcher-wsl.ts so native and WSL watchers share one shape.
@@ -1959,3 +1960,16 @@ export async function closeAllWatchers(): Promise {
}
remoteWatchers.clear()
}
+
+/** The desktop binding for {@link WorktreeWatcherRemoval}. Installed during startup. */
+export const desktopWorktreeWatcherRemoval: WorktreeWatcherRemoval = {
+ closeLocal: (worktreePath, deadline) =>
+ deadline
+ ? closeLocalWatcherForWorktreePath(worktreePath, deadline)
+ : closeLocalWatcherForWorktreePath(worktreePath),
+ restoreLocal: restoreLocalWatcherAfterFailedRemoval,
+ forgetLocal: forgetLocalWatcherRemovalSnapshot,
+ closeRemote: closeRemoteWatcherForWorktreePath,
+ restoreRemote: restoreRemoteWatcherAfterFailedRemoval,
+ forgetRemote: forgetRemoteWatcherRemovalSnapshot
+}
diff --git a/src/main/ipc/orca-profile-auth-handlers.test.ts b/src/main/ipc/orca-profile-auth-handlers.test.ts
index 73726dd2b52..e16d949dc1a 100644
--- a/src/main/ipc/orca-profile-auth-handlers.test.ts
+++ b/src/main/ipc/orca-profile-auth-handlers.test.ts
@@ -21,7 +21,6 @@ const {
vi.mock('electron', () => ({
app: {
exit: vi.fn(),
- getPath: () => '/tmp/orca-user-data',
relaunch: vi.fn()
},
ipcMain: {
@@ -56,9 +55,13 @@ vi.mock('../orca-profiles/profile-cloud-service', () => ({
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
+import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
describe('registerOrcaProfileHandlers auth channels', () => {
beforeEach(() => {
+ // Why the port and per-test: userData resolves through AppEnvironment now, and
+ // the global setup's beforeEach reinstates its own fake before this runs.
+ installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' })
handlers.clear()
createCloudLinkedOrcaProfileMock.mockReset()
connectCurrentOrcaProfileMock.mockReset()
diff --git a/src/main/ipc/orca-profiles.test.ts b/src/main/ipc/orca-profiles.test.ts
index fe69ce2e138..215b559327d 100644
--- a/src/main/ipc/orca-profiles.test.ts
+++ b/src/main/ipc/orca-profiles.test.ts
@@ -30,8 +30,7 @@ vi.mock('electron', () => ({
app: {
exit: appExitMock,
quit: appQuitMock,
- relaunch: appRelaunchMock,
- getPath: () => '/tmp/orca-user-data'
+ relaunch: appRelaunchMock
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
@@ -68,9 +67,13 @@ vi.mock('../orca-profiles/profile-project-transfer', () => ({
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
+import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
+ // Why the port and per-test: userData resolves through AppEnvironment now, and
+ // the global setup's beforeEach reinstates its own fake before this runs.
+ installFakeAppEnvironment({ getPath: () => '/tmp/orca-user-data' })
vi.useFakeTimers()
handlers.clear()
appExitMock.mockReset()
diff --git a/src/main/ipc/worktree-watcher-removal.ts b/src/main/ipc/worktree-watcher-removal.ts
new file mode 100644
index 00000000000..b5de15592cd
--- /dev/null
+++ b/src/main/ipc/worktree-watcher-removal.ts
@@ -0,0 +1,42 @@
+import type { WatcherRemovalDeadline } from './watcher-removal-drain'
+
+/**
+ * Removal-time coordination for the renderer-facing filesystem watchers.
+ *
+ * Worktree removal has to close any watcher holding the directory open, then either
+ * restore it (removal failed) or drop the snapshot (removal succeeded). The desktop
+ * implementation lives in `./filesystem-watcher`, where that bookkeeping is inseparable
+ * from the `WebContents` map it suspends and replays.
+ *
+ * Why the default is inert rather than a throw: every entry in those maps arrives through
+ * an `ipcMain` handler carrying a renderer `sender`. A host with no renderer never
+ * installs one, so there is nothing to close, restore, or forget — the no-op is what the
+ * desktop code itself would do against empty maps, not a silent stub hiding lost work.
+ */
+export type WorktreeWatcherRemoval = {
+ closeLocal(worktreePath: string, deadline?: WatcherRemovalDeadline): Promise
+ restoreLocal(worktreePath: string): Promise
+ forgetLocal(worktreePath: string): void
+ closeRemote(connectionId: string, worktreePath: string): Promise
+ restoreRemote(connectionId: string, worktreePath: string): Promise
+ forgetRemote(connectionId: string, worktreePath: string): void
+}
+
+const inert: WorktreeWatcherRemoval = {
+ closeLocal: async () => {},
+ restoreLocal: async () => {},
+ forgetLocal: () => {},
+ closeRemote: async () => {},
+ restoreRemote: async () => {},
+ forgetRemote: () => {}
+}
+
+let current: WorktreeWatcherRemoval = inert
+
+export function setWorktreeWatcherRemoval(next: WorktreeWatcherRemoval | null): void {
+ current = next ?? inert
+}
+
+export function getWorktreeWatcherRemoval(): WorktreeWatcherRemoval {
+ return current
+}
diff --git a/src/main/orca-profiles/profile-index-store.test.ts b/src/main/orca-profiles/profile-index-store.test.ts
index 128dc56cea3..06f2efe0903 100644
--- a/src/main/orca-profiles/profile-index-store.test.ts
+++ b/src/main/orca-profiles/profile-index-store.test.ts
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
@@ -12,11 +13,10 @@ import {
const testState = { dir: '' }
-vi.mock('electron', () => ({
- app: {
- getPath: () => testState.dir
- }
-}))
+// Why the port and not vi.mock('electron'): profile path resolution reads AppEnvironment
+// now, so an electron mock would be inert and every case would share the global fake's
+// one temp dir instead of its own.
+installFakeAppEnvironment({ getPath: () => testState.dir })
async function loadProfileIndexStore() {
vi.resetModules()
@@ -30,6 +30,8 @@ function readJson(path: string): unknown {
describe('profile index store', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-profile-test-'))
+ // Why re-install per test: the global setup's beforeEach reinstates its own fake.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
})
afterEach(() => {
diff --git a/src/main/orca-profiles/profile-storage-paths.ts b/src/main/orca-profiles/profile-storage-paths.ts
index 5f8dc0df241..81dc990b0e1 100644
--- a/src/main/orca-profiles/profile-storage-paths.ts
+++ b/src/main/orca-profiles/profile-storage-paths.ts
@@ -1,4 +1,4 @@
-import { app } from 'electron'
+import { getAppEnvironment } from '../../shared/app-environment'
import { join } from 'node:path'
const LEGACY_DATA_FILE_NAME = 'orca-data.json'
@@ -13,12 +13,12 @@ export const LEGACY_BACKUP_COUNT = 5
let profileUserDataPath: string | null = null
export function initOrcaProfilePaths(): void {
- profileUserDataPath = app.getPath('userData')
+ profileUserDataPath = getAppEnvironment().getPath('userData')
}
export function getProfileUserDataPath(): string {
if (!profileUserDataPath) {
- profileUserDataPath = app.getPath('userData')
+ profileUserDataPath = getAppEnvironment().getPath('userData')
}
return profileUserDataPath
}
diff --git a/src/main/orcad/main.ts b/src/main/orcad/main.ts
new file mode 100644
index 00000000000..e76ed873cb0
--- /dev/null
+++ b/src/main/orcad/main.ts
@@ -0,0 +1,8 @@
+/** Executable entry for `orcad`. See `./orcad-entry.ts`. */
+import process from 'node:process'
+import { main } from './orcad-entry'
+
+main().catch((error: unknown) => {
+ console.error('orcad: failed to start:', error)
+ process.exit(1)
+})
diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts
index 9080288cd53..621e89be5dc 100644
--- a/src/main/orcad/orcad-entry.ts
+++ b/src/main/orcad/orcad-entry.ts
@@ -14,6 +14,7 @@ import { join } from 'node:path'
import process from 'node:process'
import { setAppEnvironment, type AppEnvironment } from '../../shared/app-environment'
import { setSecretStore, type SecretStore } from '../../shared/secret-store'
+import type { ServeReadiness } from '../server/serve-readiness'
/** XDG-ish data root. `$ORCA_USER_DATA` wins so a smoke test can isolate state. */
function resolveUserDataPath(): string {
@@ -83,16 +84,56 @@ export function installOrcadHostAdapters(): void {
setSecretStore(createNodeSecretStore())
}
-/** Boot the runtime and serve RPC. Returns once the transport is listening. */
-export async function startOrcad(options: { port?: number } = {}): Promise {
+export type OrcadOptions = {
+ port?: number
+ json?: boolean
+ noPairing?: boolean
+ pairingAddress?: string
+}
+
+export type OrcadHandle = {
+ readiness: ServeReadiness
+ stop(): Promise
+}
+
+/**
+ * Boot the runtime and serve RPC. Resolves once the transport is listening and the
+ * readiness payload has been published, mirroring the desktop `--serve` contract byte
+ * for byte so the same harnesses can drive either host.
+ */
+export async function startOrcad(options: OrcadOptions = {}): Promise {
installOrcadHostAdapters()
const { OrcaRuntimeService } = await import('../runtime/orca-runtime')
const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc')
- const { registerPtyHandlers } = await import('../ipc/pty')
+ const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } =
+ await import('../ipc/pty')
const { getAppEnvironment } = await import('../../shared/app-environment')
+ const { resolveAdvertisedPairingEndpoint } = await import('../runtime/pairing-endpoint')
+ const { ServeReadinessPublisher } = await import('../server/serve-readiness')
+ const { Store } = await import('../persistence/loading-store/store')
+ const { ensureActiveOrcaProfile, initOrcaProfilePaths } =
+ await import('../orca-profiles/profile-index-store')
+ const { initSshHostKeyStoreFile } = await import('../ssh/ssh-host-key-store')
- const runtime = new OrcaRuntimeService(null, undefined, {
+ const userDataPath = getAppEnvironment().getPath('userData')
+ initOrcaProfilePaths()
+ const profile = ensureActiveOrcaProfile(userDataPath)
+ // Why a real Store: without one every persistence-backed RPC throws `runtime_unavailable`
+ // and the read paths that use `this.store?.x ?? []` quietly answer "empty" instead —
+ // a server that pairs and lists nothing looks healthy and is not.
+ const store = new Store({ dataFile: profile.dataFile })
+ // Why: every SSH connect consults this sidecar. Left unbound it reports nothing trusted,
+ // which is safe but silently discards accept records on every launch.
+ initSshHostKeyStoreFile(profile.dataFile)
+
+ const runtime = new OrcaRuntimeService(store, undefined, {
+ // Why lazy: a daemon swap replaces the provider after construction, so an eager
+ // reference would freeze the pre-daemon one.
+ getLocalProvider: () => getLocalPtyProvider(),
+ // Why: destructive worktree removal refuses to run without a provider to stop
+ // processes through — correctly, since it cannot otherwise verify the tree is idle.
+ getSshProvider: (connectionId) => getSshPtyProvider(connectionId),
// Why false: this host does not run the terminal daemon, so persistent local PTYs
// cannot be recovered. The constructor defaults this to true, which would claim a
// capability orcad does not have.
@@ -103,16 +144,125 @@ export async function startOrcad(options: { port?: number } = {}): Promise
getDesktopWindowStatus: () => 'blocked'
})
- // Why null: no renderer. This installs the RuntimePtyController that terminal.create
- // spawns through — the whole reason this module had to stop importing electron.
- registerPtyHandlers(null, runtime)
+ // Why the headless entry point rather than registerPtyHandlers(null, …): this is the
+ // same call `--serve` makes, and it threads the store through. Without the store the
+ // handlers install fine and every terminal.create then fails at persistence time.
+ //
+ // Codex-home and Claude-auth preparation are left unset: both are desktop account
+ // flows. A launch that needs one fails with its own message rather than silently
+ // spawning an unauthenticated agent.
+ registerHeadlessPtyRuntime(runtime, undefined, () => store.getSettings(), undefined, store)
+
+ // Why: same post-registration reconciliation `--serve` performs. Skipping it leaves
+ // restored orchestration rows claiming an authority this host never took over.
+ await runtime.refreshRestoredOrchestrationAuthority()
+ await runtime.reconcileLegacyWorkerTerminals()
const rpc = new OrcaRuntimeRpcServer({
runtime,
- userDataPath: getAppEnvironment().getPath('userData'),
+ userDataPath,
enableWebSocket: true,
exposeNetworkByDefault: true,
...(options.port !== undefined ? { wsPort: options.port, preferPinnedWsPort: true } : {})
- } as never)
+ })
await rpc.start()
+
+ const boundEndpoint = rpc.getWebSocketEndpoint()
+ const advertised = boundEndpoint
+ ? resolveAdvertisedPairingEndpoint(boundEndpoint, options.pairingAddress)
+ : null
+ const offer = options.noPairing
+ ? ({
+ available: false,
+ reason: 'disabled_by_operator',
+ guidance: 'Restart without --no-pairing to create a client pairing offer.'
+ } as const)
+ : rpc.createPairingOffer({
+ address: options.pairingAddress,
+ name: `CLI ${new Date().toLocaleDateString()}`,
+ scope: 'runtime'
+ })
+
+ const readiness: ServeReadiness = {
+ runtimeId: runtime.getRuntimeId(),
+ boundEndpoint,
+ advertisedEndpoint: advertised?.ok ? advertised.endpoint : null,
+ // Why 'settled': the WSL CLI reconciliation barrier is a desktop-launch concern.
+ // orcad never runs it, so there is no pending repair a client could race.
+ managedWslCliReconciliation: 'settled',
+ pairing: offer.available
+ ? {
+ available: true,
+ url: offer.pairingUrl,
+ endpoint: offer.endpoint,
+ deviceId: offer.deviceId,
+ webClientUrl: offer.webClientUrl,
+ scope: 'runtime',
+ qr: null
+ }
+ : offer
+ }
+
+ await new ServeReadinessPublisher().publish(readiness, {
+ mode: options.json ? 'json' : 'human'
+ })
+
+ return {
+ readiness,
+ stop: async () => {
+ await rpc.stop()
+ }
+ }
+}
+
+function parseArgs(argv: string[]): OrcadOptions {
+ const options: OrcadOptions = {}
+ for (let i = 0; i < argv.length; i += 1) {
+ const arg = argv[i]
+ if (arg === '--port') {
+ const raw = argv[i + 1]
+ const port = Number(raw)
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
+ throw new Error(`--port expects an integer 0-65535, got ${raw ?? "''"}`)
+ }
+ options.port = port
+ i += 1
+ } else if (arg === '--json') {
+ options.json = true
+ } else if (arg === '--no-pairing') {
+ options.noPairing = true
+ } else if (arg === '--pairing-address') {
+ const value = argv[i + 1]
+ if (!value) {
+ throw new Error('--pairing-address expects a value')
+ }
+ options.pairingAddress = value
+ i += 1
+ } else {
+ throw new Error(`Unknown argument: ${arg}`)
+ }
+ }
+ return options
+}
+
+export async function main(argv: string[] = process.argv.slice(2)): Promise {
+ const handle = await startOrcad(parseArgs(argv))
+ let stopping = false
+ const shutdown = (signal: NodeJS.Signals): void => {
+ if (stopping) {
+ return
+ }
+ stopping = true
+ handle
+ .stop()
+ .then(() => process.exit(0))
+ // Why not rethrow: we are already tearing down on a signal, and an exit code is
+ // the only thing a supervisor can act on.
+ .catch((error) => {
+ console.error(`orcad: shutdown after ${signal} failed:`, error)
+ process.exit(1)
+ })
+ }
+ process.on('SIGINT', () => shutdown('SIGINT'))
+ process.on('SIGTERM', () => shutdown('SIGTERM'))
}
diff --git a/src/main/persistence-async-write-syscalls.test.ts b/src/main/persistence-async-write-syscalls.test.ts
index 2d6d581599e..3a4c1f11e87 100644
--- a/src/main/persistence-async-write-syscalls.test.ts
+++ b/src/main/persistence-async-write-syscalls.test.ts
@@ -13,6 +13,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { SshRemotePtyLeaseState } from '../shared/ssh-types'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -177,6 +178,9 @@ async function createStore(dir: string): Promise {
testState.dir = dir
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store() as unknown as TestStore
}
diff --git a/src/main/persistence-duplicate-repo-id-host-scope.test.ts b/src/main/persistence-duplicate-repo-id-host-scope.test.ts
index f1a63e876ea..0da93c3a979 100644
--- a/src/main/persistence-duplicate-repo-id-host-scope.test.ts
+++ b/src/main/persistence-duplicate-repo-id-host-scope.test.ts
@@ -12,6 +12,7 @@ import type { Project, ProjectHostSetup } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import { getDefaultPersistedState } from '../shared/constants'
import { toRuntimeExecutionHostId } from '../shared/execution-host'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -52,6 +53,9 @@ async function createStoreFromState(state: Record) {
)
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts
index 9d407ab505b..c69233bb760 100644
--- a/src/main/persistence-protected-secret-fail-closed.test.ts
+++ b/src/main/persistence-protected-secret-fail-closed.test.ts
@@ -3,6 +3,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
type FailureMode = 'availability-throws' | 'encryption-throws' | 'unavailable'
@@ -56,6 +57,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts
index 89849d22dde..e21cc02fd56 100644
--- a/src/main/persistence-protected-secret-write-race.test.ts
+++ b/src/main/persistence-protected-secret-write-race.test.ts
@@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
const cipherState = { available: true }
@@ -53,6 +54,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts
index faa6f65822e..fb9c74c68be 100644
--- a/src/main/persistence-proxy-secret-recovery.test.ts
+++ b/src/main/persistence-proxy-secret-recovery.test.ts
@@ -10,6 +10,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, mkdtempSync, writeFileSync
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -63,6 +64,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts
index 06512f9a089..00becc26d4b 100644
--- a/src/main/persistence-single-serialize.test.ts
+++ b/src/main/persistence-single-serialize.test.ts
@@ -8,6 +8,7 @@ import { readFileSync, rmSync, mkdtempSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { randomUUID } from 'node:crypto'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -58,6 +59,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-test-harness.ts b/src/main/persistence-test-harness.ts
index 94b2c62d238..89f5f03a88f 100644
--- a/src/main/persistence-test-harness.ts
+++ b/src/main/persistence-test-harness.ts
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { vi } from 'vitest'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
import type { Project, ProjectHostSetup } from '../shared/project-types'
import type { Repo } from '../shared/repo-types'
import type { TerminalTab } from '../shared/terminal-tab-types'
@@ -13,6 +14,10 @@ export const testState = { dir: '' }
/** Reset modules and dynamically import Store so the data-file path picks up the current testState.dir */
export async function createStore() {
vi.resetModules()
+ // Why here and not a per-file vi.mock('electron'): the data-file path resolves through
+ // AppEnvironment now, and it must point at this test's dir rather than the global
+ // fake's shared one. Re-installed after resetModules so the fresh graph sees it.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
return new Store()
diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts
index 7bb676ffe34..1ff2cda74ee 100644
--- a/src/main/persistence-ui-state.test.ts
+++ b/src/main/persistence-ui-state.test.ts
@@ -12,6 +12,7 @@ import {
readDataFile,
makeRepo
} from './persistence-test-harness'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -50,6 +51,9 @@ async function createStore() {
describeUnavailable: () => null
})
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence-workspace-session-scrollback.test.ts b/src/main/persistence-workspace-session-scrollback.test.ts
index 1c98d9525a3..f57a2a1b603 100644
--- a/src/main/persistence-workspace-session-scrollback.test.ts
+++ b/src/main/persistence-workspace-session-scrollback.test.ts
@@ -18,6 +18,7 @@ import {
makeSessionWithTerminalBuffers,
makeSessionWithBrowserHistory
} from './persistence-session-fixtures'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Stub the ~/.ssh/config parser so the SSH-import test drives the real Store with deterministic hosts, not the operator's actual ~/.ssh/config.
const { loadUserSshConfigMock, sshConfigHostsToTargetsMock } = vi.hoisted(() => ({
@@ -188,6 +189,9 @@ describe('Store', () => {
mkdirSync(profileDataDirectory, { recursive: true })
vi.resetModules()
+ // Why: the legacy snapshot dir hangs off userData, which resolves through
+ // AppEnvironment — without this it points at the global fake's shared dir.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
@@ -218,6 +222,9 @@ describe('Store', () => {
writeFileSync(join(legacySnapshotDir, `${ref}.bin`), 'legacy-scrollback', 'utf-8')
vi.resetModules()
+ // Why: the legacy snapshot dir hangs off userData, which resolves through
+ // AppEnvironment — without this it points at the global fake's shared dir.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
const { Store, initDataPath } = await import('./persistence')
initDataPath()
const store = new Store({ dataFile: profileDataFile })
diff --git a/src/main/persistence-worktree-name-retirement.test.ts b/src/main/persistence-worktree-name-retirement.test.ts
index dcfb35a5954..2f89775ce87 100644
--- a/src/main/persistence-worktree-name-retirement.test.ts
+++ b/src/main/persistence-worktree-name-retirement.test.ts
@@ -8,6 +8,7 @@ import { createRetiredNameLookup } from '../shared/worktree/retired-name-registr
import type { SshTarget } from '../shared/ssh-types'
import { MAX_RETIREMENT_NAMESPACES } from './worktree-retirement-namespace'
import { getRuntimeOwnedSshTargetId } from './ssh/ssh-connection-store'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
const testState = { dir: '' }
@@ -45,6 +46,9 @@ function sshTarget(id: string, overrides: Partial = {}): SshTarget {
async function reloadStore() {
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
+ // Why here: userData resolves through AppEnvironment, and this must point at this
+ // file's temp dir rather than the global fake's shared one, after resetModules.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store()
}
diff --git a/src/main/persistence/loading-store/user-data-path.ts b/src/main/persistence/loading-store/user-data-path.ts
index db62f706b7b..6dd4b50b1c8 100644
--- a/src/main/persistence/loading-store/user-data-path.ts
+++ b/src/main/persistence/loading-store/user-data-path.ts
@@ -1,4 +1,4 @@
-import { app } from 'electron'
+import { getAppEnvironment } from '../../../shared/app-environment'
import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import type { PersistedState } from '../../../shared/persisted-state-types'
@@ -11,7 +11,7 @@ let _dataFile: string | null = null
let _userDataDir: string | null = null
export function initDataPath(): void {
- const userDataDir = app.getPath('userData')
+ const userDataDir = getAppEnvironment().getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
@@ -19,7 +19,7 @@ export function initDataPath(): void {
export function getDataFile(): string {
if (!_dataFile) {
// Safety fallback — should not be hit in normal startup.
- const userDataDir = app.getPath('userData')
+ const userDataDir = getAppEnvironment().getPath('userData')
_userDataDir = userDataDir
_dataFile = join(userDataDir, 'orca-data.json')
}
@@ -51,14 +51,14 @@ export function readGithubCacheSnapshot(dataFile: string): PersistedState['githu
}
/**
- * Return the userData directory captured at initDataPath() time, before app.setName() can change how app.getPath('userData') resolves.
+ * Return the userData directory captured at initDataPath() time, before app.setName() can change how getAppEnvironment().getPath('userData') resolves.
*
* Subsystems sharing storage with orca-data.json read this instead of resolving late, which on case-sensitive FS can lose paired devices.
*/
export function getCanonicalUserDataPath(): string {
if (!_userDataDir) {
// Safety fallback — should not be hit in normal startup.
- _userDataDir = app.getPath('userData')
+ _userDataDir = getAppEnvironment().getPath('userData')
}
return _userDataDir
}
diff --git a/src/main/quit-path-durable-write-blocking.test.ts b/src/main/quit-path-durable-write-blocking.test.ts
index 35ebe7460c3..58e055c5297 100644
--- a/src/main/quit-path-durable-write-blocking.test.ts
+++ b/src/main/quit-path-durable-write-blocking.test.ts
@@ -4,6 +4,7 @@ import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
+import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously,
// before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park
@@ -115,6 +116,8 @@ async function createStore(dir: string): Promise {
testState.dir = dir
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
+ // Why: userData resolves through AppEnvironment; point it at this file's temp dir.
+ installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store() as unknown as TestStore
}
diff --git a/src/main/runtime/mobile-pairing-userdata-path.test.ts b/src/main/runtime/mobile-pairing-userdata-path.test.ts
index be13c1ebbc3..441b5a57eb2 100644
--- a/src/main/runtime/mobile-pairing-userdata-path.test.ts
+++ b/src/main/runtime/mobile-pairing-userdata-path.test.ts
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'
// Import from the production source of truth so a filename rename can't silently
// pass these tests against stale names.
import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files'
+import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup'
// Mutable userData the electron mock resolves. We flip it mid-test to simulate
// app.setName('Orca') changing how app.getPath('userData') resolves (e.g. from
@@ -15,8 +16,12 @@ import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairin
// of whether the test host's filesystem is case-sensitive.
const appState = { userData: '' }
+// Why the port for getPath: userData now resolves through AppEnvironment, so an
+// electron mock would be inert here. safeStorage is still mocked because the modules
+// under test seal through it directly.
+installFakeAppEnvironment({ getPath: () => appState.userData })
+
vi.mock('electron', () => ({
- app: { getPath: () => appState.userData },
safeStorage: {
isEncryptionAvailable: () => false,
encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'),
@@ -38,6 +43,8 @@ describe('mobile pairing userData path stability', () => {
lateDir = join(root, 'userdata-late')
mkdirSync(canonicalDir, { recursive: true })
mkdirSync(lateDir, { recursive: true })
+ // Why re-install: the global setup's beforeEach reinstates its own fake.
+ installFakeAppEnvironment({ getPath: () => appState.userData })
vi.resetModules()
})
@@ -55,8 +62,9 @@ describe('mobile pairing userData path stability', () => {
appState.userData = lateDir
expect(getCanonicalUserDataPath()).toBe(canonicalDir)
- const { app } = await import('electron')
- expect(getCanonicalUserDataPath()).not.toBe(app.getPath('userData'))
+ // Why the port: this is the "resolve late" path the captured value must differ from.
+ const { getAppEnvironment } = await import('../../shared/app-environment')
+ expect(getCanonicalUserDataPath()).not.toBe(getAppEnvironment().getPath('userData'))
})
it('writes DeviceRegistry + E2EE keypair under the canonical path, not the late one', async () => {
diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts
index d1eaee981a0..3c7e27b062c 100644
--- a/src/main/runtime/orca-runtime.test.ts
+++ b/src/main/runtime/orca-runtime.test.ts
@@ -136,6 +136,7 @@ import {
setTerminalViewAttributes
} from './terminal-view-attribute-store'
import { clearConfiguredWorktreeSharedDirectoriesCacheForTests } from '../git/worktree-shared-directories'
+import { setWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
const ORIGINAL_PLATFORM = process.platform
const ORIGINAL_PLATFORM_DESCRIPTOR = Object.getOwnPropertyDescriptor(process, 'platform')
@@ -219,14 +220,17 @@ const forgetRemoteWatcherRemovalSnapshotMock = vi.hoisted(() => vi.fn())
const scanLocalRepoWorktreesForResolutionMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => electronMocks)
-vi.mock('../ipc/filesystem-watcher', () => ({
- closeLocalWatcherForWorktreePath: closeLocalWatcherForWorktreePathMock,
- closeRemoteWatcherForWorktreePath: closeRemoteWatcherForWorktreePathMock,
- restoreLocalWatcherAfterFailedRemoval: restoreLocalWatcherAfterFailedRemovalMock,
- restoreRemoteWatcherAfterFailedRemoval: restoreRemoteWatcherAfterFailedRemovalMock,
- forgetLocalWatcherRemovalSnapshot: forgetLocalWatcherRemovalSnapshotMock,
- forgetRemoteWatcherRemovalSnapshot: forgetRemoteWatcherRemovalSnapshotMock
-}))
+// Why install the port instead of mocking ../ipc/filesystem-watcher: the runtime calls
+// WorktreeWatcherRemoval now, so a module mock would be inert and every assertion below
+// would silently pass against the inert default. Same mocks, same expectations.
+setWorktreeWatcherRemoval({
+ closeLocal: closeLocalWatcherForWorktreePathMock,
+ closeRemote: closeRemoteWatcherForWorktreePathMock,
+ restoreLocal: restoreLocalWatcherAfterFailedRemovalMock,
+ restoreRemote: restoreRemoteWatcherAfterFailedRemovalMock,
+ forgetLocal: forgetLocalWatcherRemovalSnapshotMock,
+ forgetRemote: forgetRemoteWatcherRemovalSnapshotMock
+})
const {
MOCK_GIT_WORKTREES,
diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts
index 03ba37f0d98..8ddbfa4fdf8 100644
--- a/src/main/runtime/orca-runtime.ts
+++ b/src/main/runtime/orca-runtime.ts
@@ -1136,14 +1136,7 @@ import {
} from '../worktree-removal-repo-owner'
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation'
-import {
- closeLocalWatcherForWorktreePath,
- closeRemoteWatcherForWorktreePath,
- forgetLocalWatcherRemovalSnapshot,
- forgetRemoteWatcherRemovalSnapshot,
- restoreLocalWatcherAfterFailedRemoval,
- restoreRemoteWatcherAfterFailedRemoval
-} from '../ipc/filesystem-watcher'
+import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal'
import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate'
import {
createWatcherRemovalDeadline,
@@ -10234,11 +10227,11 @@ export class OrcaRuntimeService {
const results = await Promise.allSettled([
connectionId
? drainBeforeWatcherRemoval(
- closeRemoteWatcherForWorktreePath(connectionId, worktreePath),
+ getWorktreeWatcherRemoval().closeRemote(connectionId, worktreePath),
deadline,
`remote watcher close for ${worktreePath}`
)
- : closeLocalWatcherForWorktreePath(worktreePath, deadline),
+ : getWorktreeWatcherRemoval().closeLocal(worktreePath, deadline),
drainBeforeWatcherRemoval(
this.fileCommands.closeFileExplorerWatchersForPath(worktreePath, connectionId),
deadline,
@@ -10260,16 +10253,16 @@ export class OrcaRuntimeService {
): Promise => {
await Promise.all([
connectionId
- ? restoreRemoteWatcherAfterFailedRemoval(connectionId, worktreePath)
- : restoreLocalWatcherAfterFailedRemoval(worktreePath),
+ ? getWorktreeWatcherRemoval().restoreRemote(connectionId, worktreePath)
+ : getWorktreeWatcherRemoval().restoreLocal(worktreePath),
this.fileCommands.restoreFileExplorerWatchersAfterFailedRemoval(worktreePath, connectionId)
])
}
forgetFileWatchersAfterRemoval = (worktreePath: string, connectionId?: string): void => {
if (connectionId) {
- forgetRemoteWatcherRemovalSnapshot(connectionId, worktreePath)
+ getWorktreeWatcherRemoval().forgetRemote(connectionId, worktreePath)
} else {
- forgetLocalWatcherRemovalSnapshot(worktreePath)
+ getWorktreeWatcherRemoval().forgetLocal(worktreePath)
}
this.fileCommands.forgetFileExplorerWatchersAfterRemoval(worktreePath, connectionId)
}
diff --git a/src/main/speech/speech-runtime-service.ts b/src/main/speech/speech-runtime-service.ts
index 97f963ae57b..f5a65a939dc 100644
--- a/src/main/speech/speech-runtime-service.ts
+++ b/src/main/speech/speech-runtime-service.ts
@@ -1,28 +1,60 @@
-import { ModelManager } from './model-manager'
-import { SttService } from './stt-service'
+import type { ModelManager } from './model-manager'
+import type { SttService } from './stt-service'
import type { VoiceSettings } from '../../shared/speech-types'
+/**
+ * Lazy accessors for the speech services.
+ *
+ * Why the construction is injected: `ModelManager` downloads models through Electron's
+ * streaming `net.request` — byte-range resume, progress events, a manual idle timeout —
+ * and `SttService` resolves paths inside the packaged app. Importing either for its
+ * *type* is free; constructing one is what pulls Electron in.
+ *
+ * The desktop installs the factories. A host without them rejects per call rather than
+ * returning a stub that silently does nothing: speech is a desktop feature, and a
+ * headless host saying so is more useful than one that appears to transcribe.
+ */
+
type SpeechSettingsStore = {
getSettings(): {
voice?: VoiceSettings
}
}
+export type SpeechServiceFactories = {
+ createModelManager(customModelsDir: string | undefined): ModelManager
+ createSttService(models: ModelManager): SttService
+}
+
+let factories: SpeechServiceFactories | null = null
let modelManager: ModelManager | null = null
let sttService: SttService | null = null
+export function setSpeechServiceFactories(next: SpeechServiceFactories | null): void {
+ factories = next
+ modelManager = null
+ sttService = null
+}
+
+function requireFactories(): SpeechServiceFactories {
+ if (!factories) {
+ throw new Error('speech_unavailable: this host has no speech services')
+ }
+ return factories
+}
+
export function getSpeechModelManager(store: SpeechSettingsStore): ModelManager {
if (!modelManager) {
const settings = store.getSettings()
const customDir = settings.voice?.modelsDir || undefined
- modelManager = new ModelManager(customDir || undefined)
+ modelManager = requireFactories().createModelManager(customDir || undefined)
}
return modelManager
}
export function getSpeechSttService(store: SpeechSettingsStore): SttService {
if (!sttService) {
- sttService = new SttService(getSpeechModelManager(store))
+ sttService = requireFactories().createSttService(getSpeechModelManager(store))
}
return sttService
}
From 36dfcc3955e38dfe16d84f28aa4153ed05569b16 Mon Sep 17 00:00:00 2001
From: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:01:45 -0700
Subject: [PATCH 20/27] fix(terminal): arm the restore baseline only on a
snapshot that painted content (#16009)
The restored-snapshot baseline permanently drops every delivery chunk at or
below the snapshot's seq, on the model's claim that those bytes are already
painted. reconcileChunkAgainstRestoredSnapshot recovers when the baseline
under-reports (a gap ahead re-restores; a rawLength mismatch re-restores) but
has no path for a baseline that over-reports: those chunks return
drop-duplicate forever, and an idle shell never re-sends them.
Gate arming on whether the snapshot painted printable cells. A snapshot
claiming seq > 0 while painting nothing cannot be the rendering of the output
it claims to cover, so the claim is disproven and the redelivery is the only
remaining copy.
Also pins disposeHeadlessTerminal's two-part write ordering, which was
previously unpinned and silently reversible.
Refs STA-5179
---
...ss-terminal-dispose-write-ordering.test.ts | 106 +++++++
...ection-restored-baseline-shortfall.test.ts | 263 ++++++++++++++++++
.../terminal-pane/pty-connection.ts | 30 +-
.../restored-snapshot-coverage.test.ts | 30 ++
.../restored-snapshot-coverage.ts | 32 +++
5 files changed, 457 insertions(+), 4 deletions(-)
create mode 100644 src/main/runtime/headless-terminal-dispose-write-ordering.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts
create mode 100644 src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts
diff --git a/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts
new file mode 100644
index 00000000000..b26c7bfb62c
--- /dev/null
+++ b/src/main/runtime/headless-terminal-dispose-write-ordering.test.ts
@@ -0,0 +1,106 @@
+/**
+ * disposeHeadlessTerminal's unwritten contract: no model write is ever dropped.
+ *
+ * Two halves, both load-bearing and both silently reversible:
+ * 1. `headlessTerminals.delete(ptyId)` runs BEFORE disposal, so a write
+ * issued after the call lazily creates a fresh emulator instead of
+ * landing on the disposing one.
+ * 2. `dispose()` is registered on the CURRENT writeChain, so links already
+ * queued behind it parse before the emulator goes away.
+ * Swapping either ordering loses output with no error anywhere.
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { OrcaRuntimeService } from './orca-runtime'
+import { HeadlessEmulator } from '../daemon/headless-emulator'
+
+const store = {
+ getRepo: () => undefined,
+ getRepos: () => [],
+ addRepo: () => {},
+ updateRepo: () => undefined as never,
+ getAllWorktreeMeta: () => ({}),
+ getWorktreeMeta: () => undefined,
+ setWorktreeMeta: () => undefined as never,
+ removeWorktreeMeta: () => {},
+ getRetiredWorktreeNameRegistry: () => ({ exhaustedTiers: 0, names: [] }),
+ addRetiredWorktreeName: () => {},
+ mergeRetiredWorktreeNames: () => false,
+ getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
+ getSettings: () => ({
+ workspaceDir: '/tmp/workspaces',
+ nestWorkspaces: false,
+ refreshLocalBaseRefOnWorktreeCreate: false,
+ branchPrefix: 'none',
+ branchPrefixCustom: '',
+ terminalMainSideEffectAuthority: true
+ })
+}
+
+const PTY_ID = 'pty-dispose-ordering'
+
+function createRuntime(): OrcaRuntimeService {
+ const runtime = new OrcaRuntimeService(store)
+ runtime.setPtyController({
+ write: () => true,
+ kill: () => true,
+ getForegroundProcess: async () => null,
+ getSize: () => ({ cols: 80, rows: 24 }),
+ resize: () => true
+ })
+ return runtime
+}
+
+describe('disposeHeadlessTerminal write ordering', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('parses a write queued before disposal, then routes later writes to a fresh emulator', async () => {
+ const events: string[] = []
+ const emulators: HeadlessEmulator[] = []
+ const write = HeadlessEmulator.prototype.write
+ const dispose = HeadlessEmulator.prototype.dispose
+ vi.spyOn(HeadlessEmulator.prototype, 'write').mockImplementation(async function (
+ this: HeadlessEmulator,
+ data: string,
+ opts
+ ) {
+ if (!emulators.includes(this)) {
+ emulators.push(this)
+ }
+ events.push(`write:${emulators.indexOf(this)}:${data.trim()}`)
+ return write.call(this, data, opts)
+ })
+ vi.spyOn(HeadlessEmulator.prototype, 'dispose').mockImplementation(
+ function (this: HeadlessEmulator) {
+ events.push(`dispose:${emulators.indexOf(this)}`)
+ return dispose.call(this)
+ }
+ )
+
+ const runtime = createRuntime()
+ // Queued but not yet parsed: the chain link is still pending here.
+ runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1)
+ runtime.resetPtyModelAfterMigrationFailure(PTY_ID)
+ runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2)
+ await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID)
+
+ expect(events).toEqual([
+ 'write:0:QUEUED-BEFORE-DISPOSE',
+ 'write:1:ISSUED-AFTER-DISPOSE',
+ 'dispose:0'
+ ])
+ })
+
+ it('leaves the post-disposal emulator holding only post-disposal output', async () => {
+ const runtime = createRuntime()
+ runtime.onPtyData(PTY_ID, 'QUEUED-BEFORE-DISPOSE\r\n', 1)
+ runtime.resetPtyModelAfterMigrationFailure(PTY_ID)
+ runtime.onPtyData(PTY_ID, 'ISSUED-AFTER-DISPOSE\r\n', 2)
+
+ const snapshot = await runtime.serializeHiddenOutputRecoveryBuffer(PTY_ID)
+ const painted = `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`
+ expect(painted).toContain('ISSUED-AFTER-DISPOSE')
+ expect(painted).not.toContain('QUEUED-BEFORE-DISPOSE')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts
new file mode 100644
index 00000000000..5ac0a49fcb6
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/pty-connection-restored-baseline-shortfall.test.ts
@@ -0,0 +1,263 @@
+import type * as React from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { flushAsyncTicks } from './pty-connection-test-async'
+import {
+ createMockTransport,
+ createPane,
+ createManager,
+ type ConnectCallbacks,
+ type MockTransport
+} from './pty-connection-test-pane-fixtures'
+import { buildPaneConnectionDeps } from './pty-connection-test-deps'
+import { createInitialStoreState } from './pty-connection-test-store-fixtures'
+import type { StoreState } from './pty-connection-test-store-state'
+import {
+ installTerminalTestGlobals,
+ restoreTerminalTestGlobals
+} from './pty-connection-test-environment'
+
+const {
+ resetAndRefreshAllTerminalWebglAtlases,
+ scheduleTerminalWebglAtlasRecovery,
+ scheduleRuntimeGraphSync,
+ shouldSeedCacheTimerOnInitialTitle,
+ toastInfo,
+ notifyCodexPaneBoundForStaleSweep
+} = vi.hoisted(() => ({
+ resetAndRefreshAllTerminalWebglAtlases: vi.fn(),
+ scheduleTerminalWebglAtlasRecovery: vi.fn(),
+ scheduleRuntimeGraphSync: vi.fn(),
+ shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
+ toastInfo: vi.fn(),
+ notifyCodexPaneBoundForStaleSweep: vi.fn()
+}))
+
+let mockStoreState: StoreState
+let transportFactoryQueue: MockTransport[] = []
+let createdTransportOptions: Record[] = []
+let storeSubscribers: ((state: StoreState) => void)[] = []
+
+vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync }))
+
+vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({
+ ...(await importOriginal>()),
+ resetAndRefreshAllTerminalWebglAtlases
+}))
+
+vi.mock('./terminal-webgl-atlas-recovery', () => ({
+ scheduleTerminalWebglAtlasRecovery
+}))
+
+vi.mock('@/store', () => ({
+ useAppStore: {
+ getState: () => mockStoreState,
+ subscribe: (listener: (state: StoreState) => void) => {
+ storeSubscribers.push(listener)
+ return () => {
+ storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener)
+ }
+ }
+ }
+}))
+
+vi.mock('@/lib/agent-status', async (importOriginal) => {
+ const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment')
+ return buildAgentStatusModuleMock(await importOriginal>())
+})
+
+vi.mock('./cache-timer-seeding', () => ({
+ shouldSeedCacheTimerOnInitialTitle
+}))
+
+vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
+
+vi.mock('@/lib/codex-stale-pane-sweep', () => ({
+ notifyCodexPaneBoundForStaleSweep
+}))
+
+vi.mock('react', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ useCallback: unknown>(fn: T): T => fn
+ }
+})
+
+vi.mock('./pty-transport', () => ({
+ createIpcPtyTransport: vi.fn((options: Record) => {
+ createdTransportOptions.push(options)
+ const nextTransport = transportFactoryQueue.shift()
+ if (!nextTransport) {
+ throw new Error('No mock transport queued')
+ }
+ return nextTransport
+ })
+}))
+
+vi.mock('./remote-runtime-pty-transport', () => ({
+ createRemoteRuntimePtyTransport: vi.fn(
+ (_environmentId: string, options: Record) => {
+ createdTransportOptions.push(options)
+ const nextTransport = transportFactoryQueue.shift()
+ if (!nextTransport) {
+ throw new Error('No mock transport queued')
+ }
+ return nextTransport
+ }
+ )
+}))
+
+vi.mock('./pty-dispatcher', async (importOriginal) => {
+ const actual = await importOriginal>()
+ return { ...actual, getEagerPtyBufferHandle: vi.fn(() => undefined) }
+})
+
+/** Mode/attribute rehydration only — the shape a model emulator serializes when
+ * a seed or hydration write never landed and it holds no cell content. */
+const BLANK_MODEL_IMAGE = '\x1b[0m\x1b[?25h\x1b[?7h'
+
+describe('restored snapshot baseline shortfall (STA-5179)', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ vi.clearAllMocks()
+ transportFactoryQueue = []
+ createdTransportOptions = []
+ storeSubscribers = []
+ mockStoreState = createInitialStoreState(() => mockStoreState)
+ installTerminalTestGlobals()
+ })
+
+ afterEach(async () => {
+ await restoreTerminalTestGlobals()
+ })
+
+ function createDeps(overrides: Record = {}) {
+ return buildPaneConnectionDeps(() => mockStoreState, overrides)
+ }
+
+ function writtenData(pane: ReturnType): string {
+ return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')
+ }
+
+ async function revealPaneWithModelSnapshot(snapshot: {
+ data: string
+ seq: number
+ pendingDeliveryStartSeq?: number
+ scrollbackAnsi?: string
+ }): Promise<{
+ pane: ReturnType
+ dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void
+ getMainBufferSnapshot: ReturnType
+ }> {
+ mockStoreState.settings = {
+ ...mockStoreState.settings,
+ terminalMainSideEffectAuthority: true
+ } as StoreState['settings']
+ const { connectPanePty } = await import('./pty-connection')
+ const transport = createMockTransport('pty-id')
+ const capturedDataCallback: {
+ current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null
+ } = { current: null }
+ transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
+ capturedDataCallback.current = callbacks.onData ?? null
+ return 'pty-id'
+ })
+ transportFactoryQueue.push(transport)
+ const pane = createPane(1)
+ connectPanePty(
+ pane as never,
+ createManager(1) as never,
+ createDeps({
+ isVisibleRef: { current: true }
+ }) as never
+ )
+ await flushAsyncTicks(6)
+ const transportOptions = createdTransportOptions.at(-1) as {
+ onPtySpawn?: (ptyId: string) => void
+ }
+ transportOptions.onPtySpawn?.('pty-id')
+
+ const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType<
+ typeof vi.fn
+ >
+ getMainBufferSnapshot.mockResolvedValue({
+ cols: 100,
+ rows: 30,
+ ...snapshot
+ })
+ const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
+ _dispatchPtyModelRestoreNeededForTest({
+ id: 'pty-id',
+ reason: 'pending-cap',
+ markerSeq: snapshot.seq
+ })
+ await flushAsyncTicks(20)
+ expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1)
+ pane.terminal.write.mockClear()
+ return {
+ pane,
+ dataCallback: capturedDataCallback.current!,
+ getMainBufferSnapshot
+ }
+ }
+
+ it('recovers the backlog a blank snapshot claimed to have painted', async () => {
+ // The model over-reports: seq 2_472 with no cell content behind it. Every
+ // chunk carrying the missing tail sits at or below that seq, so the old
+ // baseline dropped all of it as duplicates with nothing left to re-send.
+ const { pane, dataCallback } = await revealPaneWithModelSnapshot({
+ data: BLANK_MODEL_IMAGE,
+ seq: 2_472,
+ pendingDeliveryStartSeq: 2_400
+ })
+
+ dataCallback('TAIL-INSIDE-WINDOW', { seq: 2_460, rawLength: 18 })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).toContain('TAIL-INSIDE-WINDOW')
+
+ // Straddling the claimed baseline: the whole chunk is recovered, not sliced.
+ dataCallback('TAIL-STRADDLING', { seq: 2_475, rawLength: 15 })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).toContain('TAIL-STRADDLING')
+
+ // An idle shell never re-sends: no further restore is needed to heal.
+ dataCallback('LIVE-AFTER-BASELINE', { seq: 2_494, rawLength: 19 })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).toContain('LIVE-AFTER-BASELINE')
+ })
+
+ it('recovers the backlog when only scrollback-less blank content was painted', async () => {
+ const { pane, dataCallback } = await revealPaneWithModelSnapshot({
+ data: '\x1b[H\x1b[2J',
+ scrollbackAnsi: ' \r\n',
+ seq: 900
+ })
+
+ dataCallback('BLANK-WITH-WHITESPACE-SCROLLBACK', {
+ seq: 880,
+ rawLength: 32
+ })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).toContain('BLANK-WITH-WHITESPACE-SCROLLBACK')
+ })
+
+ it('still suppresses backlog duplicates behind a snapshot that painted content', async () => {
+ // Regression guard for the normal path: a snapshot with real cell content
+ // keeps its drop-everything baseline, so the ACK backlog stays suppressed.
+ const { pane, dataCallback } = await revealPaneWithModelSnapshot({
+ data: 'restored snapshot\r\n',
+ seq: 2_472,
+ pendingDeliveryStartSeq: 2_400
+ })
+
+ dataCallback('COVERED-DUPLICATE', { seq: 2_460, rawLength: 17 })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).not.toContain('COVERED-DUPLICATE')
+
+ // Only the post-baseline remainder of a straddling chunk is written.
+ dataCallback('ABCDEFGHIJKLMNOPQRSTU', { seq: 2_478, rawLength: 21 })
+ await flushAsyncTicks(8)
+ expect(writtenData(pane)).toContain('PQRSTU')
+ expect(writtenData(pane)).not.toContain('ABCDEF')
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts
index 068cb9b6087..3050f81708e 100644
--- a/src/renderer/src/components/terminal-pane/pty-connection.ts
+++ b/src/renderer/src/components/terminal-pane/pty-connection.ts
@@ -19,6 +19,7 @@ import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral
import { TERMINAL_PAIRED_PARKING_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker'
import { parseTerminalKittyKeyboardFlags } from '../../../../shared/terminal-kitty-keyboard-flags'
+import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage'
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic'
import { isWorktreeRemovalFenceError } from '../../../../shared/worktree/removal-fence-error'
@@ -5982,6 +5983,7 @@ export function connectPanePty(
let hiddenOutputRestoreReplayingSnapshot: {
seq?: number
pendingDeliveryStartSeq?: number
+ paintsContent?: boolean
} | null = null
let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null
let cancelHiddenOutputRestoreFloodRepaintPark: (() => void) | null = null
@@ -6001,12 +6003,19 @@ export function connectPanePty(
function setRestoredSnapshotBaseline(
ptyId: string,
- snapshot: { seq?: number; pendingDeliveryStartSeq?: number }
+ snapshot: { seq?: number; pendingDeliveryStartSeq?: number },
+ paintsContent: boolean
): void {
if (typeof snapshot.seq !== 'number') {
clearRestoredSnapshotBaseline()
return
}
+ // Why: arming drops the redelivery permanently, so the snapshot's painted
+ // content must be able to back the seq it claims; a blank image cannot (STA-5179).
+ if (snapshot.seq > 0 && !paintsContent) {
+ clearRestoredSnapshotBaseline()
+ return
+ }
const windowStartSeq =
typeof snapshot.pendingDeliveryStartSeq === 'number'
? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq)
@@ -7220,7 +7229,11 @@ export function connectPanePty(
pendingData += sliced ?? chunk.data
}
if (replayingSnapshot && replayedSeq !== null) {
- setRestoredSnapshotBaseline(expectedPtyId, replayingSnapshot)
+ setRestoredSnapshotBaseline(
+ expectedPtyId,
+ replayingSnapshot,
+ replayingSnapshot.paintsContent === true
+ )
for (const chunk of pendingChunks) {
if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) {
restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq)
@@ -7403,6 +7416,7 @@ export function connectPanePty(
if (typeof snapshot.seq === 'number') {
hiddenOutputRestoreReplayingSnapshot = {
seq: snapshot.seq,
+ paintsContent: restoredSnapshotPaintsPrintableContent(snapshot),
...(typeof snapshot.pendingDeliveryStartSeq === 'number'
? { pendingDeliveryStartSeq: snapshot.pendingDeliveryStartSeq }
: {})
@@ -7726,7 +7740,11 @@ export function connectPanePty(
return
}
// Why: everything at/before snapshot.seq is now painted; chunks still draining from main's ACK backlog below it are duplicates to suppress.
- setRestoredSnapshotBaseline(currentPtyId, snapshot)
+ setRestoredSnapshotBaseline(
+ currentPtyId,
+ snapshot,
+ restoredSnapshotPaintsPrintableContent(snapshot)
+ )
hiddenOutputRestoreReplayingSnapshot = null
const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded
hiddenOutputRestoreFreshSnapshotNeeded = false
@@ -8537,7 +8555,11 @@ export function connectPanePty(
writeReplayData(modelSnapshot.pendingEscapeTailAnsi)
}
// Why: main sampled its delivery backlog with the snapshot; the baseline drops/slices deferred and live chunks the snapshot already covers.
- setRestoredSnapshotBaseline(ptyId, modelSnapshot)
+ setRestoredSnapshotBaseline(
+ ptyId,
+ modelSnapshot,
+ restoredSnapshotPaintsPrintableContent(modelSnapshot)
+ )
recordRendererOrderedSeq(modelSnapshot)
sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
if (connectResult?.coldRestore && !isRemoteRuntimePtyId(ptyId)) {
diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts
new file mode 100644
index 00000000000..4a8938b1266
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest'
+import { restoredSnapshotPaintsPrintableContent } from './restored-snapshot-coverage'
+
+describe('restoredSnapshotPaintsPrintableContent', () => {
+ it('accepts a snapshot with cell content in the frame or the scrollback', () => {
+ expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b[0m$ ls\r\n' })).toBe(true)
+ expect(
+ restoredSnapshotPaintsPrintableContent({
+ data: '\x1b[H\x1b[2J',
+ scrollbackAnsi: 'older\r\n'
+ })
+ ).toBe(true)
+ })
+
+ it('rejects a mode-rehydration-only image, which no amount of output renders to', () => {
+ expect(
+ restoredSnapshotPaintsPrintableContent({
+ data: '\x1b[0m\x1b[?25h\x1b[?7h'
+ })
+ ).toBe(false)
+ expect(restoredSnapshotPaintsPrintableContent({ data: '\x1b]0;title\x07' })).toBe(false)
+ expect(restoredSnapshotPaintsPrintableContent({})).toBe(false)
+ expect(
+ restoredSnapshotPaintsPrintableContent({
+ data: '\x1b[2J',
+ scrollbackAnsi: ' \r\n\t'
+ })
+ ).toBe(false)
+ })
+})
diff --git a/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts
new file mode 100644
index 00000000000..8e80c6485bd
--- /dev/null
+++ b/src/renderer/src/components/terminal-pane/restored-snapshot-coverage.ts
@@ -0,0 +1,32 @@
+import {
+ stripAnsiEscapeSequences,
+ TERMINAL_CONTROL_CHARACTER_PATTERN
+} from '../../../../shared/ansi-escape-sequences'
+
+/**
+ * Whether a restored model snapshot painted any printable cell content.
+ *
+ * Why this gates the restore baseline (STA-5179): the baseline makes every
+ * chunk at or below the snapshot's `seq` unrecoverable, on the model's word
+ * that those bytes are already painted. An image with no printable characters
+ * cannot be the rendering of the output it claims to cover, so the claim is
+ * disproven and the redelivery is the only remaining copy of those bytes.
+ * Replaying it into the blank screen the snapshot just produced also cannot
+ * duplicate visible output, so refusing to arm is the safe direction.
+ */
+export function restoredSnapshotPaintsPrintableContent(snapshot: {
+ data?: string
+ scrollbackAnsi?: string
+}): boolean {
+ return hasPrintableContent(snapshot.data) || hasPrintableContent(snapshot.scrollbackAnsi)
+}
+
+function hasPrintableContent(value: string | undefined): boolean {
+ if (!value) {
+ return false
+ }
+ return (
+ stripAnsiEscapeSequences(value).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '').trim().length >
+ 0
+ )
+}
From b851a5e13fa56595a50bc449f5a2235e32d77cce Mon Sep 17 00:00:00 2001
From: erish
Date: Sun, 23 Aug 2026 14:11:55 +0900
Subject: [PATCH 21/27] fix(github): add GHES avatar fallback to PR and task
views (#13981)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(pr-page): route remaining user avatars through GitHubUserAvatar
On a private-mode GitHub Enterprise instance the stored avatar URL 302s to
/login, and the renderer's default Electron session carries no cookie, so the
image never loads. #8784 added GitHubUserAvatar for exactly this — it degrades
to an initials placeholder via onError — but three call sites in
PullRequestPage kept a bare
: the reviewer picker, the comment author,
and the @ mention suggestions.
Each only guarded on avatarUrl being absent, so on GHE the URL is present, the
placeholder branch never runs, and a broken image is left on screen. The
authorAvatarUrl type comment already documents the intended contract ("falls
back to the login URL and finally an initials placeholder").
GitHubUserAvatar was already imported in this file for the PR author, so this
makes all five avatars in the page consistent. Note the three switched slots
now carry the shared border/bg styling, matching the two that already did.
Add a boundary test that fails if any avatar is rendered through a bare
again.
Fixes #13976
* fix(task-page): route GitHub avatar cells through GitHubUserAvatar too
Auditing the rest of the GHE avatar path turned up the same bare
in
TaskPage: GitHubAssigneeAvatar, GHAssigneesCell and PRReviewCell. Fixing only
the PR page would leave half of #13976 in place.
GitHubAssigneeAvatar is the clearest case — ReviewChipAvatar directly above it
already renders through GitHubUserAvatar, so two adjacent functions disagreed
on how a GitHub user avatar is drawn. Its border also moves from
border-border/40 to /50, matching the neighbour.
Linear member avatars in this file are left alone; they use their own provider
path and are out of scope here.
Move the regression assertions into the existing repro-8784 file rather than a
new boundary test — that file already guards PullRequestPage and TaskPage
together, so it is where this belongs. The PR-page check matches the
pattern instead of specific field names, so a rename or a newly added avatar
slot cannot slip past it; the TaskPage check is scoped per function to avoid
catching the Linear cells.
* test(github): scope the avatar guard per call site and cover TaskPage names
Addresses review feedback on the regression guard.
The field-name regex missed aliases and resolver expressions, and the PR-page
assertion did not require GitHubUserAvatar in each migrated slot — deleting all
three would have passed. Reject any bare
within the component scope
instead, which is safe now that every assertion is scoped to one function.
Drive all six slots from one table so each gets its own named case, and extend
the display-name contract to TaskPage, which previously went unchecked. The
ConversationTab entry carries displayName: null because PRComment has no
display-name field.
Reverting the fix now fails 11 cases instead of 3.
---
src/renderer/src/components/TaskPage.tsx | 51 ++++++---------
.../repro-8784-ghe-avatar-fallback.test.ts | 64 +++++++++++++++++++
.../conversation/comment-card.tsx | 16 ++---
.../pull-request-page/mentions/textarea.tsx | 14 ++--
.../reviewers/picker-row.tsx | 14 ++--
5 files changed, 104 insertions(+), 55 deletions(-)
diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx
index cf6e884b7b0..b0265713971 100644
--- a/src/renderer/src/components/TaskPage.tsx
+++ b/src/renderer/src/components/TaskPage.tsx
@@ -1438,25 +1438,14 @@ function ReviewChipAvatar({
}
function GitHubAssigneeAvatar({ assignee }: { assignee: GitHubAssignableUser }): React.JSX.Element {
- if (assignee.avatarUrl) {
- return (
-
- )
- }
return (
-
- {assignee.login.slice(0, 1).toUpperCase()}
-
+
)
}
@@ -1896,13 +1885,12 @@ function GHAssigneesCell({
) : null}
- {user.avatarUrl ? (
-
- ) : (
-
- {user.login.slice(0, 1).toUpperCase()}
-
- )}
+
{user.login}
{user.name ? (
@@ -2388,13 +2376,12 @@ function PRReviewCell({
{selected ? : null}
- {reviewer.avatarUrl ? (
-
- ) : (
-
- {reviewer.login.slice(0, 1).toUpperCase()}
-
- )}
+
{reviewer.login}
diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts
index f30da28d603..533274b2a95 100644
--- a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts
+++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts
@@ -51,4 +51,68 @@ describe('issue #8784 GHE avatar fallback (regression)', () => {
// Why: list chip must not hardcode github.com/{login}.png.
expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/)
})
+
+ // GHES URLs can exist but fail unauthenticated; target slots need onError fallbacks.
+ // Scope checks because TaskPage also renders non-GitHub provider avatars.
+ const GITHUB_AVATAR_SLOTS = [
+ {
+ file: 'pull-request-page/reviewers/picker-row.tsx',
+ fn: 'ReviewerPickerRow',
+ login: 'reviewer.login',
+ displayName: 'reviewer.name'
+ },
+ {
+ file: 'pull-request-page/conversation/comment-card.tsx',
+ fn: 'ConversationCommentCard',
+ login: 'comment.author',
+ displayName: null
+ },
+ {
+ file: 'pull-request-page/mentions/textarea.tsx',
+ fn: 'MentionTextarea',
+ login: 'option.login',
+ displayName: 'option.name'
+ },
+ {
+ file: 'TaskPage.tsx',
+ fn: 'GitHubAssigneeAvatar',
+ login: 'assignee.login',
+ displayName: 'assignee.name'
+ },
+ { file: 'TaskPage.tsx', fn: 'GHAssigneesCell', login: 'user.login', displayName: 'user.name' },
+ {
+ file: 'TaskPage.tsx',
+ fn: 'PRReviewCell',
+ login: 'reviewer.login',
+ displayName: 'reviewer.name'
+ }
+ ] as const
+
+ function componentBody(file: string, fn: string): string {
+ const source = readFileSync(join(__dirname, '..', file), 'utf8')
+ const start = source.indexOf(`function ${fn}`)
+ expect(start, `${file}: function ${fn} not found`).toBeGreaterThanOrEqual(0)
+ const next = source.indexOf('\nfunction ', start + 1)
+ return source.slice(start, next === -1 ? undefined : next)
+ }
+
+ it.each(GITHUB_AVATAR_SLOTS)(
+ 'renders the $fn avatar through GitHubUserAvatar (#13976)',
+ ({ file, fn, login }) => {
+ const body = componentBody(file, fn)
+
+ // Reject aliases and resolver expressions as well as direct avatarUrl fields.
+ expect(body, `${fn} still renders a bare img`).not.toMatch(/
]/)
+ expect(body, `${fn} does not use GitHubUserAvatar`).toContain(' slot.displayName !== null))(
+ 'passes the $fn display name so initials are not reduced to one letter (#13976)',
+ ({ file, fn, displayName }) => {
+ expect(componentBody(file, fn)).toContain(`name={${displayName}}`)
+ }
+ )
})
diff --git a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx
index 66ed253a6c3..598a23ad6ca 100644
--- a/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx
+++ b/src/renderer/src/components/pull-request-page/conversation/comment-card.tsx
@@ -12,6 +12,7 @@ import {
import { formatRelativeTime } from '@/components/github/work-item-state-presentation'
import { CommentCodeContext } from '@/components/github/CommentCodeContext'
import { CommentReactions } from '@/components/github/CommentReactions'
+import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import { translate } from '@/i18n/i18n'
import type { GitHubOwnerRepo, GitHubPRFile } from '../../../../../shared/github/pull-request-types'
import type { PRComment } from '../../../../../shared/github/comment-types'
@@ -62,15 +63,12 @@ export function ConversationCommentCard({
)}
>
- {comment.authorAvatarUrl ? (
-

- ) : (
-
- )}
+
- {option.avatarUrl ? (
-
- ) : (
-
- {option.login.slice(0, 1).toUpperCase()}
-
- )}
+
@{option.login}
{option.name && (
diff --git a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx
index a8b347765f2..5e24fffdbbb 100644
--- a/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx
+++ b/src/renderer/src/components/pull-request-page/reviewers/picker-row.tsx
@@ -2,6 +2,7 @@ import React from 'react'
import { Check } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
+import { GitHubUserAvatar } from '@/components/github/github-user-avatar'
import type { GitHubAssignableUser } from '../../../../../shared/github/pull-request-types'
export function ReviewerPickerRow({
@@ -59,13 +60,12 @@ export function ReviewerPickerRow({
{selected ? : null}
- {reviewer.avatarUrl ? (
-
- ) : (
-
- {reviewer.login.slice(0, 1).toUpperCase()}
-
- )}
+
{reviewer.login}
From 838f5bfb7536a5e197289698f313f11f97a9995a Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:30:11 -0700
Subject: [PATCH 22/27] fix(secrets): tell Linux users when their secrets are
only obfuscated (#16033)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
On Linux with no keyring, Electron falls back to the `basic_text` backend, which
"encrypts" with a hardcoded password. `isEncryptionAvailable()` returns true for
it, so Orca reported those secrets as sealed. They are not.
The obvious fix — returning false for basic_text — is wrong and would have been a
credential regression: `decryptWithStatus()` skips decryption entirely when
encryption is unavailable, so every already-stored secret would read back empty.
Sealing genuinely works on basic_text and must keep working.
So capability and trust are now separate questions. `isEncryptionAvailable()`
still answers "can this host seal and unseal", and `describeProtectionGap()`
(renamed from `describeUnavailable`) answers "is my data actually protected",
covering both no-sealing and weak-sealing.
That method had no production caller — the port documented a promise nothing
kept. `reportSecretProtectionGap()` now reads it at startup. A user-visible
surface is follow-up; this at least stops the silence.
Adds a bootstrap wiring guard over all nine host port installs. The no-op
defaults are correct for a renderer-less host and silently wrong for the desktop,
and a dropped or reordered install fails no existing test. Verified in both
directions: it fails when an install is removed, and when one moves after the
runtime is constructed.
---
config/scripts/vitest-host-ports-setup.ts | 2 +-
.../bitbucket/credential-connection.test.ts | 2 +-
src/main/bitbucket/credential-store.test.ts | 2 +-
src/main/bitbucket/status-no-decrypt.test.ts | 2 +-
src/main/host/electron-secret-store.test.ts | 61 ++++++++++++++++-
src/main/host/electron-secret-store.ts | 24 ++++---
.../host/secret-protection-report.test.ts | 38 +++++++++++
src/main/host/secret-protection-report.ts | 30 +++++++++
src/main/index.ts | 4 ++
.../worktree-watcher-removal-binding.test.ts | 60 +++++++++++++++++
src/main/jira/client.test.ts | 2 +-
src/main/linear/client.test.ts | 2 +-
src/main/orcad/orcad-entry.ts | 4 +-
...tence-protected-secret-fail-closed.test.ts | 2 +-
...stence-protected-secret-write-race.test.ts | 2 +-
.../persistence-proxy-secret-recovery.test.ts | 2 +-
src/main/persistence-single-serialize.test.ts | 2 +-
src/main/persistence-ui-state.test.ts | 2 +-
src/main/protected-secret-persistence.test.ts | 2 +-
src/main/speech/openai-api-key-store.test.ts | 2 +-
.../host-port-bootstrap-wiring.test.ts | 66 +++++++++++++++++++
src/shared/secret-store.test.ts | 6 +-
src/shared/secret-store.ts | 16 +++--
23 files changed, 300 insertions(+), 35 deletions(-)
create mode 100644 src/main/host/secret-protection-report.test.ts
create mode 100644 src/main/host/secret-protection-report.ts
create mode 100644 src/main/ipc/worktree-watcher-removal-binding.test.ts
create mode 100644 src/main/startup/host-port-bootstrap-wiring.test.ts
diff --git a/config/scripts/vitest-host-ports-setup.ts b/config/scripts/vitest-host-ports-setup.ts
index c12e16b593f..34df265b7e2 100644
--- a/config/scripts/vitest-host-ports-setup.ts
+++ b/config/scripts/vitest-host-ports-setup.ts
@@ -62,6 +62,6 @@ beforeEach(() => {
}
return text.slice(SEAL_PREFIX.length)
},
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
})
diff --git a/src/main/bitbucket/credential-connection.test.ts b/src/main/bitbucket/credential-connection.test.ts
index 0d1a7e632bb..ea07cf6a96c 100644
--- a/src/main/bitbucket/credential-connection.test.ts
+++ b/src/main/bitbucket/credential-connection.test.ts
@@ -15,7 +15,7 @@ async function loadModule() {
isEncryptionAvailable: () => true,
encryptString: (value) => Buffer.from(value),
decryptString: (value) => value.toString('utf-8'),
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('node:os', async () => {
const actual = await vi.importActual('node:os')
diff --git a/src/main/bitbucket/credential-store.test.ts b/src/main/bitbucket/credential-store.test.ts
index 601831a5930..34797bb516b 100644
--- a/src/main/bitbucket/credential-store.test.ts
+++ b/src/main/bitbucket/credential-store.test.ts
@@ -24,7 +24,7 @@ async function loadStore(
isEncryptionAvailable: () => true,
encryptString: (value) => Buffer.from(value),
decryptString: decryptStringMock,
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('node:os', async () => {
const actual = await vi.importActual('node:os')
diff --git a/src/main/bitbucket/status-no-decrypt.test.ts b/src/main/bitbucket/status-no-decrypt.test.ts
index 05543108ea6..11dc39bfcd8 100644
--- a/src/main/bitbucket/status-no-decrypt.test.ts
+++ b/src/main/bitbucket/status-no-decrypt.test.ts
@@ -24,7 +24,7 @@ async function loadModules() {
isEncryptionAvailable: () => true,
encryptString: (value) => Buffer.from(value),
decryptString: decryptSpy,
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('node:os', async () => {
const actual = await vi.importActual('node:os')
diff --git a/src/main/host/electron-secret-store.test.ts b/src/main/host/electron-secret-store.test.ts
index 3c171d7d499..0a1f3127cb6 100644
--- a/src/main/host/electron-secret-store.test.ts
+++ b/src/main/host/electron-secret-store.test.ts
@@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const safeStorageMock = vi.hoisted(() => ({
isEncryptionAvailable: vi.fn(() => true),
encryptString: vi.fn((plainText: string) => Buffer.from(`os-sealed:${plainText}`)),
- decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length))
+ decryptString: vi.fn((cipher: Buffer) => cipher.toString().slice('os-sealed:'.length)),
+ getSelectedStorageBackend: vi.fn(() => 'gnome_libsecret')
}))
vi.mock('electron', () => ({ safeStorage: safeStorageMock }))
@@ -14,6 +15,60 @@ describe('ElectronSecretStore', () => {
beforeEach(() => {
vi.clearAllMocks()
safeStorageMock.isEncryptionAvailable.mockReturnValue(true)
+ safeStorageMock.getSelectedStorageBackend.mockReturnValue('gnome_libsecret')
+ })
+
+ function withPlatform(platform: NodeJS.Platform, run: () => T): T {
+ const original = process.platform
+ Object.defineProperty(process, 'platform', { configurable: true, value: platform })
+ try {
+ return run()
+ } finally {
+ Object.defineProperty(process, 'platform', { configurable: true, value: original })
+ }
+ }
+
+ describe('at-rest protection reporting', () => {
+ it('reports no gap when a real keyring backend is selected', () => {
+ withPlatform('linux', () => {
+ expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
+ })
+ })
+
+ it('reports a gap for the Linux basic_text backend, which protects nothing', () => {
+ safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text')
+ withPlatform('linux', () => {
+ const gap = new ElectronSecretStore().describeProtectionGap()
+ expect(gap).toMatch(/built-in key/)
+ expect(gap).toMatch(/gnome-keyring|kwallet/)
+ })
+ })
+
+ it('keeps sealing AVAILABLE on basic_text so stored credentials still decrypt', () => {
+ // Why this matters more than the warning: flipping isEncryptionAvailable() would
+ // make decryptWithStatus() stop attempting and return every stored secret as
+ // empty. The gap is a trust signal, never a capability switch.
+ safeStorageMock.getSelectedStorageBackend.mockReturnValue('basic_text')
+ withPlatform('linux', () => {
+ const store = new ElectronSecretStore()
+ expect(store.isEncryptionAvailable()).toBe(true)
+ expect(store.decryptString(store.encryptString('linear-token'))).toBe('linear-token')
+ })
+ })
+
+ it('does not consult the backend on macOS, where basic_text does not exist', () => {
+ withPlatform('darwin', () => {
+ expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
+ })
+ expect(safeStorageMock.getSelectedStorageBackend).not.toHaveBeenCalled()
+ })
+
+ it('reports the missing-keyring gap before considering the backend', () => {
+ safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
+ withPlatform('linux', () => {
+ expect(new ElectronSecretStore().describeProtectionGap()).toMatch(/keyring is unavailable/)
+ })
+ })
})
// Why this shape: the whole safety argument for the SecretStore refactor is that the
@@ -47,12 +102,12 @@ describe('ElectronSecretStore', () => {
})
it('has no reason to give while sealing works', () => {
- expect(new ElectronSecretStore().describeUnavailable()).toBeNull()
+ expect(new ElectronSecretStore().describeProtectionGap()).toBeNull()
})
it('names the missing facility when sealing is unavailable, so the plaintext fallback is explainable', () => {
safeStorageMock.isEncryptionAvailable.mockReturnValue(false)
- const reason = new ElectronSecretStore().describeUnavailable()
+ const reason = new ElectronSecretStore().describeProtectionGap()
expect(reason).toContain('unencrypted')
if (process.platform === 'linux') {
expect(reason).toContain('keyring')
diff --git a/src/main/host/electron-secret-store.ts b/src/main/host/electron-secret-store.ts
index 63d4bb78d9b..ae9f8643a74 100644
--- a/src/main/host/electron-secret-store.ts
+++ b/src/main/host/electron-secret-store.ts
@@ -18,14 +18,22 @@ export class ElectronSecretStore implements SecretStore {
return safeStorage.decryptString(cipher)
}
- describeUnavailable(): string | null {
- if (safeStorage.isEncryptionAvailable()) {
- return null
+ describeProtectionGap(): string | null {
+ if (!safeStorage.isEncryptionAvailable()) {
+ // Why platform-specific: the fix differs, and "encryption unavailable" alone
+ // sends users looking in the wrong place.
+ return process.platform === 'linux'
+ ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.'
+ : 'The OS keychain is unavailable, so secrets are stored unencrypted.'
}
- // Why platform-specific: the fix differs, and "encryption unavailable" alone
- // sends users looking in the wrong place.
- return process.platform === 'linux'
- ? 'The OS keyring is unavailable, so secrets are stored unencrypted. Install and unlock gnome-keyring or kwallet to seal them.'
- : 'The OS keychain is unavailable, so secrets are stored unencrypted.'
+ // Why this is not folded into isEncryptionAvailable(): on Linux with no keyring,
+ // Electron falls back to `basic_text`, which "encrypts" with a hardcoded password.
+ // It round-trips, so sealing and unsealing genuinely work and must keep working —
+ // reporting it unavailable would strand every credential already stored this way.
+ // But it protects nothing, and reporting it as sealed is the actual lie.
+ if (process.platform === 'linux' && safeStorage.getSelectedStorageBackend() === 'basic_text') {
+ return 'Secrets are obfuscated with a built-in key, not protected by the OS keyring. Install and unlock gnome-keyring or kwallet, then restart Orca, to seal them properly.'
+ }
+ return null
}
}
diff --git a/src/main/host/secret-protection-report.test.ts b/src/main/host/secret-protection-report.test.ts
new file mode 100644
index 00000000000..2288bf11806
--- /dev/null
+++ b/src/main/host/secret-protection-report.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { setSecretStore, _resetSecretStoreForTests } from '../../shared/secret-store'
+import { reportSecretProtectionGap } from './secret-protection-report'
+
+function installStore(gap: string | null): void {
+ setSecretStore({
+ isEncryptionAvailable: () => true,
+ encryptString: (plainText) => Buffer.from(plainText),
+ decryptString: (cipher) => cipher.toString(),
+ describeProtectionGap: () => gap
+ })
+}
+
+describe('reportSecretProtectionGap', () => {
+ it('logs and returns the gap when protection is weaker than a user would assume', () => {
+ installStore('Secrets are obfuscated with a built-in key.')
+ const logged: string[] = []
+ expect(reportSecretProtectionGap((m) => logged.push(m))).toMatch(/built-in key/)
+ expect(logged).toHaveLength(1)
+ expect(logged[0]).toContain('[secrets]')
+ })
+
+ it('stays silent when secrets are properly sealed', () => {
+ installStore(null)
+ const logged: string[] = []
+ expect(reportSecretProtectionGap((m) => logged.push(m))).toBeNull()
+ expect(logged).toEqual([])
+ })
+
+ it('does not throw startup when no store is installed', () => {
+ // Why: this is diagnostics running early in bootstrap. The useful error is the one
+ // raised by the first real read, not a crash from the thing reporting on it.
+ _resetSecretStoreForTests()
+ const logged: string[] = []
+ expect(() => reportSecretProtectionGap((m) => logged.push(m))).not.toThrow()
+ expect(logged[0]).toContain('could not determine at-rest protection')
+ })
+})
diff --git a/src/main/host/secret-protection-report.ts b/src/main/host/secret-protection-report.ts
new file mode 100644
index 00000000000..511b2a4425c
--- /dev/null
+++ b/src/main/host/secret-protection-report.ts
@@ -0,0 +1,30 @@
+import { getSecretStore } from '../../shared/secret-store'
+
+/**
+ * Report at-rest secret protection once at startup.
+ *
+ * Why this exists: the port has always been able to describe a protection gap, and
+ * nothing read it — so a Linux user whose secrets are obfuscated with a built-in key
+ * was told nothing at all. Reading it here is the difference between the port
+ * documenting a promise and keeping one.
+ *
+ * Deliberately not fatal and not a dialog: sealing still works, so blocking startup
+ * would be worse than the gap it reports.
+ */
+export function reportSecretProtectionGap(
+ log: (message: string) => void = (message) => console.warn(message)
+): string | null {
+ let gap: string | null
+ try {
+ gap = getSecretStore().describeProtectionGap()
+ } catch (error) {
+ // Why swallow: this is diagnostics. An uninstalled store is already a hard failure
+ // at the first real read, and that error is the useful one.
+ log(`[secrets] could not determine at-rest protection: ${String(error)}`)
+ return null
+ }
+ if (gap) {
+ log(`[secrets] ${gap}`)
+ }
+ return gap
+}
diff --git a/src/main/index.ts b/src/main/index.ts
index 42abcd75590..5686dd41e33 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -34,6 +34,7 @@ import { setSpeechServiceFactories } from './speech/speech-runtime-service'
import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal'
import { setSecretStore } from '../shared/secret-store'
import { ElectronSecretStore } from './host/electron-secret-store'
+import { reportSecretProtectionGap } from './host/secret-protection-report'
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
@@ -883,6 +884,9 @@ if (hasSingleInstanceLock) {
// the app.setName ordering the userData captures below depend on.
setAppEnvironment(new ElectronAppEnvironment())
setSecretStore(new ElectronSecretStore())
+ // Why right after install: this is the first moment the answer is knowable, and a
+ // silent weak backend is the gap users most need told about.
+ reportSecretProtectionGap()
// Why at process level, not per-window: pty.ts registers against injected surfaces so
// it can load without electron, and an Electron main process always has ipcMain —
// whether a window exists is irrelevant. Installing this in attachMainWindowServices
diff --git a/src/main/ipc/worktree-watcher-removal-binding.test.ts b/src/main/ipc/worktree-watcher-removal-binding.test.ts
new file mode 100644
index 00000000000..b0b99ed1f79
--- /dev/null
+++ b/src/main/ipc/worktree-watcher-removal-binding.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest'
+import { getWorktreeWatcherRemoval, setWorktreeWatcherRemoval } from './worktree-watcher-removal'
+
+/**
+ * Why: the port's default is inert by design — a host with no renderer has no watchers
+ * to close. That is correct for orcad and silently wrong for the desktop, where an
+ * uninstalled port means worktree removal stops releasing the directory and Windows
+ * fails the delete on a locked file. The default is indistinguishable from a working
+ * install at the call site, so it gets asserted here.
+ */
+describe('WorktreeWatcherRemoval port', () => {
+ const METHODS = [
+ 'closeLocal',
+ 'restoreLocal',
+ 'forgetLocal',
+ 'closeRemote',
+ 'restoreRemote',
+ 'forgetRemote'
+ ] as const
+
+ it('defaults to inert so a renderer-less host is honest, not broken', async () => {
+ setWorktreeWatcherRemoval(null)
+ const inert = getWorktreeWatcherRemoval()
+ for (const method of METHODS) {
+ await expect(
+ Promise.resolve(inert[method]('repo::/tmp/w', '/tmp/w' as never))
+ ).resolves.not.toThrow()
+ }
+ })
+
+ it('routes every method to the installed binding', async () => {
+ const calls: string[] = []
+ setWorktreeWatcherRemoval({
+ closeLocal: async () => void calls.push('closeLocal'),
+ restoreLocal: async () => void calls.push('restoreLocal'),
+ forgetLocal: () => void calls.push('forgetLocal'),
+ closeRemote: async () => void calls.push('closeRemote'),
+ restoreRemote: async () => void calls.push('restoreRemote'),
+ forgetRemote: () => void calls.push('forgetRemote')
+ })
+ for (const method of METHODS) {
+ await getWorktreeWatcherRemoval()[method]('conn', '/tmp/w' as never)
+ }
+ expect(calls).toEqual([...METHODS])
+ setWorktreeWatcherRemoval(null)
+ })
+
+ it('exposes a desktop binding that delegates rather than stubbing', async () => {
+ // Why import lazily: filesystem-watcher pulls electron, so it must not load in the
+ // inert-default case above.
+ const { desktopWorktreeWatcherRemoval } = await import('./filesystem-watcher')
+ for (const method of METHODS) {
+ expect(typeof desktopWorktreeWatcherRemoval[method], method).toBe('function')
+ // An inert stub is an empty arrow; a real delegation is not.
+ expect(desktopWorktreeWatcherRemoval[method].toString(), method).not.toMatch(
+ /^\s*(async )?\(\s*\)\s*=>\s*\{?\s*\}?\s*$/
+ )
+ }
+ })
+})
diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts
index 7480a98db03..7f57ad80b01 100644
--- a/src/main/jira/client.test.ts
+++ b/src/main/jira/client.test.ts
@@ -114,7 +114,7 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value) => Buffer.from(value),
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('os', async () => {
const actual = await vi.importActual('os')
diff --git a/src/main/linear/client.test.ts b/src/main/linear/client.test.ts
index f2105a250de..1043a0997ec 100644
--- a/src/main/linear/client.test.ts
+++ b/src/main/linear/client.test.ts
@@ -95,7 +95,7 @@ async function loadClientModule(options: SafeStorageMockOptions = {}) {
isEncryptionAvailable: () => options.encryptionAvailable ?? false,
encryptString: (value) => Buffer.from(value),
decryptString: options.decryptString ?? ((value) => value.toString('utf-8')),
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('os', async () => {
const actual = await vi.importActual('os')
diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts
index 621e89be5dc..bab68282e53 100644
--- a/src/main/orcad/orcad-entry.ts
+++ b/src/main/orcad/orcad-entry.ts
@@ -63,7 +63,7 @@ function createNodeAppEnvironment(): AppEnvironment {
/**
* Why not silently plaintext: `isEncryptionAvailable() === false` already makes every
* caller fall back to unsealed storage, which is a security posture, not a detail.
- * `describeUnavailable()` gives the reason a client can surface.
+ * `describeProtectionGap()` gives the reason a client can surface.
*/
function createNodeSecretStore(): SecretStore {
return {
@@ -74,7 +74,7 @@ function createNodeSecretStore(): SecretStore {
decryptString: () => {
throw new Error('orcad_secret_sealing_unavailable')
},
- describeUnavailable: () =>
+ describeProtectionGap: () =>
'This host has no OS keyring, so credentials are stored unencrypted. Pair from a desktop to manage secrets, or install and unlock a keyring.'
}
}
diff --git a/src/main/persistence-protected-secret-fail-closed.test.ts b/src/main/persistence-protected-secret-fail-closed.test.ts
index c69233bb760..26f49ea2e44 100644
--- a/src/main/persistence-protected-secret-fail-closed.test.ts
+++ b/src/main/persistence-protected-secret-fail-closed.test.ts
@@ -54,7 +54,7 @@ async function createStore() {
}
return decoded.slice('enc:'.length + 36 + 1)
},
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
diff --git a/src/main/persistence-protected-secret-write-race.test.ts b/src/main/persistence-protected-secret-write-race.test.ts
index e21cc02fd56..21aa20d13b8 100644
--- a/src/main/persistence-protected-secret-write-race.test.ts
+++ b/src/main/persistence-protected-secret-write-race.test.ts
@@ -51,7 +51,7 @@ async function createStore() {
isEncryptionAvailable: () => cipherState.available,
encryptString: (plaintext) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
decryptString: (ciphertext) => ciphertext.toString('utf-8').slice('enc:'.length),
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
diff --git a/src/main/persistence-proxy-secret-recovery.test.ts b/src/main/persistence-proxy-secret-recovery.test.ts
index fb9c74c68be..176113192c7 100644
--- a/src/main/persistence-proxy-secret-recovery.test.ts
+++ b/src/main/persistence-proxy-secret-recovery.test.ts
@@ -61,7 +61,7 @@ async function createStore() {
}
return decoded.slice('enc:'.length + 36 + 1)
},
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
diff --git a/src/main/persistence-single-serialize.test.ts b/src/main/persistence-single-serialize.test.ts
index 00becc26d4b..1293bb30f48 100644
--- a/src/main/persistence-single-serialize.test.ts
+++ b/src/main/persistence-single-serialize.test.ts
@@ -56,7 +56,7 @@ async function createStore() {
}
return decoded.slice('enc:'.length + 36 + 1)
},
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
diff --git a/src/main/persistence-ui-state.test.ts b/src/main/persistence-ui-state.test.ts
index 1ff2cda74ee..1b092293c68 100644
--- a/src/main/persistence-ui-state.test.ts
+++ b/src/main/persistence-ui-state.test.ts
@@ -48,7 +48,7 @@ async function createStore() {
}
return decoded.slice('encrypted:'.length)
},
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
const { Store, initDataPath } = await import('./persistence')
// Why here: userData resolves through AppEnvironment, and this must point at this
diff --git a/src/main/protected-secret-persistence.test.ts b/src/main/protected-secret-persistence.test.ts
index ff71a9e8c9d..28a9ca6cf43 100644
--- a/src/main/protected-secret-persistence.test.ts
+++ b/src/main/protected-secret-persistence.test.ts
@@ -10,7 +10,7 @@ describe('ProtectedSecretPersistence', () => {
isEncryptionAvailable: () => cipherState.available,
encryptString: (plaintext) => Buffer.from(`encrypted:${plaintext}`),
decryptString: (ciphertext) => ciphertext.toString().slice('encrypted:'.length),
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
})
diff --git a/src/main/speech/openai-api-key-store.test.ts b/src/main/speech/openai-api-key-store.test.ts
index 2153c9fe608..e9dafd63781 100644
--- a/src/main/speech/openai-api-key-store.test.ts
+++ b/src/main/speech/openai-api-key-store.test.ts
@@ -17,7 +17,7 @@ async function loadStoreModule() {
const { setSecretStore } = await import('../../shared/secret-store')
setSecretStore({
...safeStorageMock,
- describeUnavailable: () => null
+ describeProtectionGap: () => null
})
vi.doMock('os', async () => {
const actual = await vi.importActual('os')
diff --git a/src/main/startup/host-port-bootstrap-wiring.test.ts b/src/main/startup/host-port-bootstrap-wiring.test.ts
new file mode 100644
index 00000000000..cfebf6cb0a8
--- /dev/null
+++ b/src/main/startup/host-port-bootstrap-wiring.test.ts
@@ -0,0 +1,66 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+/**
+ * Guards the single point of failure for every host port.
+ *
+ * The ports deliberately split two ways when uninstalled: AppEnvironment and SecretStore
+ * throw, because a silent default writes real user state to the wrong place; the rest
+ * default to no-ops or inert stubs, because a host with no renderer legitimately has
+ * nothing to register. That asymmetry is only safe while the desktop installs all of
+ * them before anything reads state — a dropped or reordered line here does not fail a
+ * unit test, it silently degrades the shipped app (worktree removal stops closing
+ * watchers, notifications stop firing, browser panes start rejecting).
+ *
+ * Source-level because that is the property: these run once at module scope during
+ * startup, so there is no seam to assert against at runtime.
+ */
+describe('host port bootstrap wiring', () => {
+ const source = readFileSync(join(process.cwd(), 'src/main/index.ts'), 'utf8')
+
+ const INSTALLS = [
+ 'setAppEnvironment(new ElectronAppEnvironment())',
+ 'setSecretStore(new ElectronSecretStore())',
+ 'setPtyHostBindings({',
+ 'setRuntimeDesktopSurface(electronRuntimeDesktopSurface)',
+ 'setRuntimeBrowserCommandsFactory(electronRuntimeBrowserCommandsFactory)',
+ 'setDefaultProxySessionResolver(',
+ 'setMainHttpClient(electronHttpClient)',
+ 'setSpeechServiceFactories(electronSpeechServiceFactories)',
+ 'setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)'
+ ]
+
+ it('installs every host port exactly once', () => {
+ for (const install of INSTALLS) {
+ expect(source.split(install).length - 1, `${install} should appear exactly once`).toBe(1)
+ }
+ })
+
+ it('installs every port before the runtime, the PTY handlers, or any window exists', () => {
+ // Why these three: they are the first things that resolve a path, seal a credential,
+ // or register against an injected surface.
+ const firstUse = Math.min(
+ ...['new OrcaRuntimeService(', 'registerHeadlessPtyRuntime(', 'function openMainWindow(']
+ .map((marker) => source.indexOf(marker))
+ .filter((index) => index >= 0)
+ )
+ expect(firstUse).toBeGreaterThan(0)
+
+ for (const install of INSTALLS) {
+ expect(source.indexOf(install), `${install} must run before first use`).toBeLessThan(firstUse)
+ }
+ })
+
+ it('installs the ports at process level, not per window', () => {
+ // Why: installing per window registered the PTY surfaces against no-ops on the
+ // serve path, where no window ever opens. Caught in CI by the SSH docker E2E.
+ const openWindow = source.indexOf('function openMainWindow(')
+ for (const install of INSTALLS) {
+ expect(
+ source.indexOf(install, openWindow),
+ `${install} must not be re-installed per window`
+ ).toBe(-1)
+ }
+ })
+})
diff --git a/src/shared/secret-store.test.ts b/src/shared/secret-store.test.ts
index c0478472cd5..7c325018047 100644
--- a/src/shared/secret-store.test.ts
+++ b/src/shared/secret-store.test.ts
@@ -12,7 +12,7 @@ function fakeStore(overrides: Partial = {}): SecretStore {
isEncryptionAvailable: () => true,
encryptString: (plainText) => Buffer.from(`sealed:${plainText}`),
decryptString: (cipher) => cipher.toString().slice('sealed:'.length),
- describeUnavailable: () => null,
+ describeProtectionGap: () => null,
...overrides
}
}
@@ -45,9 +45,9 @@ describe('SecretStore registry', () => {
setSecretStore(
fakeStore({
isEncryptionAvailable: () => false,
- describeUnavailable: () => 'The OS keyring is unavailable.'
+ describeProtectionGap: () => 'The OS keyring is unavailable.'
})
)
- expect(getSecretStore().describeUnavailable()).toBe('The OS keyring is unavailable.')
+ expect(getSecretStore().describeProtectionGap()).toBe('The OS keyring is unavailable.')
})
})
diff --git a/src/shared/secret-store.ts b/src/shared/secret-store.ts
index 4f3d594e207..312c28e557f 100644
--- a/src/shared/secret-store.ts
+++ b/src/shared/secret-store.ts
@@ -6,7 +6,7 @@
* The contract mirrors safeStorage exactly, including the part that matters most:
* `isEncryptionAvailable()` may return false. A store that cannot seal must say so
* rather than throw; how a caller degrades is its own decision (persistence retains
- * the prior sealed blob rather than writing plaintext). See `describeUnavailable()`,
+ * the prior sealed blob rather than writing plaintext). See `describeProtectionGap()`,
* which exists so the reason reaches the user, not a console warning nobody reads.
*/
@@ -15,12 +15,16 @@ export type SecretStore = {
encryptString(plainText: string): Buffer
decryptString(cipher: Buffer): string
/**
- * Why: "encryption unavailable" is a security posture, not a detail. When
- * `isEncryptionAvailable()` is false this returns a short, user-safe sentence
- * explaining which host facility is missing, for the degradation surface.
- * Returns null when encryption IS available.
+ * Why separate from `isEncryptionAvailable()`: that answers "can this host seal and
+ * unseal at all", which must stay true for a backend that seals weakly — flipping it
+ * would stop `decryptWithStatus()` even attempting, and every already-stored
+ * credential would read back empty. This answers the different question a user cares
+ * about: "is my data actually protected at rest?"
+ *
+ * Non-null means the protection is not what a user would assume — either no sealing,
+ * or sealing with a backend that does not meaningfully protect. Null means sealed.
*/
- describeUnavailable(): string | null
+ describeProtectionGap(): string | null
}
/**
From 0cb076f03c60fcac05d0bbd0f19b248c13c47414 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:34:38 -0700
Subject: [PATCH 23/27] fix(dictation): address visualizer review feedback
(#16035)
---
docs/STYLEGUIDE.md | 2 +-
src/renderer/src/assets/main.css | 5 ++--
.../DictationIndicator.localization.test.ts | 24 +++++++++++++++++++
.../dictation/DictationIndicator.tsx | 12 +++++-----
src/renderer/src/i18n/locales/en.json | 10 ++++----
src/renderer/src/i18n/locales/es.json | 7 +++++-
src/renderer/src/i18n/locales/ja.json | 7 +++++-
src/renderer/src/i18n/locales/ko.json | 7 +++++-
src/renderer/src/i18n/locales/zh.json | 7 +++++-
9 files changed, 63 insertions(+), 18 deletions(-)
create mode 100644 src/renderer/src/components/dictation/DictationIndicator.localization.test.ts
diff --git a/docs/STYLEGUIDE.md b/docs/STYLEGUIDE.md
index a76d981dd2f..06be8eda659 100644
--- a/docs/STYLEGUIDE.md
+++ b/docs/STYLEGUIDE.md
@@ -102,7 +102,7 @@ Orca uses shadows sparingly. Three levels in practice:
1. **Inset hairline** — `border` + `border` token. The default. Almost everything sits at this level.
2. **Subtle lift** — `shadow-xs` + a single-token border. Outline buttons, embedded cards.
-3. **Floating** — `0 10px 24px rgba(0, 0, 0, 0.18)`. Popovers, popups that escape the editor surface. Reserved.
+3. **Floating** — `shadow-floating` (`0 10px 24px rgba(0, 0, 0, 0.18)`). Popovers, popups that escape the editor surface. Reserved.
Don't add a fourth level. If something needs more emphasis than "floating," you're probably reaching for the focus `ring` instead.
diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css
index 9b4df1b4aa9..9d75836cd11 100644
--- a/src/renderer/src/assets/main.css
+++ b/src/renderer/src/assets/main.css
@@ -117,6 +117,7 @@
--color-git-graph-lane-4: var(--git-graph-lane-4);
--color-git-graph-lane-5: var(--git-graph-lane-5);
--color-annotation-highlight: var(--annotation-highlight);
+ --shadow-floating: 0 10px 24px rgb(0 0 0 / 0.18);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -395,7 +396,7 @@
flex-wrap: wrap;
gap: 10px;
padding: 14px 16px;
- box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
+ box-shadow: var(--shadow-floating);
}
[data-sonner-toaster] [data-sonner-toast][data-styled='true'] [data-icon] {
@@ -1322,7 +1323,7 @@ html.native-shell .app-layout {
background: var(--popover);
color: var(--popover-foreground);
/* STYLEGUIDE: floating elevation for popovers that escape the editor surface. */
- box-shadow: 0 10px 24px rgb(0 0 0 / 0.18);
+ box-shadow: var(--shadow-floating);
}
/* Why: caret points down at the usage meters this notice explains. */
diff --git a/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts
new file mode 100644
index 00000000000..8b2863d7613
--- /dev/null
+++ b/src/renderer/src/components/dictation/DictationIndicator.localization.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import en from '@/i18n/locales/en.json'
+import es from '@/i18n/locales/es.json'
+import ja from '@/i18n/locales/ja.json'
+import ko from '@/i18n/locales/ko.json'
+import zh from '@/i18n/locales/zh.json'
+
+const englishLabels = en.auto.components.dictation.DictationIndicator as Record
+const translatedLabels = { es, ja, ko, zh }
+
+describe('DictationIndicator localization', () => {
+ it('uses generated localization keys', () => {
+ expect(Object.keys(englishLabels).every((key) => /^[a-f0-9]{10}$/.test(key))).toBe(true)
+ })
+
+ it.each(Object.entries(translatedLabels))('translates every label in %s', (_locale, catalog) => {
+ const labels = catalog.auto.components.dictation.DictationIndicator as Record
+
+ for (const [key, englishLabel] of Object.entries(englishLabels)) {
+ expect(labels[key]).toBeTruthy()
+ expect(labels[key]).not.toBe(englishLabel)
+ }
+ })
+})
diff --git a/src/renderer/src/components/dictation/DictationIndicator.tsx b/src/renderer/src/components/dictation/DictationIndicator.tsx
index 5029cf225ed..caa9981ea62 100644
--- a/src/renderer/src/components/dictation/DictationIndicator.tsx
+++ b/src/renderer/src/components/dictation/DictationIndicator.tsx
@@ -27,14 +27,14 @@ export function DictationIndicator() {
const isSpeaking = isListening && dictationMeter.isSpeaking && !isClipping
const lifecycleLabel =
dictationState === 'starting'
- ? translate('auto.components.dictation.DictationIndicator.starting', 'Starting mic…')
+ ? translate('auto.components.dictation.DictationIndicator.7f3660a7ba', 'Starting mic…')
: dictationState === 'stopping'
- ? translate('auto.components.dictation.DictationIndicator.processing', 'Processing…')
- : translate('auto.components.dictation.DictationIndicator.listening', 'Listening')
+ ? translate('auto.components.dictation.DictationIndicator.f082d0cb9d', 'Processing…')
+ : translate('auto.components.dictation.DictationIndicator.3de5a129e7', 'Listening')
const label = isClipping
- ? translate('auto.components.dictation.DictationIndicator.tooLoud', 'Too loud')
+ ? translate('auto.components.dictation.DictationIndicator.4977162383', 'Too loud')
: isSpeaking
- ? translate('auto.components.dictation.DictationIndicator.speaking', 'Speaking')
+ ? translate('auto.components.dictation.DictationIndicator.25f2b7a6a5', 'Speaking')
: lifecycleLabel
const announcedLabel = isClipping ? label : lifecycleLabel
const canStop = dictationState !== 'stopping'
@@ -50,7 +50,7 @@ export function DictationIndicator() {
data-testid="dictation-indicator"
className={cn(
'fixed bottom-12 left-1/2 z-50 -translate-x-1/2 overflow-hidden',
- 'border border-border bg-popover/95 text-sm text-popover-foreground shadow-[0_10px_24px_rgba(0,0,0,0.18)] backdrop-blur',
+ 'border border-border bg-popover/95 text-sm text-popover-foreground shadow-floating backdrop-blur',
'transition-[width,border-radius,opacity] duration-200 ease-out motion-reduce:transition-none',
transcript
? 'w-[min(28rem,calc(100vw-2rem))] rounded-xl'
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 05c84a4b55e..596ef2f4e3d 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -14611,11 +14611,11 @@
},
"DictationIndicator": {
"335e1bc6cb": "Stop dictation",
- "tooLoud": "Too loud",
- "speaking": "Speaking",
- "starting": "Starting mic…",
- "processing": "Processing…",
- "listening": "Listening"
+ "7f3660a7ba": "Starting mic…",
+ "f082d0cb9d": "Processing…",
+ "3de5a129e7": "Listening",
+ "4977162383": "Too loud",
+ "25f2b7a6a5": "Speaking"
}
},
"dashboard": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index add3a3cf295..3689e21bd12 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -13277,7 +13277,12 @@
"5d2c3e7ae3": "No se detectó ninguna voz."
},
"DictationIndicator": {
- "335e1bc6cb": "Detener dictado"
+ "335e1bc6cb": "Detener dictado",
+ "7f3660a7ba": "Iniciando micrófono…",
+ "f082d0cb9d": "Procesando…",
+ "3de5a129e7": "Escuchando",
+ "4977162383": "Volumen demasiado alto",
+ "25f2b7a6a5": "Hablando"
}
},
"dashboard": {
diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json
index 22a47205978..304d5f1d5eb 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -13277,7 +13277,12 @@
"5d2c3e7ae3": "音声が検出されませんでした。"
},
"DictationIndicator": {
- "335e1bc6cb": "ディクテーションを停止する"
+ "335e1bc6cb": "ディクテーションを停止する",
+ "7f3660a7ba": "マイクを起動中…",
+ "f082d0cb9d": "処理中…",
+ "3de5a129e7": "聞き取り中",
+ "4977162383": "音量が大きすぎます",
+ "25f2b7a6a5": "発話中"
}
},
"dashboard": {
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index c3e8f863415..1108cdd735e 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -13295,7 +13295,12 @@
"5d2c3e7ae3": "음성이 감지되지 않았습니다."
},
"DictationIndicator": {
- "335e1bc6cb": "받아쓰기 중지"
+ "335e1bc6cb": "받아쓰기 중지",
+ "7f3660a7ba": "마이크 시작 중…",
+ "f082d0cb9d": "처리 중…",
+ "3de5a129e7": "듣는 중",
+ "4977162383": "음량이 너무 큽니다",
+ "25f2b7a6a5": "말하는 중"
}
},
"dashboard": {
diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json
index 4d59715176a..cbb41b0fa66 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -13297,7 +13297,12 @@
"5d2c3e7ae3": "未检测到语音。"
},
"DictationIndicator": {
- "335e1bc6cb": "停止听写"
+ "335e1bc6cb": "停止听写",
+ "7f3660a7ba": "正在启动麦克风…",
+ "f082d0cb9d": "正在处理…",
+ "3de5a129e7": "正在聆听",
+ "4977162383": "音量过大",
+ "25f2b7a6a5": "正在说话"
}
},
"dashboard": {
From 9ea2636b38a05cc28ca9a1b4f661ab8e68fd24ba Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:37:40 -0700
Subject: [PATCH 24/27] fix(remote): keep editor focus after unfocused HTML
side preview (STA-5001) (#15716)
Opening a remote HTML preview to the side created an empty split and made it
the active group. The next host session-tab snapshot still had the terminal
active, so the client treated that empty group as a terminal focus change.
Do not activate the empty split for unfocused remote previews, and when a
reserved preview group is still empty, keep the sibling editor as the
visible tab instead of following the host terminal.
---
src/renderer/src/lib/file-preview.test.ts | 19 ++
src/renderer/src/lib/file-preview.ts | 7 +-
...b-session-focus-intent-visible-tab.test.ts | 32 ++-
.../src/runtime/web-session-focus-intent.ts | 27 ++
...ssion-tabs-sync-html-preview-focus.test.ts | 265 ++++++++++++++++++
.../src/runtime/web-session-tabs-sync.ts | 12 +
.../tabs-empty-split-activation.test.ts | 55 ++++
src/renderer/src/store/slices/tabs.ts | 15 +-
8 files changed, 426 insertions(+), 6 deletions(-)
create mode 100644 src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts
create mode 100644 src/renderer/src/store/slices/tabs-empty-split-activation.test.ts
diff --git a/src/renderer/src/lib/file-preview.test.ts b/src/renderer/src/lib/file-preview.test.ts
index 8196e5b4e05..07f5239f1ab 100644
--- a/src/renderer/src/lib/file-preview.test.ts
+++ b/src/renderer/src/lib/file-preview.test.ts
@@ -111,6 +111,9 @@ describe('openFileInBrowserTab', () => {
sourceGroupId: 'group-1'
})
+ expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right', {
+ activate: false
+ })
expect(mocks.createWebRuntimeSessionBrowserTab).toHaveBeenCalledWith({
worktreeId: 'wt-1',
environmentId: 'runtime-1',
@@ -124,6 +127,22 @@ describe('openFileInBrowserTab', () => {
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
+ it('creates local side previews in an activated right-hand split', () => {
+ openFilePreviewToSide({
+ language: 'html',
+ filePath: '/tmp/example.html',
+ worktreeId: 'wt-1',
+ sourceGroupId: 'group-1'
+ })
+
+ expect(mocks.createEmptySplitGroup).toHaveBeenCalledWith('wt-1', 'group-1', 'right')
+ expect(mocks.createBrowserTab).toHaveBeenCalledWith('wt-1', 'file:///tmp/example.html', {
+ title: 'example.html',
+ targetGroupId: 'group-2',
+ activate: true
+ })
+ })
+
it('reports a paired-runtime recovery failure without an unhandled rejection', async () => {
mocks.environmentId = 'runtime-1'
mocks.browserAvailability = { state: 'enabled', provider: 'paired-runtime' }
diff --git a/src/renderer/src/lib/file-preview.ts b/src/renderer/src/lib/file-preview.ts
index 7f50873e3a8..7bea5d29012 100644
--- a/src/renderer/src/lib/file-preview.ts
+++ b/src/renderer/src/lib/file-preview.ts
@@ -207,8 +207,11 @@ export function openFilePreviewToSide(params: {
let targetGroupId = existingSibling
if (!targetGroupId) {
// Why: no split yet — create one to the right so the preview lands beside
- // the editor. createEmptySplitGroup returns the new (empty) group id.
- targetGroupId = state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right')
+ // the editor. Remote previews stay unfocused until click, so do not activate
+ // the empty group or a host snapshot will treat it as a terminal pane.
+ targetGroupId = environmentId
+ ? state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right', { activate: false })
+ : state.createEmptySplitGroup(worktreeId, sourceGroupId, 'right')
}
if (!targetGroupId) {
return
diff --git a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts
index 09ca8760d7e..1434a8ef3d4 100644
--- a/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts
+++ b/src/renderer/src/runtime/web-session-focus-intent-visible-tab.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { resolveWebSessionVisibleTabId } from './web-session-focus-intent'
+import {
+ resolveWebSessionSiblingVisibleTabId,
+ resolveWebSessionVisibleTabId
+} from './web-session-focus-intent'
import {
toVisibleTabType,
type Tab,
@@ -235,6 +238,33 @@ describe('resolveWebSessionVisibleTabId — no-group compatibility path', () =>
})
})
+describe('resolveWebSessionSiblingVisibleTabId', () => {
+ it('returns the sibling whose active tab matches the remembered visible type', () => {
+ const editor = tab({ id: 'tab-editor', entityId: 'file-1', contentType: 'editor' })
+ const terminal = tab({
+ id: 'tab-terminal',
+ entityId: 'term-1',
+ contentType: 'terminal',
+ groupId: GROUP_B
+ })
+ const state = makeState({
+ unifiedTabsByWorktree: { [WT]: [editor, terminal] },
+ groupsByWorktree: {
+ [WT]: [
+ group({ id: GROUP_A, activeTabId: editor.id, tabOrder: [editor.id] }),
+ group({ id: GROUP_B, activeTabId: null, tabOrder: [] })
+ ]
+ },
+ activeGroupIdByWorktree: { [WT]: GROUP_B },
+ activeTabType: 'editor',
+ activeTabTypeByWorktree: { [WT]: 'editor' }
+ })
+
+ expect(resolveWebSessionVisibleTabId(state, WT)).toBeNull()
+ expect(resolveWebSessionSiblingVisibleTabId(state, WT)).toBe(editor.id)
+ })
+})
+
describe('toVisibleTabType', () => {
it('collapses every editor-family kind and preserves the rest', () => {
expect(toVisibleTabType('editor')).toBe('editor')
diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts
index e69d7edf467..5fd06192f40 100644
--- a/src/renderer/src/runtime/web-session-focus-intent.ts
+++ b/src/renderer/src/runtime/web-session-focus-intent.ts
@@ -95,6 +95,33 @@ export function resolveWebSessionVisibleTabId(
)
}
+export function resolveWebSessionSiblingVisibleTabId(
+ state: WebSessionVisibleTabState,
+ worktreeId: string,
+ tabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
+): string | null {
+ const activeGroupId = state.activeGroupIdByWorktree?.[worktreeId] ?? null
+ const preferredType =
+ state.activeTabTypeByWorktree?.[worktreeId] ??
+ (state.activeWorktreeId === worktreeId ? state.activeTabType : null)
+ const tabById = new Map(tabs.map((tab) => [tab.id, tab]))
+ let fallback: string | null = null
+ for (const group of state.groupsByWorktree?.[worktreeId] ?? []) {
+ if (group.id === activeGroupId || group.activeTabId == null) {
+ continue
+ }
+ const tab = tabById.get(group.activeTabId)
+ if (!tab || tab.groupId !== group.id) {
+ continue
+ }
+ if (preferredType && toVisibleTabType(tab.contentType) === preferredType) {
+ return tab.id
+ }
+ fallback ??= tab.id
+ }
+ return fallback
+}
+
function focusIntentPartitionKey(owner: WebSessionIntentOwner, worktreeId: string): string {
return `${webSessionIntentOwnerKey(owner)}\0${worktreeId}`
}
diff --git a/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts
new file mode 100644
index 00000000000..77ed83dcbf9
--- /dev/null
+++ b/src/renderer/src/runtime/web-session-tabs-sync-html-preview-focus.test.ts
@@ -0,0 +1,265 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id'
+import type { OpenFile } from '../store/slices/editor'
+import type { Tab } from '../../../shared/tab-types'
+import { recordWebSessionBrowserPlacement } from './web-session-browser-placement'
+import { applyWebSessionTabsSnapshot, type WebSessionTabsSyncState } from './web-session-tabs-sync'
+import {
+ ENV,
+ LEAF_ID,
+ NOW,
+ WT,
+ makeSnapshot,
+ makeState,
+ resetWebSessionTabsSyncTestState
+} from './web-session-tabs-sync-test-harness'
+
+vi.mock('../store', () => ({
+ useAppStore: {
+ setState: vi.fn()
+ }
+}))
+
+const EDITOR_GROUP = 'client-editor-group'
+const PREVIEW_GROUP = 'client-preview-group'
+const EDITOR_FILE_ID = '/repo/paired-html-focus.html'
+const EDITOR_TAB_ID = 'local-html-editor'
+const HOST_TERMINAL_PARENT = 'host-terminal'
+const HOST_TERMINAL_SURFACE = `${HOST_TERMINAL_PARENT}::${LEAF_ID}`
+const HOST_BROWSER_TAB = 'host-html-browser-tab'
+const HOST_BROWSER_PAGE = 'host-html-browser-page'
+
+function htmlOpenFile(): OpenFile {
+ return {
+ id: EDITOR_FILE_ID,
+ filePath: EDITOR_FILE_ID,
+ relativePath: 'paired-html-focus.html',
+ worktreeId: WT,
+ language: 'html',
+ isDirty: false,
+ runtimeEnvironmentId: ENV,
+ mode: 'edit'
+ }
+}
+
+function htmlEditorTab(): Tab {
+ return {
+ id: EDITOR_TAB_ID,
+ entityId: EDITOR_FILE_ID,
+ groupId: EDITOR_GROUP,
+ worktreeId: WT,
+ contentType: 'editor',
+ label: 'paired-html-focus.html',
+ customLabel: null,
+ color: null,
+ sortOrder: 1,
+ createdAt: NOW,
+ isPreview: false,
+ isPinned: false
+ }
+}
+
+function mirroredTerminalTab(terminalTabId: string): Tab {
+ return {
+ id: terminalTabId,
+ entityId: terminalTabId,
+ groupId: EDITOR_GROUP,
+ worktreeId: WT,
+ contentType: 'terminal',
+ label: 'host shell',
+ customLabel: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: NOW,
+ isPreview: false,
+ isPinned: false
+ }
+}
+
+function editorFocusedSplitState(activeGroupId: string): WebSessionTabsSyncState {
+ const terminalTabId = toWebTerminalSurfaceTabId(HOST_TERMINAL_PARENT)
+ const editorTab = htmlEditorTab()
+ const openFile = htmlOpenFile()
+ return makeState({
+ activeFileId: openFile.id,
+ activeFileIdByWorktree: { [WT]: openFile.id },
+ activeGroupIdByWorktree: { [WT]: activeGroupId },
+ activeTabId: terminalTabId,
+ activeTabIdByWorktree: { [WT]: terminalTabId },
+ activeTabType: 'editor',
+ activeTabTypeByWorktree: { [WT]: 'editor' },
+ openFiles: [openFile],
+ tabsByWorktree: {
+ [WT]: [
+ {
+ id: terminalTabId,
+ ptyId: 'remote:web-env-1@@terminal-1',
+ worktreeId: WT,
+ title: 'host shell',
+ customTitle: null,
+ color: null,
+ sortOrder: 0,
+ createdAt: NOW
+ }
+ ]
+ },
+ unifiedTabsByWorktree: { [WT]: [mirroredTerminalTab(terminalTabId), editorTab] },
+ groupsByWorktree: {
+ [WT]: [
+ {
+ id: EDITOR_GROUP,
+ worktreeId: WT,
+ activeTabId: editorTab.id,
+ tabOrder: [terminalTabId, editorTab.id],
+ recentTabIds: [terminalTabId, editorTab.id]
+ },
+ {
+ id: PREVIEW_GROUP,
+ worktreeId: WT,
+ activeTabId: null,
+ tabOrder: []
+ }
+ ]
+ },
+ layoutByWorktree: {
+ [WT]: {
+ type: 'split',
+ direction: 'horizontal',
+ first: { type: 'leaf', groupId: EDITOR_GROUP },
+ second: { type: 'leaf', groupId: PREVIEW_GROUP },
+ ratio: 0.5
+ }
+ }
+ })
+}
+
+function unfocusedHtmlPreviewSnapshot() {
+ return makeSnapshot(
+ [
+ {
+ type: 'terminal',
+ id: HOST_TERMINAL_SURFACE,
+ title: 'host shell',
+ parentTabId: HOST_TERMINAL_PARENT,
+ leafId: LEAF_ID,
+ isActive: true,
+ status: 'ready',
+ terminal: 'terminal-1'
+ },
+ {
+ type: 'browser',
+ id: HOST_BROWSER_TAB,
+ title: 'paired-html-focus.html',
+ browserWorkspaceId: 'host-html-workspace',
+ browserPageId: HOST_BROWSER_PAGE,
+ url: 'file:///repo/paired-html-focus.html',
+ loading: false,
+ canGoBack: false,
+ canGoForward: false,
+ isActive: false
+ }
+ ],
+ {
+ activeGroupId: 'host-group-1',
+ activeTabId: HOST_TERMINAL_SURFACE,
+ activeTabType: 'terminal',
+ tabGroups: [
+ {
+ id: 'host-group-1',
+ activeTabId: HOST_TERMINAL_PARENT,
+ tabOrder: [HOST_TERMINAL_PARENT, HOST_BROWSER_TAB]
+ }
+ ]
+ }
+ )
+}
+
+function previewFocus(state: WebSessionTabsSyncState): {
+ activeGroupId: string | null
+ activeTabId: string | null
+ activeTabType: WebSessionTabsSyncState['activeTabType'] | null
+} {
+ const activeGroup = (state.groupsByWorktree[WT] ?? []).find(
+ (group) => group.id === state.activeGroupIdByWorktree[WT]
+ )
+ return {
+ activeGroupId: activeGroup?.id ?? null,
+ activeTabId: activeGroup?.activeTabId ?? null,
+ activeTabType: state.activeTabTypeByWorktree[WT] ?? null
+ }
+}
+
+describe('applyWebSessionTabsSnapshot — unfocused HTML side preview', () => {
+ beforeEach(resetWebSessionTabsSyncTestState)
+
+ it('keeps the source editor focused when the empty preview split is still the active group', () => {
+ // Why: Open Preview to the Side creates the right-hand group first. Until the
+ // host browser lands, that group is empty — a host snapshot with terminal still
+ // active must not treat that emptiness as "the user focused a terminal".
+ recordWebSessionBrowserPlacement({
+ environmentId: ENV,
+ worktreeId: WT,
+ remotePageId: HOST_BROWSER_PAGE,
+ groupId: PREVIEW_GROUP,
+ callerCreatedGroup: true
+ })
+ const state = editorFocusedSplitState(PREVIEW_GROUP)
+
+ const patch = applyWebSessionTabsSnapshot(
+ state,
+ unfocusedHtmlPreviewSnapshot(),
+ ENV,
+ NOW + 10
+ ) as Partial
+ const next = { ...state, ...patch }
+
+ expect(previewFocus(next)).toEqual({
+ activeGroupId: EDITOR_GROUP,
+ activeTabId: EDITOR_TAB_ID,
+ activeTabType: 'editor'
+ })
+ expect(
+ next.unifiedTabsByWorktree[WT]?.find((tab) => tab.contentType === 'browser')
+ ).toMatchObject({ id: HOST_BROWSER_TAB, groupId: PREVIEW_GROUP })
+ })
+
+ it('still follows a host terminal snapshot when the empty split is not a reserved preview', () => {
+ const state = editorFocusedSplitState(PREVIEW_GROUP)
+
+ const patch = applyWebSessionTabsSnapshot(
+ state,
+ unfocusedHtmlPreviewSnapshot(),
+ ENV,
+ NOW + 10
+ ) as Partial
+ const next = { ...state, ...patch }
+
+ expect(previewFocus(next).activeGroupId).not.toBe(EDITOR_GROUP)
+ expect(previewFocus(next).activeTabType).toBe('editor')
+ })
+
+ it('keeps the source editor focused when the editor group is still active', () => {
+ recordWebSessionBrowserPlacement({
+ environmentId: ENV,
+ worktreeId: WT,
+ remotePageId: HOST_BROWSER_PAGE,
+ groupId: PREVIEW_GROUP,
+ callerCreatedGroup: true
+ })
+ const state = editorFocusedSplitState(EDITOR_GROUP)
+
+ const patch = applyWebSessionTabsSnapshot(
+ state,
+ unfocusedHtmlPreviewSnapshot(),
+ ENV,
+ NOW + 10
+ ) as Partial
+ const next = { ...state, ...patch }
+
+ expect(previewFocus(next)).toEqual({
+ activeGroupId: EDITOR_GROUP,
+ activeTabId: EDITOR_TAB_ID,
+ activeTabType: 'editor'
+ })
+ })
+})
diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts
index d4bdc8d971a..cff237cb129 100644
--- a/src/renderer/src/runtime/web-session-tabs-sync.ts
+++ b/src/renderer/src/runtime/web-session-tabs-sync.ts
@@ -64,6 +64,7 @@ import {
clearWebSessionFocusIntent,
clearWebSessionFocusIntentsForOwner,
peekWebSessionFocusIntent,
+ resolveWebSessionSiblingVisibleTabId,
resolveWebSessionVisibleTabId
} from './web-session-focus-intent'
import {
@@ -2908,6 +2909,16 @@ function applyWebSessionTabsSnapshotWithContext(
worktreeId,
nextUnifiedTabs ?? []
)
+ const activeGroupId = state.activeGroupIdByWorktree[worktreeId]
+ // Why: Open Preview to the Side can activate an empty reserved group before the host
+ // browser lands. A snapshot that still has the host terminal active must not treat
+ // that emptiness as a terminal focus change.
+ const reservedEmptyPreviewFallbackTabId =
+ currentVisibleUnifiedTabId == null &&
+ activeGroupId != null &&
+ isWebSessionBrowserPlacementGroupReserved({ worktreeId, groupId: activeGroupId })
+ ? resolveWebSessionSiblingVisibleTabId(state, worktreeId, nextUnifiedTabs ?? [])
+ : null
// Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab.
const intentUnifiedTabId = honorSnapshotActiveFocus
? navigationIntentTab?.type === 'browser'
@@ -2921,6 +2932,7 @@ function applyWebSessionTabsSnapshotWithContext(
const nextActiveUnifiedTabId =
intentUnifiedTabId ??
currentVisibleUnifiedTabId ??
+ reservedEmptyPreviewFallbackTabId ??
(snapshot.activeTabType === 'browser'
? (activeMirroredBrowserTabId ??
mirroredBrowserTabs[0]?.unifiedTab.id ??
diff --git a/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts
new file mode 100644
index 00000000000..0d29c0d7784
--- /dev/null
+++ b/src/renderer/src/store/slices/tabs-empty-split-activation.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type * as AgentStatusModule from '@/lib/agent-status'
+import { createTabsSliceMockApi } from './tabs-slice-test-harness'
+import { createTestStore } from './store-test-helpers'
+
+vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
+
+vi.mock('@/lib/agent-status', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ detectAgentStatusFromTitle: vi.fn().mockReturnValue(null)
+ }
+})
+
+createTabsSliceMockApi()
+
+const WT = 'repo1::/tmp/feature'
+
+describe('createEmptySplitGroup', () => {
+ let store: ReturnType
+
+ beforeEach(() => {
+ store = createTestStore()
+ })
+
+ it('activates the new group by default so terminal splits land in the empty pane', () => {
+ const tab = store.getState().createUnifiedTab(WT, 'terminal')
+ const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
+
+ const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right')
+
+ expect(targetGroupId).toBeTruthy()
+ expect(store.getState().activeGroupIdByWorktree[WT]).toBe(targetGroupId)
+ expect(
+ store.getState().groupsByWorktree[WT].find((group) => group.id === sourceGroupId)
+ ).toEqual(expect.objectContaining({ tabOrder: [tab.id] }))
+ })
+
+ it('keeps the source group focused when activate is false', () => {
+ store.getState().createUnifiedTab(WT, 'editor', { id: 'file-a.ts', label: 'file-a.ts' })
+ const sourceGroupId = store.getState().groupsByWorktree[WT][0].id
+
+ const targetGroupId = store.getState().createEmptySplitGroup(WT, sourceGroupId, 'right', {
+ activate: false
+ })
+
+ expect(targetGroupId).toBeTruthy()
+ expect(store.getState().activeGroupIdByWorktree[WT]).toBe(sourceGroupId)
+ expect(store.getState().groupsByWorktree[WT].map((group) => group.id)).toEqual([
+ sourceGroupId,
+ targetGroupId
+ ])
+ })
+})
diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts
index 221c3fd7c4a..7a1c5b1150a 100644
--- a/src/renderer/src/store/slices/tabs.ts
+++ b/src/renderer/src/store/slices/tabs.ts
@@ -157,7 +157,8 @@ export type TabsSlice = {
createEmptySplitGroup: (
worktreeId: string,
sourceGroupId: string,
- direction: TabSplitDirection
+ direction: TabSplitDirection,
+ opts?: { activate?: boolean }
) => string | null
moveUnifiedTabToGroup: (
tabId: string,
@@ -1645,7 +1646,7 @@ export const createTabsSlice: StateCreator = (set,
return true
},
- createEmptySplitGroup: (worktreeId, sourceGroupId, direction) => {
+ createEmptySplitGroup: (worktreeId, sourceGroupId, direction, opts) => {
const newGroupId = createBrowserUuid()
const newGroup: TabGroup = {
id: newGroupId,
@@ -1653,6 +1654,7 @@ export const createTabsSlice: StateCreator = (set,
activeTabId: null,
tabOrder: []
}
+ const shouldActivate = opts?.activate !== false
set((state) => {
const existing = state.groupsByWorktree[worktreeId] ?? []
const currentLayout =
@@ -1669,7 +1671,14 @@ export const createTabsSlice: StateCreator = (set,
...state.layoutByWorktree,
[worktreeId]: replaceLeaf(currentLayout, sourceGroupId, replacement)
},
- activeGroupIdByWorktree: { ...state.activeGroupIdByWorktree, [worktreeId]: newGroupId }
+ ...(shouldActivate
+ ? {
+ activeGroupIdByWorktree: {
+ ...state.activeGroupIdByWorktree,
+ [worktreeId]: newGroupId
+ }
+ }
+ : {})
}
})
get().recordFeatureInteraction?.('terminal-panes')
From 8d1557c225f5b6e02cc45c43b0a80b3dd0051121 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:00:23 -0700
Subject: [PATCH 25/27] fix(skills): discover Hermes home skills (#15862)
Co-authored-by: kriptoburak
---
src/main/skills/discovery.test.ts | 75 +++++++++++++++++++
src/main/skills/discovery.ts | 4 +-
.../skill-discovery-concurrency.test.ts | 7 +-
src/main/skills/skill-discovery-sources.ts | 14 +++-
.../skill-discovery-wsl-plugins.test.ts | 9 ++-
.../skill-provider-runtime-roots.test.ts | 67 +++++++++++++++++
.../skills/skill-provider-runtime-roots.ts | 43 +++++++++++
7 files changed, 214 insertions(+), 5 deletions(-)
diff --git a/src/main/skills/discovery.test.ts b/src/main/skills/discovery.test.ts
index 3d905a01d84..740c4af7cd0 100644
--- a/src/main/skills/discovery.test.ts
+++ b/src/main/skills/discovery.test.ts
@@ -48,11 +48,16 @@ function makeRepo(path: string, connectionId: string | null = null): Repo {
beforeEach(() => {
// Roots are shared between scans for a few seconds; each case owns its own tree.
clearSkillRootScanCache()
+ // Why: the Hermes root is the only home root that env vars can move outside the
+ // per-case tree, so a developer's real install must not leak into these scans.
+ vi.stubEnv('HERMES_HOME', '')
+ vi.stubEnv('LOCALAPPDATA', '')
vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
afterEach(() => {
unavailableRootPath = null
+ vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -367,6 +372,7 @@ describe('skill discovery', () => {
'/home/test/.config/opencode/skills',
'/home/test/.pi/agent/skills',
'/home/test/.omp/agent/skills',
+ '/home/test/.hermes/skills',
'/home/test/.gemini/skills',
'/home/test/.gemini/antigravity/skills',
'/home/test/.cursor/skills',
@@ -393,6 +399,9 @@ describe('skill discovery', () => {
expect(
roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.omp/agent/skills')?.owner
).toBe('omp')
+ expect(
+ roots.find((root) => root.path.replace(/\\/g, '/') === '/home/test/.hermes/skills')?.owner
+ ).toBe('hermes')
})
it('does not add runtime-owned repository paths to local scan roots', () => {
@@ -471,6 +480,72 @@ describe('skill discovery', () => {
expect(skill?.providers).toEqual(['agent-skills'])
})
+ it('discovers Skills installed in the Hermes home', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
+ const home = join(root, 'home')
+ const skillDir = join(home, '.hermes', 'skills', 'social-media', 'research')
+ await mkdir(skillDir, { recursive: true })
+ await writeFile(
+ join(skillDir, 'SKILL.md'),
+ ['---', 'name: social-research', 'description: Research social sources.', '---', ''].join(
+ '\n'
+ )
+ )
+
+ const result = await discoverSkills({
+ homeDir: home,
+ cwd: join(root, 'missing-cwd')
+ })
+
+ expect(result.skills).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'social-research',
+ sourceKind: 'home',
+ sourceLabel: 'Hermes home',
+ directoryPath: skillDir,
+ providers: ['agent-skills']
+ })
+ ])
+ )
+ expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({
+ owner: 'hermes',
+ exists: true
+ })
+ })
+
+ it('discovers Hermes Skills under a relocated HERMES_HOME profile', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
+ const home = join(root, 'home')
+ const hermesHome = join(root, 'profiles', 'coder')
+ const skillDir = join(hermesHome, 'skills', 'research')
+ await mkdir(skillDir, { recursive: true })
+ await writeFile(
+ join(skillDir, 'SKILL.md'),
+ ['---', 'name: profile-research', 'description: Research sources.', '---', ''].join('\n')
+ )
+ vi.stubEnv('HERMES_HOME', hermesHome)
+
+ const result = await discoverSkills({
+ homeDir: home,
+ cwd: join(root, 'missing-cwd')
+ })
+
+ expect(result.skills).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: 'profile-research',
+ sourceLabel: 'Hermes home',
+ directoryPath: skillDir
+ })
+ ])
+ )
+ expect(result.sources.find((source) => source.id === 'home-hermes')).toMatchObject({
+ path: join(hermesHome, 'skills'),
+ exists: true
+ })
+ })
+
it('discovers worktree .agents skill symlinks from the requested cwd', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-skills-'))
const home = join(root, 'home')
diff --git a/src/main/skills/discovery.ts b/src/main/skills/discovery.ts
index c5b8515c319..77e1e477ffd 100644
--- a/src/main/skills/discovery.ts
+++ b/src/main/skills/discovery.ts
@@ -34,11 +34,11 @@ const MAX_MARKDOWN_BYTES = 256 * 1024
// them for a few seconds is what bounds that fan-out.
export const SKILL_ROOT_SCAN_TTL_MS = 10_000
// Why: sized off the root formula, not a round number. One scan builds
-// `12 fixed home roots + 2 per local repo (+ cwd) + plugin roots`, so a bound
+// `17 fixed home roots + 7 per local repo (+ cwd) + plugin roots`, so a bound
// smaller than a single scan's root count makes that scan evict its own earlier
// entries and the cache degrades to a ~0% hit rate — exactly the walk this
// exists to prevent. The live key space is the union across targets — the fixed
-// home roots plus two per repo plus two per distinct workspace cwd — so this holds
+// home roots plus seven per repo plus seven per distinct workspace cwd — so this holds
// a few hundred repos with panes open, not an unbounded install. Past that the LRU
// keeps the hot home roots and the repo roots thrash, which degrades rather than
// breaks. Most repo roots do not exist, and a missing root caches as
diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts
index da7079331bd..a51d6dc9d52 100644
--- a/src/main/skills/skill-discovery-concurrency.test.ts
+++ b/src/main/skills/skill-discovery-concurrency.test.ts
@@ -60,11 +60,16 @@ function readdirCountUnder(path: string): number {
beforeEach(() => {
clearSkillRootScanCache()
readdirPaths.length = 0
+ // Why: the Hermes root is the one home root an env var can move outside the
+ // fixture, so a developer with a real Hermes install would score it `present`.
+ vi.stubEnv('HERMES_HOME', '')
+ vi.stubEnv('LOCALAPPDATA', '')
vi.spyOn(console, 'info').mockImplementation(() => undefined)
})
afterEach(() => {
clearSkillRootScanCache()
+ vi.unstubAllEnvs()
vi.restoreAllMocks()
})
@@ -180,7 +185,7 @@ describe('bounded concurrent skill discovery', () => {
const line = String(info.mock.calls.at(0)?.at(0))
// `present` is the signal that separates "big tree" from "big root set", and
// is not derivable from the other counts.
- expect(line).toContain('[skills] scan roots=23 present=3 walked=23 skills=3')
+ expect(line).toContain('[skills] scan roots=24 present=3 walked=24 skills=3')
expect(line).toContain('home-claude')
expect(line).not.toContain(home)
expect(line).not.toContain(tmpdir())
diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts
index 91221a55413..9d9781c3687 100644
--- a/src/main/skills/skill-discovery-sources.ts
+++ b/src/main/skills/skill-discovery-sources.ts
@@ -11,7 +11,11 @@ import type { AgentType } from '../../shared/agent-status-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
-import { resolveEnvironmentSkillProviderRoots } from './skill-provider-runtime-roots'
+import {
+ resolveDefaultHermesSkillsRoot,
+ resolveEnvironmentHermesSkillsRoot,
+ resolveEnvironmentSkillProviderRoots
+} from './skill-provider-runtime-roots'
export type SkillScanRoot = Omit
type SkillDiscoveryPathApi = Pick
@@ -77,6 +81,13 @@ export function buildSkillDiscoverySources(
const cwd = args.cwd ?? process.cwd()
const providerRootOverrides =
args.providerRootOverrides ?? (args.pathApi ? {} : resolveEnvironmentSkillProviderRoots())
+ // Why: HERMES_HOME moves the whole profile tree, so the default home path
+ // finds nothing for `hermes -p `. Only this process's own host can
+ // read it — a custom pathApi means the home belongs to another host, whose
+ // Hermes install is POSIX-shaped even when this process runs on Windows.
+ const hermesSkillsRoot = args.pathApi
+ ? pathApi.join(home, '.hermes', 'skills')
+ : (resolveEnvironmentHermesSkillsRoot() ?? resolveDefaultHermesSkillsRoot({ homeDir: home }))
const roots: SkillScanRoot[] = [
source(
'home-codex',
@@ -144,6 +155,7 @@ export function buildSkillDiscoverySources(
['agent-skills'],
'omp'
),
+ source('home-hermes', 'Hermes home', hermesSkillsRoot, 'home', ['agent-skills'], 'hermes'),
source(
'home-prime-agent',
'Prime Agent home',
diff --git a/src/main/skills/skill-discovery-wsl-plugins.test.ts b/src/main/skills/skill-discovery-wsl-plugins.test.ts
index d3b5913fc57..f3ee9024ff2 100644
--- a/src/main/skills/skill-discovery-wsl-plugins.test.ts
+++ b/src/main/skills/skill-discovery-wsl-plugins.test.ts
@@ -1,6 +1,6 @@
import type { WslResult } from '../wsl/wsl-runner'
import { posix as pathPosix } from 'node:path'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const runWslProcessMock = vi.hoisted(() => vi.fn())
@@ -19,10 +19,15 @@ function wslResult(stdout: string): WslResult {
describe('WSL Claude plugin skill discovery', () => {
beforeEach(() => runWslProcessMock.mockReset())
+ afterEach(() => vi.unstubAllEnvs())
it('reads enabled plugin metadata and scans the selected install inside the distro', async () => {
const homeDir = '/home/alice'
const cwd = '/work/orca'
+ // Why: a Windows host's own Hermes location says nothing about the distro's,
+ // so neither variable may reach the posix scan script.
+ vi.stubEnv('HERMES_HOME', 'C:\\Users\\alice\\hermes')
+ vi.stubEnv('LOCALAPPDATA', 'C:\\Users\\alice\\AppData\\Local')
const pluginId = 'compound-engineering@compound-engineering-plugin'
const installPath = '/home/alice/.claude/plugins/cache/compound/3.14.3'
const installed = JSON.stringify({
@@ -58,6 +63,8 @@ describe('WSL Claude plugin skill discovery', () => {
expect(runWslProcessMock).toHaveBeenCalledTimes(2)
const scanScript = runWslProcessMock.mock.calls[1]?.[0].script as string
+ expect(scanScript).toContain('/home/alice/.hermes/skills')
+ expect(scanScript).not.toContain('AppData')
expect(scanScript).toContain(`${installPath}/skills`)
expect(result.skills).toEqual([
expect.objectContaining({
diff --git a/src/main/skills/skill-provider-runtime-roots.test.ts b/src/main/skills/skill-provider-runtime-roots.test.ts
index 07d9d422dac..f933d4b2ff0 100644
--- a/src/main/skills/skill-provider-runtime-roots.test.ts
+++ b/src/main/skills/skill-provider-runtime-roots.test.ts
@@ -1,6 +1,8 @@
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
+ resolveDefaultHermesSkillsRoot,
+ resolveEnvironmentHermesSkillsRoot,
resolveEnvironmentSkillProviderRoots,
resolveWslGrokSkillProviderRoot,
withClaudeSkillProviderRoot
@@ -30,6 +32,71 @@ describe('skill provider runtime roots', () => {
})
})
+ it('maps a relocated HERMES_HOME to its skill root and ignores relative ones', () => {
+ expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: join('/srv', 'hermes') })).toBe(
+ join('/srv', 'hermes', 'skills')
+ )
+ expect(resolveEnvironmentHermesSkillsRoot({ HERMES_HOME: '../hermes' })).toBeNull()
+ expect(resolveEnvironmentHermesSkillsRoot({})).toBeNull()
+ })
+
+ it('defaults the Hermes skills root to the home dotfolder off Windows', () => {
+ expect(
+ resolveDefaultHermesSkillsRoot({
+ homeDir: join('/users', 'alice'),
+ platform: 'linux',
+ env: { LOCALAPPDATA: join('/local') },
+ directoryExists: () => true
+ })
+ ).toBe(join('/users', 'alice', '.hermes', 'skills'))
+ })
+
+ it('defaults the Hermes skills root under LOCALAPPDATA on Windows', () => {
+ const resolved = resolveDefaultHermesSkillsRoot({
+ homeDir: join('/users', 'alice'),
+ platform: 'win32',
+ env: { LOCALAPPDATA: join('/local') },
+ directoryExists: (candidate) => candidate === join('/local', 'hermes')
+ })
+ expect(resolved).toBe(join('/local', 'hermes', 'skills'))
+ })
+
+ it('keeps a pre-LOCALAPPDATA Windows dotfolder install discoverable', () => {
+ const resolved = resolveDefaultHermesSkillsRoot({
+ homeDir: join('/users', 'alice'),
+ platform: 'win32',
+ env: { LOCALAPPDATA: join('/local') },
+ directoryExists: (candidate) => candidate === join('/users', 'alice', '.hermes')
+ })
+ expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills'))
+ })
+
+ it('prefers the LOCALAPPDATA tree on Windows when both layouts exist', () => {
+ const resolved = resolveDefaultHermesSkillsRoot({
+ homeDir: join('/users', 'alice'),
+ platform: 'win32',
+ env: { LOCALAPPDATA: join('/local') },
+ directoryExists: () => true
+ })
+ expect(resolved).toBe(join('/local', 'hermes', 'skills'))
+ })
+
+ it('falls back to the dotfolder when Windows exposes no usable LOCALAPPDATA', () => {
+ const seen: string[] = []
+ const resolved = resolveDefaultHermesSkillsRoot({
+ homeDir: join('/users', 'alice'),
+ platform: 'win32',
+ env: { LOCALAPPDATA: 'relative\\local' },
+ directoryExists: (candidate) => {
+ seen.push(candidate)
+ return true
+ }
+ })
+ expect(resolved).toBe(join('/users', 'alice', '.hermes', 'skills'))
+ // A rejected LOCALAPPDATA must not cost a stat: there is nothing to compare.
+ expect(seen).toEqual([])
+ })
+
it('maps the WSL login shell GROK_HOME to a host-readable skill root', async () => {
await expect(
resolveWslGrokSkillProviderRoot('Ubuntu-24.04', async () => '/srv/grok\n')
diff --git a/src/main/skills/skill-provider-runtime-roots.ts b/src/main/skills/skill-provider-runtime-roots.ts
index dd58e84d211..d4dec7bfba7 100644
--- a/src/main/skills/skill-provider-runtime-roots.ts
+++ b/src/main/skills/skill-provider-runtime-roots.ts
@@ -1,3 +1,4 @@
+import { statSync } from 'node:fs'
import { isAbsolute, join, resolve } from 'node:path'
import { toWindowsWslPath } from '../../shared/wsl-paths'
import { runWslProcess } from '../wsl/wsl-runner'
@@ -37,6 +38,48 @@ export function resolveEnvironmentSkillProviderRoots(
}
}
+// Why: `HERMES_HOME` relocates a whole Hermes profile tree (`hermes -p coder`),
+// and the rest of Orca already treats it as authoritative. Hermes is not an
+// install provider, so it stays out of `SkillProviderRootOverrides`.
+export function resolveEnvironmentHermesSkillsRoot(
+ env: NodeJS.ProcessEnv = process.env
+): string | null {
+ const hermesHome = normalizedRoot(env.HERMES_HOME)
+ return hermesHome ? join(hermesHome, 'skills') : null
+}
+
+function isExistingDirectory(candidate: string): boolean {
+ return statSync(candidate, { throwIfNoEntry: false })?.isDirectory() === true
+}
+
+/**
+ * The Hermes home when `HERMES_HOME` is unset. Windows installs land in
+ * `%LOCALAPPDATA%\hermes`, not a dotfolder in the user profile, so the POSIX
+ * `~/.hermes` default discovers nothing there. The dotfolder still wins on
+ * Windows when it exists and the LOCALAPPDATA tree does not, which is the
+ * pre-LOCALAPPDATA install Hermes itself keeps honouring rather than orphaning.
+ */
+export function resolveDefaultHermesSkillsRoot(input: {
+ homeDir: string
+ env?: NodeJS.ProcessEnv
+ platform?: NodeJS.Platform
+ directoryExists?: (candidate: string) => boolean
+}): string {
+ const dotfolderHome = join(input.homeDir, '.hermes')
+ if ((input.platform ?? process.platform) !== 'win32') {
+ return join(dotfolderHome, 'skills')
+ }
+ const localAppData = normalizedRoot((input.env ?? process.env).LOCALAPPDATA)
+ if (!localAppData) {
+ return join(dotfolderHome, 'skills')
+ }
+ const localAppDataHome = join(localAppData, 'hermes')
+ const directoryExists = input.directoryExists ?? isExistingDirectory
+ return !directoryExists(localAppDataHome) && directoryExists(dotfolderHome)
+ ? join(dotfolderHome, 'skills')
+ : join(localAppDataHome, 'skills')
+}
+
export function withClaudeSkillProviderRoot(
roots: SkillProviderRootOverrides,
claudeConfigDirectory: string | null | undefined
From af2e825626f620278deb1bfd42259cdb1bd93fb7 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:00:27 -0700
Subject: [PATCH 26/27] fix(worktree): stop warning about a stale local base
branch that does not exist yet (#15331) (#15871)
Co-authored-by: vam
---
.../worktree-add-local-base-refresh.test.ts | 116 +++++++++++++++++-
src/main/git/worktree-base-ref-probe.ts | 29 +++++
src/main/git/worktree.ts | 12 +-
src/main/ipc/worktree-remote.ts | 31 +++--
.../worktrees-ssh-local-base-refresh.test.ts | 114 +++++++++++++++++
5 files changed, 290 insertions(+), 12 deletions(-)
diff --git a/src/main/git/worktree-add-local-base-refresh.test.ts b/src/main/git/worktree-add-local-base-refresh.test.ts
index bfe76b397a7..cec1151e0eb 100644
--- a/src/main/git/worktree-add-local-base-refresh.test.ts
+++ b/src/main/git/worktree-add-local-base-refresh.test.ts
@@ -347,13 +347,19 @@ describe('addWorktree', () => {
it('skips updating the local branch when it has diverged', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not a fast-forward'))
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
resolveCreationBaseConfigWrite()
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
- await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
+ const result = await addWorktree('/repo', '/repo-feature', 'feature/test', 'origin/main', true)
+ expect(result.localBaseRefRefresh).toEqual({
+ status: 'skipped_not_fast_forward',
+ baseRef: 'origin/main',
+ localBranch: 'main'
+ })
expect(gitExecFileAsyncMock.mock.calls).toEqual([
[
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
@@ -363,6 +369,10 @@ describe('addWorktree', () => {
['rev-list', '--left-right', '--count', 'refs/heads/main...refs/remotes/origin/main'],
expect.objectContaining({ cwd: '/repo' })
],
+ [
+ ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
+ expect.objectContaining({ cwd: '/repo' })
+ ],
[
[
'worktree',
@@ -396,12 +406,112 @@ describe('addWorktree', () => {
])
})
+ // #15331: evaluation runs before `-b ` exists, so rev-list fails on the missing local ref.
+ it('does not warn when worktree add itself creates the local base branch', async () => {
+ gitExecFileAsyncMock
+ .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/feature-x^{commit}
+ .mockRejectedValueOnce(
+ new Error(
+ "fatal: ambiguous argument 'refs/heads/feature-x...refs/remotes/origin/feature-x': unknown revision or path not in the working tree."
+ )
+ ) // rev-list: refs/heads/feature-x does not exist yet
+ .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/feature-x (missing)
+ .mockResolvedValueOnce({ stdout: '' }) // worktree add
+ .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base
+ .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
+ .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
+
+ const result = await addWorktree(
+ '/repo',
+ '/repo-feature-x',
+ 'feature-x',
+ 'origin/feature-x',
+ true
+ )
+
+ expect(result.localBaseRefRefresh).toBeUndefined()
+ expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
+ 'worktree',
+ 'add',
+ '--no-track',
+ '-b',
+ 'feature-x',
+ '/repo-feature-x',
+ 'refs/remotes/origin/feature-x'
+ ])
+ // Nothing was refreshed, so no ref mutation.
+ expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('update-ref')
+ expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0][0])).not.toContain('reset')
+ })
+
+ // #15331: same missing-local-branch class, but the new branch name differs from the base's.
+ it('does not warn when the local base branch does not exist in a fetch-only clone', async () => {
+ gitExecFileAsyncMock
+ .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
+ .mockRejectedValueOnce(new Error('unknown revision refs/heads/main')) // rev-list: no local main
+ .mockResolvedValueOnce({ stdout: '' }) // for-each-ref refs/heads/main (missing)
+ .mockResolvedValueOnce({ stdout: '' }) // worktree add
+ .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base
+ .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
+ .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
+
+ const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true)
+
+ expect(result.localBaseRefRefresh).toBeUndefined()
+ expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toContainEqual([
+ 'for-each-ref',
+ '--count=1',
+ '--format=%(refname)',
+ 'refs/heads/main'
+ ])
+ })
+
+ // A failed probe is not proof of absence, so the warning must survive it.
+ it('keeps the warning when the local base ref probe itself fails', async () => {
+ gitExecFileAsyncMock
+ .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
+ .mockRejectedValueOnce(new Error('rev-list failed')) // drift probe
+ .mockRejectedValueOnce(new Error('fatal: not a git repository')) // for-each-ref probe could not run
+ .mockResolvedValueOnce({ stdout: '' }) // worktree add
+ .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base
+ .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
+ .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
+
+ const result = await addWorktree('/repo', '/repo-feature', 'my-feature', 'origin/main', true)
+
+ expect(result.localBaseRefRefresh).toEqual({
+ status: 'skipped_not_fast_forward',
+ baseRef: 'origin/main',
+ localBranch: 'main'
+ })
+ })
+
+ it('still suggests nothing but keeps the warning when the local base ref exists and diverged', async () => {
+ gitExecFileAsyncMock
+ .mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse --verify --quiet refs/remotes/origin/main^{commit}
+ .mockResolvedValueOnce({ stdout: '2\t3\n' }) // rev-list: 2 local-only commits
+ .mockResolvedValueOnce({ stdout: '' }) // worktree add
+ .mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base
+ .mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
+ .mockResolvedValueOnce({ stdout: '' }) // config --local set push.autoSetupRemote
+
+ const result = await addWorktree('/repo', '/repo-feature', 'main', 'origin/main', true)
+
+ // Same branch name as the base, but rev-list succeeded: real divergence must still warn.
+ expect(result.localBaseRefRefresh).toEqual({
+ status: 'skipped_not_fast_forward',
+ baseRef: 'origin/main',
+ localBranch: 'main'
+ })
+ })
+
it('skips local base refresh when captured OIDs are no longer ancestor-safe', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'abc123\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '0\t2\n' }) // stale rev-list result
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local\n' }) // rev-parse refs/heads/main^{commit}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'remote-main\n' }) // rev-parse refs/remotes/origin/main^{commit}
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not an ancestor')) // merge-base captured OIDs
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/main\n' }) // for-each-ref refs/heads/main (exists)
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
resolveCreationBaseConfigWrite()
gitExecFileAsyncMock.mockRejectedValueOnce(Object.assign(new Error('key unset'), { code: 1 })) // config --get push.autoSetupRemote (unset)
@@ -435,6 +545,10 @@ describe('addWorktree', () => {
['merge-base', '--is-ancestor', 'new-local', 'remote-main'],
expect.objectContaining({ cwd: '/repo' })
],
+ [
+ ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
+ expect.objectContaining({ cwd: '/repo' })
+ ],
[
[
'worktree',
diff --git a/src/main/git/worktree-base-ref-probe.ts b/src/main/git/worktree-base-ref-probe.ts
index 44b8d4d85e9..85c8c8baa8f 100644
--- a/src/main/git/worktree-base-ref-probe.ts
+++ b/src/main/git/worktree-base-ref-probe.ts
@@ -37,3 +37,32 @@ export async function hasWorktreeBaseCommitRef(
): Promise {
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
+
+export type WorktreeBaseRefPresence = 'present' | 'absent' | 'unknown'
+
+/**
+ * Distinguish "the ref does not exist" from "the probe itself failed".
+ *
+ * Why for-each-ref: it exits 0 whether or not the pattern matches, so an empty result
+ * proves absence while a rejection still means the probe never ran (broken repo, dead
+ * SSH transport). `rev-parse --verify --quiet` exits 1 for both, and reading that as
+ * "absent" would silently drop warnings the caller must still surface.
+ *
+ * Executor-injected so the SSH path can route the same argv through the relay.
+ */
+export async function probeWorktreeBaseRefPresence(
+ runGit: (args: string[]) => Promise<{ stdout: string }>,
+ qualifiedRef: string
+): Promise {
+ try {
+ const { stdout } = await runGit([
+ 'for-each-ref',
+ '--count=1',
+ '--format=%(refname)',
+ qualifiedRef
+ ])
+ return stdout.trim() === qualifiedRef ? 'present' : 'absent'
+ } catch {
+ return 'unknown'
+ }
+}
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index 1f96523899e..fcd2a0067fa 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -33,7 +33,7 @@ import {
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir, runWithGitReadCacheInvalidation } from './status'
-import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
+import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
@@ -265,6 +265,16 @@ async function evaluateLocalBaseRefRefreshability(
)
drift = parsedDrift
} catch {
+ // Why (#15331): the probes above also fail when refs/heads/ is simply absent; a branch that
+ // does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning.
+ // Only a proven absence suppresses: an unusable repo leaves the warning alone.
+ const presence = await probeWorktreeBaseRefPresence(
+ (args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)),
+ parsed.fullRef
+ )
+ if (presence === 'absent') {
+ return undefined
+ }
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts
index 72ef373758e..1d63cf97deb 100644
--- a/src/main/ipc/worktree-remote.ts
+++ b/src/main/ipc/worktree-remote.ts
@@ -35,6 +35,7 @@ import {
} from '../git/repo'
import { resolveLocalGitUsername, getSshGitUsername } from '../git/git-username'
import { hasCommitObjectViaGitExec } from '../git/commit-object-ref'
+import { probeWorktreeBaseRefPresence } from '../git/worktree-base-ref-probe'
import { resolveWorktreeCreateBase } from '../worktree-create-base'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
@@ -181,7 +182,8 @@ type RemoteLocalBaseRefRefreshability =
}
| {
refreshable: false
- result: LocalBaseRefRefreshResult
+ // undefined = nothing to refresh (no local branch yet), so the caller reports no status at all.
+ result: LocalBaseRefRefreshResult | undefined
}
function appendWorktreeCreateWarning(current: string | undefined, next: string): string {
@@ -698,8 +700,7 @@ async function hasRemoteWorktreeBaseRef(
repoPath: string,
baseRef: string
): Promise {
- const refExists = (qualifiedRef: string) =>
- hasRemoteTrackingRefSsh(provider, repoPath, qualifiedRef)
+ const refExists = (qualifiedRef: string) => hasCommitRefSsh(provider, repoPath, qualifiedRef)
const resolvedBaseRef = await resolveWorktreeAddBaseRef(baseRef, refExists)
if (resolvedBaseRef !== baseRef) {
return true
@@ -710,8 +711,8 @@ async function hasRemoteWorktreeBaseRef(
return hasRemoteCommitObject(provider, repoPath, baseRef)
}
-// Why: hasRemoteCommitObject resolves only SHAs, not symbolic remote-tracking refs; detect those directly for the fetch-failed local fallback.
-async function hasRemoteTrackingRefSsh(
+// Why: hasRemoteCommitObject resolves only SHAs, not symbolic refs; resolve any qualified ref (remote-tracking or local head) directly.
+async function hasCommitRefSsh(
provider: SshGitProvider,
repoPath: string,
ref: string
@@ -1262,7 +1263,7 @@ async function resolveRemoteWorktreeCreateBasePlan(
baseBranchCandidate
)
if (remoteTrackingBase) {
- if (await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
+ if (await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref)) {
return true
}
return hasRemoteWorktreeBaseRef(provider, repo.path, baseBranchCandidate)
@@ -1313,7 +1314,7 @@ export async function prefetchRemoteWorktreeCreateBase(
}
if (basePlan.remoteTrackingBase) {
if (
- (await hasRemoteTrackingRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
+ (await hasCommitRefSsh(provider, repo.path, basePlan.remoteTrackingBase.ref)) ||
!(await hasRemoteWorktreeBaseRef(provider, repo.path, basePlan.baseBranch))
) {
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
@@ -1333,7 +1334,7 @@ async function refreshLocalBaseRefForRemoteWorktreeCreate(
provider: SshGitProvider,
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
-): Promise {
+): Promise {
const evaluation = await evaluateRemoteLocalBaseRefRefreshability(
provider,
repoPath,
@@ -1393,6 +1394,16 @@ async function evaluateRemoteLocalBaseRefRefreshability(
}
}
} catch {
+ // Why (#15331): the probes above also fail when refs/heads/ is simply absent; the relay's
+ // `worktree add -b` is about to create it, so there is nothing stale to warn about. Only a proven
+ // absence suppresses: a dropped relay connection is not evidence the branch is missing.
+ const presence = await probeWorktreeBaseRefPresence(
+ (args) => provider.exec(args, repoPath),
+ fullRef
+ )
+ if (presence === 'absent') {
+ return { refreshable: false, result: undefined }
+ }
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
@@ -1550,7 +1561,7 @@ export async function createRemoteWorktree(
let baseFallback: WorktreeCreateBaseFallback | undefined
if (remoteTrackingBase) {
- const hasRemoteTrackingBaseRef = await hasRemoteTrackingRefSsh(
+ const hasRemoteTrackingBaseRef = await hasCommitRefSsh(
provider,
repo.path,
remoteTrackingBase.ref
@@ -1700,7 +1711,7 @@ export async function createRemoteWorktree(
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase)
} catch {
// Why: a refresh failure shouldn't block create if a usable (stale) local base ref exists; probe after registerRoot and hard-fail only when none does.
- if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
+ if (!(await hasCommitRefSsh(provider, repo.path, remoteTrackingBase.ref))) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
diff --git a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts
index b739b482884..0b76025a979 100644
--- a/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts
+++ b/src/main/ipc/worktrees-ssh-local-base-refresh.test.ts
@@ -477,4 +477,118 @@ describe('registerWorktreeHandlers', () => {
})
expect(result.localBaseRefUpdateSuggestion).toBeUndefined()
})
+ // #15331: the pre-create merge-base probe fails when refs/heads/ does not exist yet.
+ const buildMissingLocalBaseSshCase = (presence: 'absent' | 'present' | 'probe-failed') => {
+ const repo = {
+ id: 'repo-ssh',
+ path: '/remote/repo',
+ displayName: 'ssh',
+ badgeColor: '#000',
+ addedAt: 0,
+ connectionId: 'conn-1',
+ worktreeBaseRef: 'origin/main'
+ }
+ const provider = {
+ exec: vi.fn().mockImplementation(async (args: string[]) => {
+ if (args[0] === 'remote') {
+ return { stdout: 'origin\n', stderr: '' }
+ }
+ if (args[0] === 'for-each-ref' && args.at(-1) === 'refs/heads/main') {
+ if (presence === 'probe-failed') {
+ throw new Error('ssh: connection closed by remote host')
+ }
+ return { stdout: presence === 'present' ? 'refs/heads/main\n' : '', stderr: '' }
+ }
+ if (args[0] === 'rev-parse') {
+ const ref = args.at(-1) ?? ''
+ if (ref.startsWith('refs/heads/main')) {
+ return { stdout: presence === 'present' ? 'local-main\n' : '', stderr: '' }
+ }
+ // Other refs/heads probes are the new-branch conflict check; it must stay unresolvable.
+ return { stdout: ref.startsWith('refs/heads/') ? '' : 'remote-main\n', stderr: '' }
+ }
+ if (args[0] === 'merge-base') {
+ throw new Error('fatal: Not a valid object name refs/heads/main')
+ }
+ return { stdout: '', stderr: '' }
+ }),
+ fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
+ addWorktree: vi.fn().mockResolvedValue(undefined),
+ listWorktrees: vi.fn().mockResolvedValue([
+ {
+ path: '/remote/repo-improve-dashboard',
+ head: 'abc123',
+ branch: 'refs/heads/improve-dashboard',
+ isBare: false,
+ isMainWorktree: false
+ }
+ ]),
+ worktreeIsClean: vi.fn().mockResolvedValue({ clean: true }),
+ refreshLocalBaseRefForWorktreeCreate: vi.fn().mockResolvedValue(undefined)
+ }
+ store.getSettings.mockReturnValue({
+ branchPrefix: 'none',
+ nestWorkspaces: false,
+ refreshLocalBaseRefOnWorktreeCreate: true,
+ workspaceDir: '/workspace'
+ })
+ store.getRepos.mockReturnValue([repo])
+ store.getRepo.mockReturnValue(repo)
+ getSshGitProviderMock.mockReturnValue(provider)
+ getActiveMultiplexerMock.mockReturnValue({
+ request: vi.fn().mockResolvedValue(undefined),
+ notify: vi.fn()
+ })
+ store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
+ return provider
+ }
+
+ it('does not report an SSH local base refresh when the local base branch does not exist', async () => {
+ const provider = buildMissingLocalBaseSshCase('absent')
+
+ const result = (await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'improve-dashboard'
+ })) as CreateWorktreeResult
+
+ expect(provider.exec).toHaveBeenCalledWith(
+ ['for-each-ref', '--count=1', '--format=%(refname)', 'refs/heads/main'],
+ '/remote/repo'
+ )
+ expect(result.localBaseRefRefresh).toBeUndefined()
+ expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
+ })
+
+ it('keeps the SSH not-fast-forward status when the local base branch exists and diverged', async () => {
+ const provider = buildMissingLocalBaseSshCase('present')
+
+ const result = (await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'improve-dashboard'
+ })) as CreateWorktreeResult
+
+ expect(result.localBaseRefRefresh).toEqual({
+ status: 'skipped_not_fast_forward',
+ baseRef: 'origin/main',
+ localBranch: 'main'
+ })
+ expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
+ })
+
+ // Losing the relay mid-probe is not evidence the branch is missing.
+ it('keeps the SSH not-fast-forward status when the local base ref probe fails', async () => {
+ const provider = buildMissingLocalBaseSshCase('probe-failed')
+
+ const result = (await handlers['worktrees:create'](null, {
+ repoId: 'repo-ssh',
+ name: 'improve-dashboard'
+ })) as CreateWorktreeResult
+
+ expect(result.localBaseRefRefresh).toEqual({
+ status: 'skipped_not_fast_forward',
+ baseRef: 'origin/main',
+ localBranch: 'main'
+ })
+ expect(provider.refreshLocalBaseRefForWorktreeCreate).not.toHaveBeenCalled()
+ })
})
From 202d74a8a4be3fe6537e1b4eac0e9e4901cf7d96 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:00:31 -0700
Subject: [PATCH 27/27] fix(git): enable Windows long paths for worktree
creation (local, sparse, and SSH hosts) (#15866)
Co-authored-by: hwantage
---
src/main/git/add-sparse-worktree.test.ts | 43 +++++-
.../git/worktree-add-creation-config.test.ts | 91 +++++++++++-
src/main/git/worktree.ts | 14 +-
src/relay/git-handler-worktree-ops.test.ts | 134 ++++++++++++++----
src/relay/git-handler-worktree-ops.ts | 17 ++-
src/shared/windows-long-path-git-args.test.ts | 29 ++++
src/shared/windows-long-path-git-args.ts | 25 ++++
7 files changed, 320 insertions(+), 33 deletions(-)
create mode 100644 src/shared/windows-long-path-git-args.test.ts
create mode 100644 src/shared/windows-long-path-git-args.ts
diff --git a/src/main/git/add-sparse-worktree.test.ts b/src/main/git/add-sparse-worktree.test.ts
index 84aef686719..2541b822438 100644
--- a/src/main/git/add-sparse-worktree.test.ts
+++ b/src/main/git/add-sparse-worktree.test.ts
@@ -1,5 +1,5 @@
import type * as FsPromises from 'node:fs/promises'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -67,6 +67,10 @@ beforeEach(() => {
})
describe('addSparseWorktree', () => {
+ // Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv
+ // assertions below would fail for a maintainer running vitest on Windows.
+ let platformSpy: MockInstance<() => NodeJS.Platform>
+
beforeEach(() => {
resetWorktreeGitMocks({
gitExecFileAsyncMock,
@@ -75,6 +79,43 @@ describe('addSparseWorktree', () => {
statMock,
resolveGitDirMock
})
+ platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
+ })
+
+ afterEach(() => {
+ platformSpy.mockRestore()
+ })
+
+ it('enables long paths on the Windows commands that actually write the deep checkout', async () => {
+ // Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone
+ // left sparse creation failing with "Filename too long" (issue #15785).
+ platformSpy.mockReturnValue('win32')
+ gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
+
+ await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web'])
+
+ const calls = getGitCalls()
+ expect(calls).toEqual(
+ expect.arrayContaining([
+ 'git -c core.longpaths=true sparse-checkout set -- packages/web',
+ 'git -c core.longpaths=true checkout feature/test'
+ ])
+ )
+ })
+
+ it('omits the long-path option on non-Windows hosts', async () => {
+ gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
+
+ await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
+
+ const calls = getGitCalls()
+ expect(calls).toEqual(
+ expect.arrayContaining([
+ 'git sparse-checkout set -- packages/web',
+ 'git checkout feature/test'
+ ])
+ )
+ expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false)
})
it('separates sparse checkout directory operands from options', async () => {
diff --git a/src/main/git/worktree-add-creation-config.test.ts b/src/main/git/worktree-add-creation-config.test.ts
index f6b3d0d51d6..44394b7728d 100644
--- a/src/main/git/worktree-add-creation-config.test.ts
+++ b/src/main/git/worktree-add-creation-config.test.ts
@@ -1,5 +1,5 @@
// addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification.
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
const {
gitExecFileAsyncMock,
@@ -40,10 +40,19 @@ describe('addWorktree', () => {
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch..base
}
+ // Why: argv now depends on the host OS, so pin a non-Windows default or every
+ // exact-argv assertion below would fail for a maintainer running vitest on Windows.
+ let platformSpy: MockInstance<() => NodeJS.Platform>
+
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
gitExecFileSyncMock.mockReset()
translateWslOutputPathsMock.mockClear()
+ platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
+ })
+
+ afterEach(() => {
+ platformSpy.mockRestore()
})
it('creates the worktree without touching the local base ref by default', async () => {
@@ -99,6 +108,86 @@ describe('addWorktree', () => {
])
})
+ it('enables long paths for native Windows worktree creation', async () => {
+ platformSpy.mockReturnValue('win32')
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
+
+ await addWorktree(
+ 'C:\\repo',
+ 'C:\\repo-feature',
+ 'feature/test',
+ 'feature/test',
+ false,
+ false,
+ { checkoutExistingBranch: true }
+ )
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
+ { cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
+ )
+ })
+
+ it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => {
+ // Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that
+ // is exactly the MAX_PATH-prone case; Linux git parses and ignores the key.
+ platformSpy.mockReturnValue('win32')
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
+
+ await addWorktree(
+ 'C:\\repo',
+ 'C:\\repo-feature',
+ 'feature/test',
+ 'feature/test',
+ false,
+ false,
+ { checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
+ )
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
+ { cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
+ )
+ })
+
+ it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => {
+ platformSpy.mockReturnValue('win32')
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
+
+ const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo'
+ await addWorktree(
+ repoPath,
+ '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
+ 'feature/test',
+ 'feature/test',
+ false,
+ false,
+ { checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
+ )
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'],
+ { cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
+ )
+ })
+
+ it.each(['darwin', 'linux'] as const)(
+ 'does not pass the long-path option on %s',
+ async (platform) => {
+ platformSpy.mockReturnValue(platform)
+ gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
+
+ await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
+ checkoutExistingBranch: true
+ })
+
+ expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
+ ['worktree', 'add', '/repo-feature', 'feature/test'],
+ { cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
+ )
+ }
+ )
+
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
// Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
// `git worktree add` for minutes. Assert the runner receives a non-zero
diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts
index fcd2a0067fa..55c5d33cf79 100644
--- a/src/main/git/worktree.ts
+++ b/src/main/git/worktree.ts
@@ -14,6 +14,7 @@ import {
scheduleWorktreeTrashDeletion
} from '../worktree-trash'
import { parseWslPath } from '../wsl'
+import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
@@ -985,7 +986,8 @@ async function performAddWorktree(
): Promise {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
- const args = ['worktree', 'add']
+ // Why: enable long paths for this Windows checkout without changing user Git config.
+ const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add']
let effectiveBase: string | undefined
if (noCheckout) {
args.push('--no-checkout')
@@ -1091,15 +1093,21 @@ export async function addSparseWorktree(
options
)
created = true
+ // Why: `worktree add --no-checkout` writes no files, so these are the calls that
+ // actually materialize the deep path and need the long-path escape hatch.
+ const longPathArgs = windowsLongPathGitArgs(worktreePath)
await gitExecFileAsync(
['sparse-checkout', 'init', '--cone'],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
- ['sparse-checkout', 'set', '--', ...directories],
+ [...longPathArgs, 'sparse-checkout', 'set', '--', ...directories],
+ gitExecOptions(worktreePath, options)
+ )
+ await gitExecFileAsync(
+ [...longPathArgs, 'checkout', branch],
gitExecOptions(worktreePath, options)
)
- await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options))
return addResult
} catch (error) {
const wrapped: SparseWorktreeCreateError =
diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts
index cedcf8b797c..0e7dfd282fc 100644
--- a/src/relay/git-handler-worktree-ops.test.ts
+++ b/src/relay/git-handler-worktree-ops.test.ts
@@ -31,12 +31,16 @@ describe('addWorktreeOp', () => {
it('writes durable branch base config after creating an SSH new-branch worktree', async () => {
const git = vi.fn